feat(code-runtime): complete sandboxed Node execution and Session fixtures
This commit is contained in:
1 parent
f6904f4615
commit
8e19ec4962
82 files changed
+2580
-348
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-node/README.md
|
||||
README.md: d03fd010db7d1475cc153632ca28fc83f782704b
|
||||
README.zh.md: bfc58f8ba0a9adff4a687cc233cc7da56224564a
|
||||
README.md: 02064e378310c2d571caca61912f6b599329714e
|
||||
README.zh.md: d23df399936ec4f20e1dc6c802c6832f32a4ab42
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Worker-thread code execution for users and maintainers composing, sizing, or debugging the shipped TypeScript backend that runs each program in a fresh Node worker."
|
||||
description: "Run TypeScript programs in fresh Node processes with the session filesystem sandbox, managed cleanup, and configurable execution and output limits."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This package lets PTC compositions execute model-written TypeScript with host-provided bindings and receive the completion value, ordered logs, or a structured failure. Each request starts with no state from earlier runs, and failures such as syntax errors, budget expiry, aborts, memory exhaustion, and output overflow are returned instead of thrown. Treat executed code as bash-equivalent: the package limits environment exposure and resource use, but does not isolate code from the host. Configurable compute, wall-clock, heap, and output limits terminate the run and bound its results.
|
||||
Execute model-written TypeScript under the same platform sandbox policy as Bash, with host-provided functions available as async bindings. Each call starts a fresh Node process and returns captured logs, an exact JSON value, or a structured failure. Direct Node APIs remain available within the selected restrictions. Elapsed deadlines, output bounds and a V8 heap limit constrain execution; cancellation and completion terminate the managed process range. A requested restricted mode fails when its sandbox backend is unavailable.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,40 +25,51 @@ This package lets PTC compositions execute model-written TypeScript with host-pr
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this backend with the code-runtime seam when a composition should execute model-written TypeScript programs; PTC mode in `dsh-tools` then drives it through `ctx.codeRuntime` whenever the model calls `run_code`. Every execution cap is validated config, so you can size the runtime for your deployment from `cordis.yml`.
|
||||
Mount this provider in a composition that supplies `fs`, `subprocess`, `sandbox` and `sandboxPolicy`. PTC mode in `dsh-tools` supplies the calling Session's directory and standing policy; direct runtime consumers resolve those options before execution.
|
||||
|
||||
### Minimal configuration
|
||||
### Configuration
|
||||
|
||||
Configure the provider row after its required services are available:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-code-runtime'
|
||||
- name: '@deepseek-ai/dsh-code-runtime-node'
|
||||
config:
|
||||
computeMs: 60000 # busy-time budget (measured event-loop active time)
|
||||
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
|
||||
maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB)
|
||||
maxOldGenerationSizeMb: 512 # worker heap cap
|
||||
timeoutMs: 120000
|
||||
maxTimeoutMs: 600000
|
||||
maxOutputBytes: 67108864
|
||||
maxOldGenerationSizeMb: 512
|
||||
maxMessageBytes: 134217728
|
||||
maxPendingCalls: 128
|
||||
graceMs: 3000
|
||||
```
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `computeMs` | `60,000` | Busy-time budget: the run fails with `timeout` once the worker's measured event-loop active time exceeds it |
|
||||
| `maxWallMs` | `600,000` | Wall-clock ceiling, the backstop for waits that busy time cannot see; at most `2_147_483_647` |
|
||||
| `maxOutputBytes` | `67,108,864` | Hard cap for serialized logs plus the completion value or failure message; at least `4` |
|
||||
| `maxOldGenerationSizeMb` | `512` | Worker heap cap; overflow kills the worker and surfaces as `worker-exit` |
|
||||
| `timeoutMs` | `120,000` | Default elapsed execution deadline, including nested tool and approval waits |
|
||||
| `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 |
|
||||
| `maxPendingCalls` | `128` | Maximum simultaneous host binding calls |
|
||||
| `graceMs` | `3,000` | Managed termination and output-drain grace |
|
||||
| `nodeExecutable` | Current Node executable | Executable resolved in the subprocess execution world |
|
||||
| `bootstrapPath` | Package bootstrap | Optional absolute path to a preinstalled built bootstrap in that world |
|
||||
|
||||
Every field is validated and defaulted at load; there are no other tunables. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-code-runtime-node) is the exhaustive source for every accepted field.
|
||||
The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-code-runtime-node) defines accepted config fields. `resolve(request)` supplies cwd, the capped timeout and the execution policy; `run(spec)` accepts those resolved inputs and does not fill missing values.
|
||||
|
||||
### What a run returns
|
||||
### Execution and results
|
||||
|
||||
A successful run returns the program's lossless-JSON completion value as `result.value` and the text it printed, in order, as `result.logs`. Top-level `await` and `return` work, and the program can call the host-provided binding functions (PTC mode exposes one `tools` object) as ordinary async calls.
|
||||
Programs are async function bodies: top-level `await` and `return` work, and only erasable TypeScript is accepted. A successful call returns its lossless-JSON value as `result.value` and captured text as `result.logs`. `result.sandbox` reports the selected mode, observed denial and the backend's full or partial enforcement independently of the program outcome.
|
||||
|
||||
### Containment, not a security boundary
|
||||
Direct filesystem, network and subprocess operations remain Node operations, subject to the selected OS sandbox. Nested host bindings cross the control channel; PTC tool calls retain the registry's visibility, ordering, logging and approval rules. Running a program does not change the Session's standing policy or automatically replay it after a denial.
|
||||
|
||||
A program runs with authority comparable to the bash tool: it can reach Node APIs, and the backend deliberately does not promise isolation from the host. What it does provide is containment — a separate isolate, an empty environment (no ambient credentials, no inherited loader flags), a configurable heap cap, and hard termination that also stops a hot synchronous loop. OS processes a program spawns survive `terminate()` and need deployment-level cleanup.
|
||||
### Deadlines and cancellation
|
||||
|
||||
### What can go wrong
|
||||
The elapsed deadline covers runtime setup and execution, including time awaiting nested tools or approval. It is not a CPU meter. Timeout or cancellation stops a synchronous loop through the host's managed process owner; successful completion also cleans that managed range. The timer stops when an outcome is selected, before cleanup, so the returned call can take longer than its execution deadline while cleanup settles.
|
||||
|
||||
Every program outcome resolves as a result, so a failed run is a `result.error`, not a rejection: a syntax error or non-erasable TypeScript (`enum`, namespaces) fails as `exception` before any worker spawns; budget expiry is `timeout`; the abort signal is `abort`; a heap overflow or other worker death is `worker-exit`; a completion value that is not lossless JSON is `invalid-output`; and serialized output beyond the cap is `output-limit` — with the fitting captured log prefix retained. Rejection means caller misuse, such as a run submitted after disposal.
|
||||
### 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.
|
||||
|
||||
-----
|
||||
|
||||
@@ -68,43 +79,29 @@ Every program outcome resolves as a result, so a failed run is a `result.error`,
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
This section explains the design behind the backend; observable behavior is fully covered in [Use this package](#use-this-package).
|
||||
The host owns policy, deadlines, binding lookup and process cleanup. The child owns program evaluation and binding proxies; model-written code is an untrusted peer even when its messages use the expected control descriptor.
|
||||
|
||||
### Design concept
|
||||
### Launch and control
|
||||
|
||||
The backend rests on one separation: **containment, not a security boundary**. Model code has bash-equivalent trust (the [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) Trust posture), so the design optimizes for reconstructability and bounded resource use rather than for a hard multi-tenant boundary — that awaits a container-class backend. Each run gets one fresh worker, so a program's world dies with its worker: no cross-run state exists to leak or to log, and a run is reconstructable from the session log alone.
|
||||
The host strips erasable types, resolves the executable and bootstrap in the configured execution world, wraps the argv through `ctx.sandbox`, and spawns through `ctx.subprocess`. The child adopts the inherited control channel and clears its environment before evaluating the program. Explicit Node arguments avoid inheriting host loader or inspector flags.
|
||||
|
||||
### Execution flow
|
||||
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. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
|
||||
A run is type-stripped host-side (`node:module`'s `stripTypeScriptTypes`, position-preserving), wrapped as the body of an async function so top-level `await`/`return` work, and sent to a fresh worker whose bootstrap materializes the binding namespaces. Binding calls cross the message port as lossless JSON and are answered at most once per call id. Log text streams to the host eagerly so a killed program still shows what it printed. Exactly one outcome settles the run — a `done` frame, a budget expiry, an abort, or worker death — after which the host terminates the worker and awaits its exit.
|
||||
### Source and built bootstraps
|
||||
|
||||
### Hostile-peer port
|
||||
|
||||
Model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and rebuilt field by field before anything reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, binding names resolve as own properties only (a forged `constructor` cannot walk a prototype chain), and junk drops silently. Worker-side namespaces are null-prototype, so `__proto__`-shaped binding names are ordinary keys.
|
||||
|
||||
### Budgets
|
||||
|
||||
Two independent budgets exist because the peer is hostile: `computeMs` meters the worker's measured busy time (`eventLoopUtilization()` polling every 25 ms), so a hot loop expires it whether or not a decoy dispatch is in flight, while a program idling on a slow binding accrues nothing; `maxWallMs` backstops what busy time cannot see, such as a promise nobody resolves. Both funnel into `worker.terminate()`. `maxWallMs` is range-checked at load against `MAX_TIMER_DELAY_MS` because `setTimeout` clamps a longer delay to 1 ms.
|
||||
|
||||
### Output ledger
|
||||
|
||||
`maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names and envelope syntax are outside that ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains a fitting captured prefix of the logs.
|
||||
Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built execution uses the packaged `process.js` entry. An execution world that cannot map the host bootstrap requires a preinstalled compatible `bootstrapPath`; a host path is never assumed to name the same remote file.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `NodeCodeRuntime`, run orchestration, output ledger |
|
||||
| [`src/worker.ts`](src/worker.ts) | Source-mode worker entry (erasable TypeScript, no `lib/` dependency) |
|
||||
| [`src/bootstrap.ts`](src/bootstrap.ts) | Worker-side bootstrap: namespace materialization, console shim, log capture |
|
||||
| [`src/protocol.ts`](src/protocol.ts) | Port message vocabulary between host and worker |
|
||||
| [`src/worker-json.ts`](src/worker-json.ts) | Worker-side lossless-JSON encode/decode |
|
||||
| [`src/output-json.ts`](src/output-json.ts) | Byte metering and truncation for the outer ledger |
|
||||
| — | No runtime invariant companion is published; this process-boundary implementation exposes no same-process event relation; worker protocol and built-worker tests cover it. |
|
||||
|
||||
### The worker entry, unbuilt and built
|
||||
|
||||
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping; its transitive runtime closure contains only Node built-ins and relative source modules, so a fresh checkout never requires a sibling workspace package's unbuilt `lib/` export. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node.
|
||||
| [`src/index.ts`](src/index.ts) | Configuration, resolution, policy, bindings and managed execution |
|
||||
| [`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/output-ledger.ts`](src/output-ledger.ts) | Host accounting for the outer result |
|
||||
| — | No runtime invariant companion is published; framing and process cleanup are enforced across the process boundary rather than through independent same-process observations. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -113,19 +110,19 @@ Source mode loads erasable-only `src/worker.ts` through Node's native type strip
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
Read these when the backend contract is not enough. They move from the seam definition to the consumer and the configuration surface.
|
||||
Read the service contract before using the provider directly; the decisions explain policy and consumer ownership.
|
||||
|
||||
- [Code runtime seam](../code-runtime/README.md) — the abstract contract this backend implements.
|
||||
- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — how `dsh-tools` consumes `ctx.codeRuntime` and presents `run_code`.
|
||||
- [Code runtime subsystem reference](../../../docs/subsystems/code-runtime.md) — request/result vocabulary, bindings, and failure taxonomy.
|
||||
- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-code-runtime-node) — every accepted config field and its source declaration.
|
||||
- [Code runtime service](../code-runtime/README.md) — requests, resolved specs and results.
|
||||
- [Sandboxed Node decision](../../../.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-code-runtime.md) — security, lifecycle and timeout tradeoffs.
|
||||
- [PTC foundation](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — registry presentation and nested tool dispatch.
|
||||
- [Subprocess provider](../../subprocess/subprocess-local/README.md) — managed process ranges and platform limitations.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through PTC mode in `dsh-tools`, which renders the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure, while only the outer `run_code` result enters model context under its ordinary spill policy and binding traffic plus intermediate values remain execution-local.
|
||||
Indirectly, through PTC mode in `dsh-tools`, which returns captured logs and the completion value or a failure with sandbox facts. Intermediate binding traffic stays outside model history; the outer result follows the ordinary tool spill policy.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -135,15 +132,15 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
These limits qualify the execution guarantees and retained output.
|
||||
|
||||
These limits define when the backend is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists.
|
||||
- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — amaro or sucrase are the named drop-in replacements if the relied-on behavior shifts.
|
||||
- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config).
|
||||
- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console API.
|
||||
- **Intermediate binding values have no byte cap** — a program can exhaust process or worker memory with a value that never becomes outer output.
|
||||
- **The default 64 MiB cap is a rejection boundary, not recoverable storage** — outer spill can save only the bounded logs and diagnostic returned after `output-limit`; bytes rejected beyond the runtime cap never reach the spill layer.
|
||||
- **Confinement inherits the selected backend's limits** — full and partial enforcement are reported separately; sandbox policy and managed-process containment are distinct guarantees.
|
||||
- **The heap cap is not a process-tree memory limit** — native allocations and descendant memory are outside the V8 old-generation bound. No process-tree CPU meter is supplied.
|
||||
- **Cleanup inherits subprocess observability** — escaped descendants on a fallback platform may remain outside the managed range; see the subprocess provider's stated limits.
|
||||
- **Execution is one-shot** — no yield/wait API, live result stream or retained program state exists between calls.
|
||||
- **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 console shim has five methods** — `log`, `info`, `warn`, `error` and `debug`.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
@@ -151,6 +148,6 @@ These limits define when the backend is a poor fit or needs special operational
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
The [timeout discussion](../../../.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-code-runtime.md#deferred-timeout-design) records open choices about yielding, total lifetime, approval wait accounting and process-tree CPU/RSS limits. Those choices do not change the configured elapsed deadline.
|
||||
|
||||
</details>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Worker 线程代码执行,面向组装、容量规划或调试已发布 TypeScript 后端的用户与维护者;该后端在全新的 Node worker 中运行每个程序。"
|
||||
description: "在全新 Node 进程中运行 TypeScript 程序,使用会话文件系统沙箱、受管清理以及可配置的执行与输出限制。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
本包让 PTC 组合能够使用宿主提供的绑定执行模型编写的 TypeScript,并取得完成值、顺序日志或结构化失败。每次请求都不继承先前运行的状态;语法错误、预算到期、中止、内存耗尽和输出溢出等失败会作为结果返回,而不是抛出。应将执行的代码视为与 bash 拥有同等权限:本包限制环境暴露和资源使用,但不将代码与宿主隔离。可配置的计算时间、墙钟时间、堆和输出上限会终止运行并限制其结果大小。
|
||||
在与 Bash 相同的平台沙箱策略下执行模型编写的 TypeScript,并通过异步绑定调用 Host 提供的函数。每次调用启动一个全新的 Node 进程,返回捕获日志、精确 JSON 值或结构化失败。直接 Node API 在所选限制内仍可使用。经过时间截止、输出上限和 V8 堆限制约束执行;取消和完成都会终止受管进程范围。请求受限模式但沙箱后端不可用时,执行失败。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -17,7 +17,7 @@ kind: "package-reference"
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
||||
- [已知限制与延后工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
@@ -25,40 +25,51 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当组合需要执行模型编写的 TypeScript 程序时,连同 code-runtime seam 一起挂载此后端;只要模型调用 `run_code`,`dsh-tools` 中的 PTC mode 就会通过 `ctx.codeRuntime` 驱动它。每个执行上限都是已验证的配置,因此你可以从 `cordis.yml` 为部署调整运行时规模。
|
||||
在提供 `fs`、`subprocess`、`sandbox` 与 `sandboxPolicy` 的组合中挂载本提供方。`dsh-tools` 的 PTC 模式传入调用 Session 的目录和常设策略;直接运行时消费方在执行前解析这些选项。
|
||||
|
||||
### 最小配置
|
||||
### 配置
|
||||
|
||||
在所需服务可用后,配置提供方条目:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-code-runtime'
|
||||
- name: '@deepseek-ai/dsh-code-runtime-node'
|
||||
config:
|
||||
computeMs: 60000 # busy-time budget (measured event-loop active time)
|
||||
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
|
||||
maxOutputBytes: 67108864 # combined serialized outer-output cap (64 MiB)
|
||||
maxOldGenerationSizeMb: 512 # worker heap cap
|
||||
timeoutMs: 120000
|
||||
maxTimeoutMs: 600000
|
||||
maxOutputBytes: 67108864
|
||||
maxOldGenerationSizeMb: 512
|
||||
maxMessageBytes: 134217728
|
||||
maxPendingCalls: 128
|
||||
graceMs: 3000
|
||||
```
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `computeMs` | `60,000` | 忙碌时间预算:worker 实测事件循环活跃时间超过该值时,运行以 `timeout` 失败 |
|
||||
| `maxWallMs` | `600,000` | 墙钟上限,为忙碌时间无法观测的等待兜底;最大 `2_147_483_647` |
|
||||
| `maxOutputBytes` | `67,108,864` | 序列化日志加完成值或失败消息的硬上限;至少 `4` |
|
||||
| `maxOldGenerationSizeMb` | `512` | worker 堆上限;溢出会杀死 worker,并以 `worker-exit` 呈现 |
|
||||
| `timeoutMs` | `120,000` | 默认经过时间截止,包括嵌套工具与审批等待 |
|
||||
| `maxTimeoutMs` | `600,000` | 解析器应用的经过时间截止上限 |
|
||||
| `maxOutputBytes` | `67,108,864` | 序列化日志与完成值或诊断的合计预算 |
|
||||
| `maxOldGenerationSizeMb` | `512` | V8 老生代堆上限,单位 MiB |
|
||||
| `maxMessageBytes` | `134,217,728` | 控制帧、未完成参数字节和排队控制写入的上限 |
|
||||
| `maxPendingCalls` | `128` | 同时进行的 Host 绑定调用数量上限 |
|
||||
| `graceMs` | `3,000` | 受管终止与输出排空宽限时间 |
|
||||
| `nodeExecutable` | 当前 Node 可执行文件 | 在子进程执行世界中解析的可执行文件 |
|
||||
| `bootstrapPath` | 包内 bootstrap | 该执行世界中预先安装的构建后 bootstrap 的可选绝对路径 |
|
||||
|
||||
每个字段在加载时都会验证并提供默认值;没有其他可调项。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-code-runtime-node)是所有受支持字段的完整参考。
|
||||
[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-code-runtime-node)定义可接受的配置字段。`resolve(request)` 补全 cwd、封顶后的 timeout 与执行策略;`run(spec)` 接受这些已解析输入,不补缺省值。
|
||||
|
||||
### 运行返回什么
|
||||
### 执行与结果
|
||||
|
||||
成功的运行把程序的无损 JSON 完成值作为 `result.value` 返回,把程序打印的文本按顺序作为 `result.logs` 返回。顶层 `await`/`return` 可用,程序可以把宿主提供的绑定函数(PTC mode 暴露一个 `tools` 对象)当作普通异步调用。
|
||||
程序是异步函数体:支持顶层 `await` 与 `return`,且只接受可擦除 TypeScript。成功调用以 `result.value` 返回无损 JSON 值,以 `result.logs` 返回捕获文本。`result.sandbox` 独立于程序结果报告所选模式、观察到的拒绝,以及后端完整或部分的强制能力。
|
||||
|
||||
### 隔离措施,而非安全边界
|
||||
直接文件系统、网络与子进程操作仍是 Node 操作,受所选 OS 沙箱约束。嵌套 Host 绑定通过控制通道调用;PTC 工具调用保留注册表的可见性、排序、日志和审批规则。运行程序不会改变 Session 的常设策略,也不会在拒绝后自动重放程序。
|
||||
|
||||
程序运行时的权限与 bash 工具相当:它可以访问 Node API,后端也刻意不承诺与宿主的隔离。它提供的是隔离措施:独立 isolate、空环境(没有环境变量凭据,也不继承 loader 标志)、可配置堆上限,以及也能终止同步热循环的强制终止。程序派生的 OS 进程在 `terminate()` 后仍然存活,需要部署层面的清理。
|
||||
### 截止时间与取消
|
||||
|
||||
### 可能出什么问题
|
||||
经过时间截止覆盖运行时准备和执行,包括等待嵌套工具或审批的时间。它不是 CPU 计量器。超时或取消通过 Host 的受管进程所有者停止同步循环;成功完成也会清理该受管范围。选择结果后、清理前停止计时器,因此调用可能要在执行截止之后等待清理结算才返回。
|
||||
|
||||
每次程序运行都会通过 resolve 返回结果,因此运行失败会体现在 `result.error` 中,而不是触发 rejection:语法错误或不可擦除的 TypeScript(`enum`、namespace)在任何 worker 启动前就以 `exception` 失败;预算到期是 `timeout`;中止信号是 `abort`;堆溢出或其他 worker 终止是 `worker-exit`;不是无损 JSON 的完成值是 `invalid-output`;超出上限的序列化输出是 `output-limit`——并保留能容纳的已捕获日志前缀。只有调用方误用才会触发 rejection,例如在 dispose(资源释放)后提交运行。
|
||||
### 失败
|
||||
|
||||
程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;畸形或超量控制通信为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。
|
||||
|
||||
-----
|
||||
|
||||
@@ -66,45 +77,31 @@ kind: "package-reference"
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
<summary>实现内部——点击展开</summary>
|
||||
|
||||
本节解释后端背后的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。
|
||||
Host 负责策略、截止时间、绑定查找和进程清理。子进程负责程序求值与绑定代理;即使使用预期的控制描述符,模型编写的代码仍是不可信对端。
|
||||
|
||||
### 设计理念
|
||||
### 启动与控制
|
||||
|
||||
后端基于一项明确区分:**这是隔离措施,而非安全边界**。模型代码按与 bash 等价的信任等级处理([PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 的 Trust posture),因此设计追求可重建性与有界资源使用,而非硬性的多租户边界——那需要等待容器级后端。每次运行使用一个全新的 worker,程序的世界随 worker 一同终止:不存在可泄漏、也无需记录的跨运行状态,仅凭会话日志即可重建一次运行。
|
||||
Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 包装 argv,并通过 `ctx.subprocess` 启动。子进程接管继承的控制通道,在求值程序前清空环境。显式 Node 参数避免继承 Host 的加载器或调试器标志。
|
||||
|
||||
### 执行流程
|
||||
带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
|
||||
一次运行在宿主侧剥离类型(`node:module` 的 `stripTypeScriptTypes`,保持字节位置不变),包裹为异步函数的函数体使顶层 `await`/`return` 可用,然后发送给全新的 worker,由 bootstrap 物化绑定命名空间。绑定调用以无损 JSON 跨消息端口传递,每个调用 id 至多应答一次。日志文本主动流向宿主,因此被终止的程序仍会显示已打印的内容。恰好一个结果结算运行——`done` 帧、预算到期、中止或 worker 终止——之后宿主终止 worker 并等待其退出。
|
||||
### 源代码与构建后 bootstrap
|
||||
|
||||
### 把对端视为不可信
|
||||
源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后执行使用包内 `process.js` 入口。无法映射 Host bootstrap 的执行世界需要预先安装兼容的 `bootstrapPath`;不会假设 Host 路径对应同一个远程文件。
|
||||
|
||||
模型代码能够访问 `parentPort` 并伪造通信,因此任何代码读取入站消息前,系统都会逐字段验证并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,绑定名称只解析为自有属性(伪造的 `constructor` 无法沿原型链访问),垃圾被静默丢弃。worker 侧命名空间使用 null-prototype,因此形似 `__proto__` 的绑定名称只是普通键。
|
||||
|
||||
### 预算
|
||||
|
||||
存在两个独立预算,因为对端不可信:`computeMs` 计量 worker 的实测忙碌时间(每 25 ms 轮询一次 `eventLoopUtilization()`),因此热循环无论是否有诱饵 dispatch 在途都会到期,而等待慢绑定的程序不累计;`maxWallMs` 为忙碌时间无法观测的情况兜底,例如永远不会 resolve 的 promise。二者最终都会调用 `worker.terminate()`。`maxWallMs` 在加载时对照 `MAX_TIMER_DELAY_MS` 做范围校验,因为 `setTimeout` 会把更长的延迟限制为 1 ms。
|
||||
|
||||
### 输出账本
|
||||
|
||||
`maxOutputBytes` 统计外层 `logs` 数组加完成值或失败消息载荷的 JSON 序列化;固定的 `CodeRunResult` 字段名与外层封装语法不计入这份账本。未超过上限时返回精确值;有损完成值属于 `invalid-output`,组合溢出属于 `output-limit`,不会用 inspected string 代替。失败会保留日志中能容纳的已捕获前缀。
|
||||
|
||||
### 源码地图
|
||||
### 源码索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`NodeCodeRuntime`、运行编排、输出账本 |
|
||||
| [`src/worker.ts`](src/worker.ts) | 源码模式 worker 入口(可擦除 TypeScript,不依赖 `lib/`) |
|
||||
| [`src/bootstrap.ts`](src/bootstrap.ts) | worker 侧 bootstrap:命名空间物化、console shim、日志捕获 |
|
||||
| [`src/protocol.ts`](src/protocol.ts) | host 与 worker 之间的端口消息词汇 |
|
||||
| [`src/worker-json.ts`](src/worker-json.ts) | worker 侧无损 JSON 编解码 |
|
||||
| [`src/output-json.ts`](src/output-json.ts) | 外层账本的字节计量与截断 |
|
||||
| — | 不发布运行时不变式伴生入口;这个进程边界实现不暴露同进程事件关系,worker 协议测试与构建后 worker 测试负责覆盖。 |
|
||||
|
||||
### 未构建与已构建的 worker 入口
|
||||
|
||||
源代码模式通过 Node 原生类型剥离加载只包含可擦除语法的 `src/worker.ts`;其传递运行时闭包只包含 Node 内置模块和相对源模块,因此全新 checkout 绝不需要兄弟工作区包尚未构建的 `lib/` 导出。构建模式会把兄弟文件 `lib/worker.cjs` 作为文件系统路径传入,因为 pkg 的虚拟文件系统(VFS)Worker hook 要求 CommonJS;同一路径也可在普通 Node 下使用。
|
||||
| [`src/index.ts`](src/index.ts) | 配置、解析、策略、绑定与受管执行 |
|
||||
| [`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/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 |
|
||||
| — | 不发布运行时不变式配套模块;分帧与进程清理跨进程边界强制执行,不依靠同进程中的独立观测。 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -113,44 +110,44 @@ kind: "package-reference"
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
当后端约定不够用时阅读以下内容。它们从 seam 定义进入消费方与配置面。
|
||||
直接使用提供方前先读服务约定;决策记录解释策略与消费方职责。
|
||||
|
||||
- [代码运行时 seam](../code-runtime/README.zh.md)——此后端实现的抽象约定。
|
||||
- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——`dsh-tools` 如何消费 `ctx.codeRuntime` 并呈现 `run_code`。
|
||||
- [代码运行时子系统参考](../../../docs/subsystems/code-runtime.zh.md)——请求/结果词汇、绑定与失败分类体系。
|
||||
- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-code-runtime-node)——每个受支持配置字段及其源声明。
|
||||
- [代码运行时服务](../code-runtime/README.zh.md)——请求、已解析 spec 与结果。
|
||||
- [沙箱 Node 决策](../../../.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-code-runtime.zh.md)——安全、生命周期与 timeout 取舍。
|
||||
- [PTC 基础](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——注册表呈现与嵌套工具分派。
|
||||
- [子进程提供方](../../subprocess/subprocess-local/README.zh.md)——受管进程范围与平台限制。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tools` 中的 PTC mode 间接提供,如果外层值能容纳则原样渲染,否则返回明确的 `invalid-output`/`output-limit` 失败,且只有外层 `run_code` 结果在其普通 spill 策略下进入模型上下文,绑定通信与中间值始终只存在于执行环境中。
|
||||
通过 `dsh-tools` 的 PTC 模式间接提供,返回捕获日志与完成值,或带沙箱事实的失败。中间绑定通信不进入模型历史;外层结果遵循普通工具溢出策略。
|
||||
|
||||
#### KV Cache 影响
|
||||
#### KV Cache effect
|
||||
|
||||
不会直接失效;由上述消费方负责请求前缀变更。
|
||||
不直接失效;具名消费方负责请求前缀的任何变更。
|
||||
|
||||
## 已知限制与延期工作
|
||||
## 已知限制与延后工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
这些限制界定执行保证与保留的输出。
|
||||
|
||||
这些限制说明此后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **程序派生的 OS 进程在程序终止后仍会存活**——`worker.terminate()` 只结束线程,比 bash-local 的进程组终止更弱;在容器后端出现前,孤儿进程清理属于部署职责。
|
||||
- **类型剥离依赖 Node 的实验性 `stripTypeScriptTypes` API**——如依赖的行为发生变化,amaro 或 sucrase 是已经点名的直接替代品。
|
||||
- **`computeMs` 到期最多可能超过一个轮询间隔**——系统每 25 ms 采样一次忙碌时间(内部常量,有意不做成配置)。
|
||||
- **程序获得一个含 5 个方法的 `console` shim**(`log`/`info`/`warn`/`error`/`debug`)——有意不提供 Node 的完整 console 接口。
|
||||
- **中间绑定值没有字节上限**——程序可以用永远不会成为外层输出的值耗尽进程或 worker 内存。
|
||||
- **默认 64 MiB 上限是拒绝边界,不是可恢复存储**——外层 spill 机制只能保存发生 `output-limit` 后返回的有界日志和诊断;在运行时上限之外被拒绝的字节永远不会到达 spill 层。
|
||||
- **约束继承所选后端的限制**——完整与部分强制能力分开报告;沙箱策略与受管进程约束是不同保证。
|
||||
- **堆上限不是进程树内存限制**——原生分配与后代进程内存不属于 V8 老生代上限。不提供进程树 CPU 计量器。
|
||||
- **清理继承子进程的可观测范围**——使用 fallback 的平台上,逃逸的后代可能仍在受管范围之外;参见子进程提供方声明的限制。
|
||||
- **执行是一次性的**——没有 yield/wait API、实时结果流或跨调用保留的程序状态。
|
||||
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
|
||||
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
|
||||
- **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
<summary>维护者工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
[timeout 讨论](../../../.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-code-runtime.zh.md#deferred-timeout-design)记录 yield、总生命周期、审批等待计时和进程树 CPU/RSS 上限的开放选择。这些选择不改变已配置的经过时间截止。
|
||||
|
||||
</details>
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./process": {
|
||||
"types": "./lib/types/process-entry.d.ts",
|
||||
"default": "./lib/process.js"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { JsonChannel } from './channel.ts'
|
||||
import { bootstrapArgs } from './launch.ts'
|
||||
import type { LaunchConfig } from './launch.ts'
|
||||
import { OutputLedger } from './output-ledger.ts'
|
||||
import { drainOutput } from './output-stream.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from './json-wire.ts'
|
||||
import type { ProgramBootData } from './protocol.ts'
|
||||
|
||||
@@ -98,6 +99,7 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
* @returns Complete execution inputs with a capped deadline.
|
||||
*/
|
||||
resolve(request: CodeRunRequest): CodeRunSpec {
|
||||
if (this.disposed) throw new Error('code-runtime-node: resolve after disposal')
|
||||
const sandboxPolicy = request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
|
||||
const cwd = request.cwd ?? sandboxPolicy.workspaceRoot
|
||||
if (!isAbsolute(cwd)) throw new Error('code-runtime-node: cwd must be absolute')
|
||||
@@ -148,6 +150,7 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
let settled = false
|
||||
let timedOut = false
|
||||
let outputOverflow = false
|
||||
let overflowResult: CodeRunResult | undefined
|
||||
let stderr = ''
|
||||
let parsing = true
|
||||
const wallTimer = setTimeout(() => { timedOut = true; controller.abort('execution deadline reached') }, spec.timeoutMs)
|
||||
@@ -159,9 +162,16 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
channel?.close()
|
||||
void (async () => {
|
||||
if (handle !== undefined) {
|
||||
handle.terminate()
|
||||
try {
|
||||
handle.terminate()
|
||||
await Promise.all([handle.done.catch(() => {}), handle.waitForExit()])
|
||||
const drained = await Promise.all([
|
||||
drainOutput(handle.stdout, this.config.graceMs),
|
||||
drainOutput(handle.stderr, this.config.graceMs),
|
||||
])
|
||||
if (drained.includes(false) && failure === undefined) {
|
||||
failure = { kind: 'worker-exit', message: 'Node process output did not close cleanly' }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failure = { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(error)}` }
|
||||
} finally {
|
||||
@@ -169,7 +179,7 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
handle.stderr?.destroy()
|
||||
}
|
||||
}
|
||||
const outcome = outputOverflow ? output.limit(logs)
|
||||
const outcome = outputOverflow ? overflowResult ?? output.limit(logs)
|
||||
: failure === undefined ? output.success(logs, value) : output.failure(logs, failure)
|
||||
result.resolve({ ...outcome, sandbox: { ...sandbox } })
|
||||
})()
|
||||
@@ -203,24 +213,38 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
if ('pkg' in process && this.config.bootstrapPath === undefined) env.DSH_CODE_RUNTIME_NODE = '1'
|
||||
handle = this.ctx.subprocess.spawn({ argv: confined?.argv ?? argv, cwd: spec.cwd, env, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', control: 'pipe' }, graceMs: this.config.graceMs, signal })
|
||||
const launched = handle
|
||||
if (launched.control === undefined) throw new Error('subprocess provider did not supply the requested control channel')
|
||||
if (launched.control === undefined || launched.stdout === undefined || launched.stderr === undefined) {
|
||||
throw new Error('subprocess provider did not supply the requested control and output pipes')
|
||||
}
|
||||
const admit = (text: string): void => {
|
||||
if (outputOverflow || text.length === 0) return
|
||||
if (outputOverflow) return
|
||||
if (!output.admit(text, logs)) {
|
||||
outputOverflow = true
|
||||
overflowResult = output.limit([...logs, text])
|
||||
finish({ kind: 'output-limit', message: `outer output exceeded ${this.config.maxOutputBytes} bytes` })
|
||||
}
|
||||
}
|
||||
const stdoutDecoder = new StringDecoder('utf8')
|
||||
const stderrDecoder = new StringDecoder('utf8')
|
||||
handle.stdout?.on('data', (chunk: Buffer) => { admit(stdoutDecoder.write(chunk)) })
|
||||
handle.stdout?.on('end', () => { admit(stdoutDecoder.end()) })
|
||||
handle.stdout?.on('data', (chunk: Buffer) => {
|
||||
const text = stdoutDecoder.write(chunk)
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stdout?.on('end', () => {
|
||||
const text = stdoutDecoder.end()
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stdout?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
|
||||
handle.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = stderrDecoder.write(chunk)
|
||||
stderr = (stderr + text).slice(-this.config.maxOutputBytes)
|
||||
admit(text)
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stderr?.on('end', () => { admit(stderrDecoder.end()) })
|
||||
handle.stderr?.on('end', () => {
|
||||
const text = stderrDecoder.end()
|
||||
if (text.length > 0) admit(text)
|
||||
})
|
||||
handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
|
||||
let ready = false
|
||||
let nextId = 1
|
||||
let pending = 0
|
||||
@@ -233,7 +257,6 @@ export class NodeCodeRuntime extends CodeRuntime {
|
||||
: { 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 (settled) return
|
||||
if (!record(raw)) { protocolFailure('invalid control frame'); return }
|
||||
if (!ready) {
|
||||
if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Bounded drainage for raw process output after managed execution ends. */
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
/**
|
||||
* Wait for queued bytes without letting an inherited descriptor retain a run forever.
|
||||
* @param stream - Caller-owned raw process output, when provided.
|
||||
* @param graceMs - Maximum wait after managed process termination.
|
||||
* @returns Whether the complete stream ended without a transport error.
|
||||
*/
|
||||
export function drainOutput(stream: Readable | undefined, graceMs: number): Promise<boolean> {
|
||||
if (stream === undefined || stream.readableEnded) return Promise.resolve(true)
|
||||
if (stream.destroyed) return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
const finish = (complete: boolean): void => {
|
||||
clearTimeout(timer)
|
||||
stream.off('end', onEnd)
|
||||
stream.off('close', onClose)
|
||||
stream.off('error', onError)
|
||||
resolve(complete)
|
||||
}
|
||||
const onEnd = (): void => { finish(true) }
|
||||
const onClose = (): void => { finish(false) }
|
||||
const onError = (): void => { finish(false) }
|
||||
const timer = setTimeout(() => { finish(false) }, graceMs)
|
||||
stream.once('end', onEnd)
|
||||
stream.once('close', onClose)
|
||||
stream.once('error', onError)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { CodeBindingFunction, CodeRunRequest } from '@deepseek-ai/dsh-code-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 { JsonChannel } from '../src/channel.ts'
|
||||
import { encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
import { mountRuntime } from './setup.ts'
|
||||
|
||||
const request: CodeRunRequest = { 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<SubprocessOutcome>()
|
||||
const messages: unknown[] = []
|
||||
const handle: SubprocessHandle = {
|
||||
stdin: undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
control,
|
||||
collected: {},
|
||||
done: direct.promise,
|
||||
terminate: vi.fn(() => {
|
||||
stdout.end()
|
||||
stderr.end()
|
||||
direct.resolve({ exitCode: 0, signal: null })
|
||||
}),
|
||||
waitForExit: vi.fn(async () => true),
|
||||
}
|
||||
const writes = new Set<Promise<void>>()
|
||||
let receive: (message: unknown) => void = () => {}
|
||||
const peer = new JsonChannel(childControl, 1024 * 1024, (message) => {
|
||||
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: CodeRunRequest = request, first: unknown = { type: 'ready' }) => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx, runtime, handle, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot,
|
||||
receive: (callback: typeof receive) => { receive = callback },
|
||||
}
|
||||
}
|
||||
|
||||
function call(id: number, value: string = '') {
|
||||
return { type: 'call', id, global: 'tools', name: 'test', args: encodeCodeJsonWire(value) }
|
||||
}
|
||||
|
||||
function withBinding(fn: CodeBindingFunction): CodeRunRequest {
|
||||
return { ...request, bindings: [{ global: 'tools', functions: { test: fn } }] }
|
||||
}
|
||||
|
||||
function confinement(argv: string[]): ConfinedArgv {
|
||||
return { argv, enforcement: 'partial', denialSignatures: ['EACCES'], runnerFailureRules: [{ fatalSignatures: ['sandbox-fatal:'] }] }
|
||||
}
|
||||
|
||||
describe('Node runtime host failures', () => {
|
||||
it.each<[Config, string]>([
|
||||
[{ timeoutMs: 0 }, 'timeoutMs'],
|
||||
[{ maxPendingCalls: -1 }, 'maxPendingCalls'],
|
||||
[{ maxOldGenerationSizeMb: Infinity }, 'maxOldGenerationSizeMb'],
|
||||
[{ timeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'timeoutMs'],
|
||||
[{ maxTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, 'maxTimeoutMs'],
|
||||
[{ graceMs: MAX_TIMER_DELAY_MS + 1 }, 'graceMs'],
|
||||
[{ maxOutputBytes: 3 }, 'maxOutputBytes'],
|
||||
[{ maxOutputBytes: 4.5 }, 'maxOutputBytes'],
|
||||
[{ maxMessageBytes: 1.5 }, 'maxMessageBytes'],
|
||||
[{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'],
|
||||
[{ maxPendingCalls: 1.5 }, 'maxPendingCalls'],
|
||||
[{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'],
|
||||
[{ nodeExecutable: '' }, 'nodeExecutable'],
|
||||
[{ bootstrapPath: 'relative-bootstrap.js' }, 'bootstrapPath'],
|
||||
])('rejects deployment configuration %j', async (config, field) => {
|
||||
await expect(mountRuntime(new Context(), config)).rejects.toThrow(field)
|
||||
})
|
||||
|
||||
it('reports the deployment mode and rejects unsupported resolved inputs before spawning', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
expect(h.runtime.sandboxMode).toBe('read-only')
|
||||
expect(() => h.runtime.resolve({ ...request, cwd: 'relative' })).toThrow('cwd must be absolute')
|
||||
await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: 1 })).rejects.toThrow('resolved sandbox policy')
|
||||
const spec = h.runtime.resolve(request)
|
||||
await expect(h.runtime.run({ ...spec, timeoutMs: Infinity })).rejects.toThrow('resolved cwd and timeout')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
await h.ctx.fiber.dispose()
|
||||
expect(() => h.runtime.resolve(request)).toThrow('resolve after disposal')
|
||||
await expect(h.runtime.run(spec)).rejects.toThrow('run after disposal')
|
||||
})
|
||||
|
||||
it('fails a required unavailable sandbox before spawning', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(() => { throw new SandboxUnavailableError('read-only') })
|
||||
expect((await h.start()).error?.kind).toBe('sandbox-unavailable')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps partial enforcement separate from a successful program', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(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(argv => confinement([...argv]))
|
||||
h.onBoot(() => { h.emit({ type: 'done', 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' })
|
||||
})
|
||||
|
||||
it('distinguishes fatal sandbox startup output from a program denial', async () => {
|
||||
const h = await setup({}, 'read-only')
|
||||
vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(argv => confinement([...argv]))
|
||||
h.onBoot(() => {
|
||||
h.stderr.write('sandbox-fatal: runner could not initialize')
|
||||
h.direct.resolve({ exitCode: 1, signal: null })
|
||||
})
|
||||
const result = await h.start()
|
||||
expect(result.error?.kind).toBe('sandbox-unavailable')
|
||||
expect(result.sandbox?.denied).toBe(false)
|
||||
})
|
||||
|
||||
it('reports bootstrap assets that cannot map into the execution world', async () => {
|
||||
const h = await setup()
|
||||
vi.spyOn(h.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect((await h.start()).error?.message).toContain('bootstrap is unavailable')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up a provider that fails to supply its requested control pipe', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockReturnValue({ ...h.handle, control: undefined })
|
||||
expect((await h.start()).error?.message).toContain('did not supply the requested control')
|
||||
expect(h.handle.terminate).toHaveBeenCalledOnce()
|
||||
expect(h.handle.waitForExit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports an executable lookup failure without allocating a process', async () => {
|
||||
const h = await setup()
|
||||
h.resolveExecutable.mockRejectedValue(new Error('Node executable missing'))
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'Node executable missing' })
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors an already-aborted run without beginning executable lookup', async () => {
|
||||
const h = await setup()
|
||||
const result = await h.start({ ...request, signal: AbortSignal.abort('already stopped') }, NO_INITIAL_FRAME)
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'already stopped' })
|
||||
expect(h.resolveExecutable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not launch after cancellation races executable lookup completion', async () => {
|
||||
const h = await setup()
|
||||
const controller = new AbortController()
|
||||
h.resolveExecutable.mockImplementation(async () => {
|
||||
controller.abort('lookup canceled')
|
||||
return process.execPath
|
||||
})
|
||||
expect((await h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME)).error?.kind).toBe('abort')
|
||||
expect(h.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an early control EOF using the direct process result', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
h.control.push(null)
|
||||
h.direct.resolve({ exitCode: 7, signal: null })
|
||||
})
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
|
||||
kind: 'worker-exit', message: 'Node process exited before completing (7)',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports startup transport loss when the direct process outcome rejects', async () => {
|
||||
const h = await setup()
|
||||
h.spawn.mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
h.control.emit('error', new Error('control transport closed'))
|
||||
h.direct.reject(new Error('runner connection broken'))
|
||||
})
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({ kind: 'worker-exit', message: 'runner connection broken' })
|
||||
})
|
||||
|
||||
it('reports control loss after readiness as a substrate failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.emit('error', new Error('control transport closed')) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
|
||||
})
|
||||
|
||||
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])) })
|
||||
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('before bootstrap readiness')
|
||||
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) => {
|
||||
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) })
|
||||
expect(binding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a repeated call id instead of dispatching it twice', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it.each<Config>([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => {
|
||||
const h = await setup(config)
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
const binding = vi.fn(async () => await release.promise)
|
||||
h.onBoot(() => { h.emit(call(1, 'a'.repeat(300))); h.emit(call(2, 'b'.repeat(300))) })
|
||||
expect((await h.start(withBinding(binding))).error?.message).toContain('pending binding calls exceed')
|
||||
expect(binding).toHaveBeenCalledOnce()
|
||||
release.resolve(null)
|
||||
await setImmediate()
|
||||
})
|
||||
|
||||
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 } }) })
|
||||
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' })
|
||||
})
|
||||
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') })
|
||||
})
|
||||
|
||||
it('does not publish a late binding reply after the program has settled', async () => {
|
||||
const h = await setup()
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) })
|
||||
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.control.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it('reports failed managed cleanup even when the program returns successfully', async () => {
|
||||
const h = await setup()
|
||||
vi.mocked(h.handle.waitForExit).mockRejectedValue(new Error('range cannot be observed'))
|
||||
h.onBoot(() => { h.emit({ type: 'done', value: encodeCodeJsonWire(42) }) })
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' })
|
||||
})
|
||||
|
||||
it('fails when the configured frame budget cannot carry bootstrap data', async () => {
|
||||
const h = await setup({ maxMessageBytes: 32 })
|
||||
expect((await h.start()).error?.message).toContain('control output exceeds 32 queued bytes')
|
||||
})
|
||||
|
||||
it('turns an oversized binding reply into a protocol failure', async () => {
|
||||
const h = await setup({ maxMessageBytes: 256 })
|
||||
h.onBoot(() => { h.emit(call(1)) })
|
||||
const result = await h.start(withBinding(async () => 'x'.repeat(512)))
|
||||
expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' })
|
||||
})
|
||||
|
||||
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' } })
|
||||
})
|
||||
const result = await h.start()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(result.logs).toEqual(['retained'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { bootstrapArgs } from '../src/launch.ts'
|
||||
|
||||
it('uses an explicit installed bootstrap without pretending it maps the host file', () => {
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('must not map') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, { bootstrapPath: '/remote/process.js' }, 2048)).toEqual(['/remote/process.js', '2048'])
|
||||
})
|
||||
|
||||
it('fails when the execution world cannot read the source bootstrap', () => {
|
||||
const fs = { processPathFromHostPath: () => undefined } as unknown as FileSystem
|
||||
expect(() => bootstrapArgs(fs, {}, 2048)).toThrow('unavailable in the subprocess execution world')
|
||||
})
|
||||
|
||||
it('selects the private packaged bootstrap through the existing executable', () => {
|
||||
const prior = Object.getOwnPropertyDescriptor(process, 'pkg')
|
||||
try {
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
const fs = { processPathFromHostPath: () => { throw new Error('pkg does not expose a host bootstrap file') } } as unknown as FileSystem
|
||||
expect(bootstrapArgs(fs, {}, 2048)).toEqual(['2048'])
|
||||
} finally {
|
||||
if (prior === undefined) Reflect.deleteProperty(process, 'pkg')
|
||||
else Object.defineProperty(process, 'pkg', prior)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { OutputLedger } from '../src/output-ledger.ts'
|
||||
|
||||
it('accounts for exact JSON escaping, separators and optional completion values', () => {
|
||||
const ledger = new OutputLedger(20)
|
||||
const logs: string[] = []
|
||||
expect(ledger.admit('first', logs)).toBe(true)
|
||||
expect(ledger.admit('second', logs)).toBe(true)
|
||||
expect(ledger.admit('third', logs)).toBe(false)
|
||||
expect(ledger.success(logs)).toEqual({ logs: ['first', 'second'] })
|
||||
expect(ledger.success(logs, 1)).toEqual({ logs: ['first', 'second'], value: 1 })
|
||||
expect(ledger.success(logs, 'too long').error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('retains a bounded prefix when logs or a failure diagnostic exceed the limit', () => {
|
||||
for (const maxBytes of [4, 8, 40, 80]) {
|
||||
const ledger = new OutputLedger(maxBytes)
|
||||
const result = ledger.limit(['a', 'b', '你好🙂'.repeat(50)])
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
const bytes = Buffer.byteLength(JSON.stringify(result.logs)) + Buffer.byteLength(JSON.stringify(result.error?.message))
|
||||
expect(bytes).toBeLessThanOrEqual(maxBytes)
|
||||
}
|
||||
const ledger = new OutputLedger(80)
|
||||
expect(ledger.failure([], { kind: 'exception', message: 'short' })).toEqual({ logs: [], error: { kind: 'exception', message: 'short' } })
|
||||
expect(ledger.failure([], { kind: 'exception', message: 'x'.repeat(100) }).error?.kind).toBe('output-limit')
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { drainOutput } from '../src/output-stream.ts'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('waits for queued output and accepts an already ended or absent stream', async () => {
|
||||
const stream = new PassThrough()
|
||||
const chunks: string[] = []
|
||||
stream.on('data', chunk => chunks.push(String(chunk)))
|
||||
const pending = drainOutput(stream, 1000)
|
||||
stream.end('last output')
|
||||
expect(await pending).toBe(true)
|
||||
expect(chunks).toEqual(['last output'])
|
||||
expect(await drainOutput(stream, 1000)).toBe(true)
|
||||
expect(await drainOutput(undefined, 1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds an output descriptor retained after the process exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stream = new PassThrough()
|
||||
const pending = drainOutput(stream, 100)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(await pending).toBe(false)
|
||||
stream.destroy()
|
||||
expect(await drainOutput(stream, 100)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['close', 'error'])('reports incomplete output on %s', async (event) => {
|
||||
const stream = new PassThrough()
|
||||
const pending = drainOutput(stream, 1000)
|
||||
stream.emit(event, new Error('pipe failed'))
|
||||
expect(await pending).toBe(false)
|
||||
stream.destroy()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { Duplex } from 'node:stream'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
@@ -25,7 +25,9 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep
|
||||
const completed = Promise.withResolvers<unknown>()
|
||||
child.once('error', error => completed.reject(error))
|
||||
child.once('exit', (code) => { if (code !== 0) completed.reject(new Error(`child exit ${code}: ${stderr}`)) })
|
||||
const channel = new JsonChannel(child.stdio[7] as Duplex, 100_000, (raw) => {
|
||||
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: {
|
||||
|
||||
@@ -100,12 +100,14 @@ describe('Node program process', () => {
|
||||
|
||||
it('disposes active programs and rejects later execution', async () => {
|
||||
const { ctx, run, runtime } = await setup()
|
||||
const spec = runtime.resolve({ program: '', bindings: [] })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const active = run({ program: 'await tools.enter({}); await new Promise(() => {})', bindings: bindings({ enter: async () => { entered.resolve(undefined); return null } }) })
|
||||
await entered.promise
|
||||
await ctx.fiber.dispose()
|
||||
expect((await active).error?.kind).toBe('abort')
|
||||
await expect(run({ program: '', bindings: [] })).rejects.toThrow('disposal')
|
||||
await expect(runtime.run(spec)).rejects.toThrow('disposal')
|
||||
expect(() => runtime.resolve({ program: '', bindings: [] })).toThrow('disposal')
|
||||
expect(runtime.isolation).toBe('process')
|
||||
})
|
||||
|
||||
@@ -140,3 +142,14 @@ describe('Node program process', () => {
|
||||
await expect(readFile(join(outside, 'denied.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves empty console entries and bounds native output overflow', async () => {
|
||||
const { run } = await setup({ maxOutputBytes: 100 })
|
||||
const lines = await run({ program: 'console.log("a"); console.log(""); console.log("b")', bindings: [] })
|
||||
expect(lines.logs).toEqual(['a', '', 'b'])
|
||||
const overflow = await run({ program: 'const fs=await import("node:fs"); fs.writeSync(1,"HEAD-"+"x".repeat(100000));', bindings: [] })
|
||||
expect(overflow.error?.kind).toBe('output-limit')
|
||||
expect(overflow.logs.join('')).toContain('HEAD-')
|
||||
const bytes = Buffer.byteLength(JSON.stringify(overflow.logs)) + Buffer.byteLength(JSON.stringify(overflow.error?.message))
|
||||
expect(bytes).toBeLessThanOrEqual(100)
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { onTestFinished } from 'vitest'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
@@ -9,6 +11,8 @@ import NodeRuntime from '../src/index.ts'
|
||||
import type { Config } from '../src/index.ts'
|
||||
|
||||
export async function mountRuntime(ctx: Context, config: Config = {}, policy: { mode?: SandboxMode; workspaceRoot?: string } = {}) {
|
||||
onTestFinished(async () => { await ctx.fiber.dispose() })
|
||||
if (!ctx.get('sessions')) await ctx.plugin(SessionStore)
|
||||
if (!ctx.get('fs')) await ctx.plugin(FileSystem)
|
||||
if (!ctx.get('subprocess')) await ctx.plugin(Subprocess)
|
||||
if (!ctx.get('sandbox')) await ctx.plugin(Sandbox, {})
|
||||
|
||||
Reference in new issue
Block a user