refactor(code-runtime): remove subprocess backend

This commit is contained in:
Tianyi Cui
2026-07-30 14:24:47 +08:00
parent c1d08edd83
commit 4d5345794d
44 changed files with 192 additions and 1614 deletions

View File

@@ -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/README.md
README.md: b498c146202f1a69d89805fa3e8966c4d0735d6c
README.zh.md: 2eefc73ed0ea22aae5fde93284c25c5d03fe86c4
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
README.zh.md: faa879f3b5b62527c1d76ab3aff6a737f876ce4d

View File

@@ -19,7 +19,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: runtime seam plus local worker and subprocess backends | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |

View File

@@ -19,7 +19,7 @@
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:运行时 seam、本地 worker 后端及进程管理后端 | 产品:稳定表面 |
| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:用于模型编写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 |
| [`sandbox/`](sandbox/README.md) | 进程限制 seambwrap/Landlock/Seatbelt 后端 | 产品:稳定表面 |
| [`fs/`](fs/README.md) | 文件系统能力系列seam、本地实现、面向模型的文件工具、bash 后端发现工具 | 产品:稳定表面 |
| [`lsp/`](lsp/README.md) | LSP 能力系列seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定表面 |

View File

@@ -6,7 +6,6 @@
*/
import { inspect } from 'node:util'
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
@@ -311,7 +310,6 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
* @param pending - the id-keyed map each posted call parks its handles in.
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
* @param errorClasses - per-namespace constructors shared with program globals.
* @param maxFrameBytes - optional serialized transport cap checked before posting.
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
@@ -320,7 +318,6 @@ export function makeNamespaces(
pending: Map<number, PendingCall>,
nextId: { value: number },
errorClasses: Map<string, BindingErrorConstructor> = makeBindingErrorClasses(data),
maxFrameBytes?: number,
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const errorClass = errorClasses.get(global)
@@ -338,11 +335,6 @@ export function makeNamespaces(
if (detached === undefined) {
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON'))
}
const call = { type: 'call' as const, id: nextId.value, global, name, args: encodeWorkerJson(detached) }
if (maxFrameBytes !== undefined
&& jsonValueBytesUpTo(call as unknown as CodeJsonValue, maxFrameBytes) === undefined) {
return Promise.reject(bindingFailure(errorClass, name, 'binding arguments exceed maxFrameBytes'))
}
return new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, {
@@ -352,7 +344,7 @@ export function makeNamespaces(
},
})
try {
port.postMessage(call)
port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
} catch (error: unknown) {
pending.delete(id)
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
@@ -372,14 +364,12 @@ export function makeNamespaces(
* @param port - host message port or test double.
* @param data - the boot payload the host sent.
* @param streams - stdout/stderr objects captured as program logs.
* @param maxFrameBytes - optional serialized transport cap checked before posting.
* @returns after posting the done message.
*/
export async function runWorkerMain(
port: BootstrapPort,
data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
maxFrameBytes?: number,
): Promise<void> {
const logs = new LogBuffer(
data.maxOutputBytes,
@@ -394,7 +384,7 @@ export async function runWorkerMain(
const nextId = { value: 1 }
const errorClasses = makeBindingErrorClasses(data)
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses, maxFrameBytes)
const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses)
const errorClassParameters: string[] = []
const errorClassValues: BindingErrorConstructor[] = []
for (const namespace of data.namespaces) {
@@ -430,8 +420,5 @@ export async function runWorkerMain(
...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes),
}
}
port.postMessage(maxFrameBytes !== undefined
&& jsonValueBytesUpTo(done as unknown as CodeJsonValue, maxFrameBytes) === undefined
? { type: 'output-limit' }
: done)
port.postMessage(done)
}

View File

@@ -1,251 +0,0 @@
/** Shared host mechanics for local and subprocess-hosted TypeScript worker runtimes. */
import { stripTypeScriptTypes } from 'node:module'
import type { Readable } from 'node:stream'
import type {
CodeBindingNamespace,
CodeJsonValue,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,
} from '@deepseek-ai/dsh-code-runtime'
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
import type { WorkerJsonWire } from './worker-json.ts'
/** Smallest cap that can represent an empty log array and failure message. */
export const MIN_RUNTIME_OUTPUT_BYTES = 4
/**
* Resolve after a worker pipe emits queued data or closes during termination.
* @param stream - captured worker or child-process pipe.
* @returns after no more queued bytes can arrive.
*/
export function waitForRuntimePipeDrain(stream: Readable): Promise<void> {
if (stream.readableEnded || stream.destroyed) return Promise.resolve()
return new Promise((resolve) => {
const done = (): void => {
stream.off('end', done)
stream.off('close', done)
stream.off('error', done)
resolve()
}
stream.once('end', done)
stream.once('close', done)
stream.once('error', done)
/* v8 ignore next -- termination can win the adjacent listener-registration race. */
if (stream.readableEnded || stream.destroyed) done()
})
}
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
])
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
/** One validated binding call received from an isolated worker. */
export interface RuntimeBindingCall {
/** Correlation id supplied by the isolated worker. */
readonly id: number
/** Injected namespace global. */
readonly global: string
/** Declared namespace function. */
readonly name: string
/** Untrusted lossless-JSON wire payload. */
readonly args: unknown
}
/** One host reply to an isolated worker binding call. */
export type RuntimeBindingReply =
| { readonly type: 'reply'; readonly id: number; readonly ok: true; readonly value: WorkerJsonWire }
| { readonly type: 'reply'; readonly id: number; readonly ok: false; readonly message: string }
/**
* Render an unknown thrown value without assuming it is an Error.
* @param error - thrown or rejected value.
* @returns the caller-facing diagnostic text.
*/
export function runtimeErrorMessage(error: unknown): string {
try {
return error instanceof Error ? error.message : String(error)
} catch {
return 'binding rejected with an unrenderable value'
}
}
/**
* Strip erasable TypeScript while preserving the program's body coordinates.
* @param program - model-written async-function body.
* @returns JavaScript source with the wrapper removed.
*/
export function stripRuntimeProgram(program: string): string {
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + program + STRIP_WRAP.suffix)
return stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
}
/**
* Validate binding globals and typed-error declarations shared by worker runtimes.
* @param request - code-runtime request carrying the namespaces.
* @param implementationName - package name used in seam-misuse diagnostics.
* @returns namespaces indexed by their injected global.
*/
export function validateRuntimeBindings(
request: CodeRunRequest,
implementationName: string,
): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`${implementationName}: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`${implementationName}: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace)
}
const errorClassNames = new Set<string>()
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (descriptor === undefined) continue
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`${implementationName}: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`${implementationName}: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
throw new Error(`${implementationName}: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)
}
return bindings
}
/**
* Resolve one untrusted worker call through a declared host binding.
* @param call - parsed call envelope from the isolated worker.
* @param bindings - namespaces returned by {@link validateRuntimeBindings}.
* @returns a lossless-JSON success or stable rejection reply.
*/
export async function invokeRuntimeBinding(
call: RuntimeBindingCall,
bindings: ReadonlyMap<string, CodeBindingNamespace>,
): Promise<RuntimeBindingReply> {
const functions = bindings.get(call.global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, call.name) ? functions[call.name] : undefined
if (typeof fn !== 'function') {
return { type: 'reply', id: call.id, ok: false, message: `unknown binding ${JSON.stringify(`${call.global}.${call.name}`)}` }
}
const args = decodeWorkerJson(call.args)
if (args === undefined) {
return { type: 'reply', id: call.id, ok: false, message: 'binding arguments must be lossless JSON' }
}
try {
const resolved = await fn(args)
let value: CodeJsonValue | undefined
try {
value = snapshotCodeJsonValue(resolved)
} catch {
value = undefined
}
if (value === undefined) {
return { type: 'reply', id: call.id, ok: false, message: 'binding resolution must be lossless JSON' }
}
return { type: 'reply', id: call.id, ok: true, value: encodeWorkerJson(value) }
} catch (error: unknown) {
return { type: 'reply', id: call.id, ok: false, message: runtimeErrorMessage(error) }
}
}
/** One run's combined outer-output ledger; binding values never enter it. */
export class RuntimeOutputLedger {
private bytes = 2
private entries = 0
/** @param maxBytes - hard cap for logs plus completion or failure payload. */
constructor(private readonly maxBytes: number) {}
/**
* Admit one exact log entry.
* @param text - candidate log entry.
* @param sink - ordered retained log list.
* @returns false when the hard cap was crossed.
*/
admit(text: string, sink: string[]): boolean {
const separatorBytes = this.entries > 0 ? 1 : 0
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
if (stringBytes === undefined) return false
this.bytes += stringBytes + separatorBytes
this.entries += 1
sink.push(text)
return true
}
/**
* Finalize a successful completion against the combined cap.
* @param logs - retained ordered logs.
* @param value - optional lossless-JSON completion.
* @returns the completion or output-limit result.
*/
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, ...value !== undefined ? { value } : {} }
}
/**
* Finalize one failure diagnostic against the combined cap.
* @param logs - retained ordered logs.
* @param error - structured runtime failure.
* @returns the failure or output-limit result.
*/
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
return { logs, error }
}
/**
* Build an explicit output-limit failure with a fitting log prefix.
* @param logs - ordered logs observed before the limit.
* @returns bounded output-limit result.
*/
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
const messageBytes = fullMessage.length + 2
const retained: string[] = []
let retainedBytes = 2
const logBudget = this.maxBytes - messageBytes
for (const text of logs) {
const separatorBytes = retained.length > 0 ? 1 : 0
const availableBytes = logBudget - retainedBytes - separatorBytes
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
if (stringBytes !== undefined) {
retained.push(text)
retainedBytes += stringBytes + separatorBytes
continue
}
const prefix = truncateJsonStringBytes(text, availableBytes)
if (prefix.length > 0) {
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
/* v8 ignore next -- truncateJsonStringBytes guarantees the same bound. */
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
retained.push(prefix)
retainedBytes += prefixBytes + separatorBytes
}
break
}
const message = truncateJsonStringBytes(fullMessage, this.maxBytes - retainedBytes)
return { logs: retained, error: { kind: 'output-limit', message } }
}
}
export { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
export { jsonStringBytesUpTo, jsonValueBytesUpTo } from './output-json.ts'
export { runWorkerMain } from './bootstrap.ts'
export type { WorkerJsonWire } from './worker-json.ts'

View File

@@ -286,23 +286,6 @@ describe('makeNamespaces', () => {
expect(nextId.value).toBe(1)
})
it('rejects an oversized transport frame before posting or allocating a call id', async () => {
const port = new FakePort()
const pending = new Map<number, PendingCall>()
const nextId = { value: 1 }
const data = { namespaces: [toolNamespace(['x'])] }
const [tools] = makeNamespaces(
data, port, pending, nextId, makeBindingErrorClasses(data), 64,
) as [Record<string, (args: unknown) => Promise<unknown>>]
await expect(tools.x?.({ text: 'x'.repeat(64) })).rejects.toMatchObject({
name: 'ToolCallError', toolName: 'x', message: 'binding arguments exceed maxFrameBytes',
})
expect(port.sent).toEqual([])
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'
@@ -360,16 +343,6 @@ describe('runWorkerMain', () => {
})
})
it('reports output-limit before posting a completion that expands past the transport cap', async () => {
const port = new FakePort()
await runWorkerMain(port, {
maxOutputBytes: 1_000,
code: 'return Array.from({ length: 100 }, () => [])',
namespaces: [],
}, fakeStreams(), 100)
expect(port.sent.at(-1)).toEqual({ type: 'output-limit' })
})
it('reports a thrown program error on the done message', async () => {
const port = new FakePort()
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../core/session"
},
{
"path": "../../../vendor/cosmokit"
},

View File

@@ -1,10 +1,9 @@
import { defineConfig } from 'tsdown'
/**
* Build the plugin, reusable runtime host, and worker as separate bundles. The
* sibling `worker.cjs` is loaded by file and must be CommonJS for pkg's VFS
* Worker hook. Separate builds inline shared implementation instead of
* emitting an unlisted chunk outside the exact `files` whitelist.
* Build the index and worker as separate single-entry bundles. The sibling `worker.cjs` is loaded
* by file and must be CommonJS for pkg's VFS Worker hook. A multi-entry build emits an unlisted
* shared chunk omitted by the package's exact `files` whitelist; separate builds inline it.
*/
export default defineConfig([
{
@@ -17,16 +16,6 @@ export default defineConfig([
dts: false,
clean: false,
},
{
entry: ['lib/types/runtime-host.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/worker.js'],
outDir: 'lib',

View File

@@ -34,5 +34,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output.
- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)).
- **No runtime claims a hard security boundary** — both shipped implementations use fresh worker threads; the subprocess backend can place them inside a stronger execution world, but no runtime reports `'container'` today.
- **Intermediate binding values are implementation-bounded** — the direct worker backend has no per-binding byte cap; the subprocess backend bounds each bridge frame, but repeated or concurrent binding traffic remains subject to process memory.
- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend.
- **Intermediate binding values have no byte cap** — implementations remain subject to structured-clone cost and process memory, while a provider or executor may already have imposed its own acquisition bound.

View File

@@ -1,853 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
CodeBindingFunction,
CodeBindingNamespace,
CodeRunResult,
CodeRuntime,
} from '@deepseek-ai/dsh-code-runtime'
interface WorkerCodeRuntimeContractConfig {
computeMs?: number
maxWallMs?: number
maxOutputBytes?: number
maxOldGenerationSizeMb?: number
}
interface WorkerCodeRuntimeContractHarness {
runtime: CodeRuntime
dispose: () => Promise<void>
}
type WorkerCodeRuntimeContractSetup = (
config?: WorkerCodeRuntimeContractConfig,
) => Promise<WorkerCodeRuntimeContractHarness>
/** Convenience: one namespace `tools` with the given functions. */
export function workerRuntimeTools(
functions: Record<string, (args: unknown) => Promise<unknown>>,
): CodeBindingNamespace[] {
return [{
global: 'tools',
functions: functions as Record<string, CodeBindingFunction>,
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
}]
}
/** Run behavior shared by the direct and subprocess-hosted worker runtimes. */
export function runWorkerCodeRuntimeContract(
label: string,
setup: WorkerCodeRuntimeContractSetup,
): void {
describe(`${label} — programs and bindings (real workers)`, () => {
it('registers with the seam descriptors', async () => {
const { runtime } = await setup()
expect(runtime.language).toBe('typescript')
expect(runtime.isolation).toBe('worker-thread')
})
it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2 } as Point;
console.log('point', p);
process.stdout.write('raw-out\\n');
console.warn('careful');
return p.x + p.y;
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(3)
expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
})
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
const { runtime } = await setup()
const calls: unknown[] = []
const result = await runtime.run({
program: `
const first = await tools.echo({ n: 1 });
let caught = {};
try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
let caughtRaw = {};
try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
return { first, caught, caughtRaw };
`,
bindings: workerRuntimeTools({
echo: async (args) => { calls.push(args); return { echoed: args } },
fail: async () => { throw new Error('nope') },
// A non-Error throw: the host renders it, the program still catches.
failRaw: async () => { throw 'raw-nope' },
}),
})
expect(result.error).toBeUndefined()
expect(result.value).toEqual({
first: { echoed: { n: 1 } },
caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
})
expect(calls).toEqual([{ n: 1 }])
})
it('materializes a typed rejection from a generic namespace descriptor', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
try { await helpers.fail({}) } catch (error) {
return {
isTyped: error instanceof HelperCallError,
name: error.name,
helperName: error.helperName,
message: error.message,
};
}
`,
bindings: [{
global: 'helpers',
functions: { fail: async () => { throw new Error('nope') } },
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
}],
})
expect(result.value).toEqual({
isTyped: true,
name: 'HelperCallError',
helperName: 'fail',
message: 'nope',
})
})
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
let value = 'leaf';
for (let depth = 0; depth < 3_000; depth++) value = [value];
return await tools.echo(value);
`,
bindings: workerRuntimeTools({ echo: async args => args }),
})
expect(result.error).toBeUndefined()
let cursor = result.value
for (let depth = 0; depth < 3_000; depth++) {
expect(Array.isArray(cursor)).toBe(true)
cursor = Array.isArray(cursor) ? cursor[0] : undefined
}
expect(cursor).toBe('leaf')
}, 15_000)
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toMatch(/enum|strip/i)
})
it('reports a runtime throw as an exception with the message', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toContain('kaboom')
})
it('gives the program an EMPTY environment', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
expect(result.value).toBe('{}')
})
it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
})
it('completes a program that returns nothing with no value at all', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'const x = 1', bindings: [] })
expect(result.error).toBeUndefined()
expect('value' in result).toBe(false)
})
it('keeps logs streamed before a failure', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'console.log("before"); throw new Error("after-log")',
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.logs).toContain('before')
})
})
describe(`${label} — budgets and containment (real workers)`, () => {
it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
const result = await runtime.run({
// The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
// then spin. Host-side pending-call bookkeeping would pause a naive
// budget here; measured busy time cannot be fooled.
program: 'void tools.slow({}); for (;;) {}',
bindings: workerRuntimeTools({ slow: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('compute budget')
}, 15_000)
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
// Keep the binding delay above the compute allowance while leaving enough
// headroom for worker bootstrap on loaded CI hosts.
const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
const result = await runtime.run({
program: 'return await tools.slow({})',
bindings: workerRuntimeTools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('slow-done')
}, 15_000)
it('ends an idle-forever run at the wall-clock ceiling', async () => {
const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
const result = await runtime.run({
program: 'await tools.never({}); return 1',
bindings: workerRuntimeTools({ never: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('wall-clock ceiling')
}, 15_000)
it('reports an abort mid-run and stops the worker', async () => {
const { runtime } = await setup()
const controller = new AbortController()
setTimeout(() => { controller.abort('user-cancel') }, 150)
const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
}, 15_000)
it('reports a pre-aborted signal without spawning', async () => {
const { runtime } = await setup()
const controller = new AbortController()
controller.abort('too-late')
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
})
it('applies the outer-output cap to failures before worker startup', async () => {
const capped = await setup({ maxOutputBytes: 64 })
const controller = new AbortController()
controller.abort('A'.repeat(1_000))
const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
const minimal = await setup({ maxOutputBytes: 4 })
const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
expect(invalid.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
})
it('drops a binding resolution that lands after the run settled', async () => {
const { runtime } = await setup()
const controller = new AbortController()
let replyDelivered!: Promise<void>
const result = await runtime.run({
program: 'void tools.late({}); for (;;) {}',
bindings: workerRuntimeTools({
// Anchored on invocation: abort 100ms after the call reaches the
// host, resolve 400ms after — by then the run has settled, so the
// resolution's reply hits the post-settlement drop.
late: () => new Promise((resolve) => {
setTimeout(() => { controller.abort('cancel-now') }, 100)
replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
}),
}),
signal: controller.signal,
})
expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
// Let the late resolution actually fire so its reply executes instead of
// being cancelled with the test.
await replyDelivered
}, 15_000)
it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
const result = await runtime.run({
program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
bindings: [],
})
expect(result.error?.kind).toBe('worker-exit')
// And the host is fine: run something else.
const after = await runtime.run({ program: 'return "alive"', bindings: [] })
expect(after.value).toBe('alive')
}, 30_000)
it('reports a worker that exits before publishing a completion', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
expect(result).toEqual({
logs: [],
error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
})
})
it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
const { runtime } = await setup({ maxOutputBytes: 300 })
const result = await runtime.run({
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [],
})
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
expect(result.value).toBeUndefined()
expect(result.logs.length).toBeGreaterThan(0)
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
})
it('retains a fitting prefix when one oversized log is the first output', async () => {
const { runtime } = await setup({ maxOutputBytes: 96 })
const result = await runtime.run({
program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
bindings: [],
})
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
expect(result.logs).toHaveLength(1)
expect(result.logs[0]?.startsWith('start-')).toBe(true)
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
})
it('fails an oversized return value without substituting a string', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
})
it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
const exact = await setup({ maxOutputBytes: 7 })
const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
// [] costs two bytes and JSON serialization of "€" costs five.
expect(exactResult).toEqual({ logs: [], value: '€' })
const over = await setup({ maxOutputBytes: 6 })
const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
expect(overResult.error?.kind).toBe('output-limit')
})
it('accounts logs and completion in one exact combined ledger', async () => {
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
const exact = await setup({ maxOutputBytes: 11 })
expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
.toEqual({ logs: ['abc'], value: 'xy' })
const over = await setup({ maxOutputBytes: 10 })
const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
})
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
const exact = await setup({ maxOutputBytes: 11 })
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
const over = await setup({ maxOutputBytes: 10 })
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
expect(result.error?.kind).toBe('output-limit')
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
})
it('does not send a giant Error stack across the worker port', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: 'throw new Error("x".repeat(1_000_000))',
bindings: [],
})
expect(result).toEqual({
logs: [],
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('completes a program that awaits its write callback, capturing the chunk', async () => {
// Node's write(chunk[, encoding][, callback]) contract: dropping the
// callback would leave this promise pending until the wall ceiling and
// misreport a completed program as a timeout.
const { runtime } = await setup({ maxWallMs: 2_000 })
const result = await runtime.run({
program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs).toContain('flushed')
})
it('returns a large JSON container exactly when the outer cap permits it', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
expect(result.error).toBeUndefined()
expect(result.value).toEqual(new Array(50_000).fill(7))
})
it('returns an exact completion at the default 64 MiB combined boundary', async () => {
const { runtime } = await setup()
// [] costs two bytes and the JSON string contributes two quotes, leaving
// exactly this many payload bytes under the 67_108_864-byte default.
const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
expect(result.error).toBeUndefined()
expect(result.logs).toEqual([])
expect(result.value).toHaveLength(67_108_860)
}, 60_000)
it('fails one byte over the default 64 MiB combined boundary', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
}, 60_000)
it('drains pipe output queued before terminal worker teardown completes', async () => {
const { runtime } = await setup({ maxOutputBytes: 200_000 })
const payload = `late-pipe-${'x'.repeat(100_000)}`
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('late-pipe-' + 'x'.repeat(100_000));
parentPort.postMessage({ type: 'done', value: ['done'] });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs.join('') === payload).toBe(true)
}, 15_000)
})
describe(`${label} — hostile programs (real workers)`, () => {
it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
parentPort.postMessage({ type: 'junk' });
return await tools.real({});
`,
bindings: workerRuntimeTools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
})
it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
for (const junk of [
null, 42, 'junk', [],
{ type: 'nope' },
{ type: 'call' },
{ type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
{ type: 'log' },
{ type: 'log', text: null },
{ type: 'log', text: 7 },
{ type: 'log', text: {} },
{ type: 'done', error: 5 },
{ type: 'done', error: { kind: 'exception', message: 5 } },
{ type: 'done', error: { kind: 'invented', message: 'bad kind' } },
]) parentPort.postMessage(junk);
return await tools.real({});
`,
bindings: workerRuntimeTools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
expect(result.logs).toEqual([])
})
it('ignores forged controller-only failure classifications', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
for (const kind of ['abort', 'timeout', 'worker-exit']) {
parentPort.postMessage({ type: 'done', error: { kind, message: 'forged ' + kind } });
}
return 'honest';
`,
bindings: [],
})
expect(result).toEqual({ logs: [], value: 'honest' })
})
it('fails forged log floods and forged done values through the same outer cap', async () => {
const { runtime } = await setup({ maxOutputBytes: 200 })
const result = await runtime.run({
// Forged messages bypass worker-side capture and completion checks;
// the outer ledger must still contain them.
program: `
const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
for (;;) {}
`,
bindings: [],
})
expect(result.value).toBeUndefined()
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
})
it('re-caps an oversized forged done value at the host boundary', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({
logs: [],
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
})
})
it('drops a malformed forged done carrying both value and error', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
return 'honest';
`,
bindings: [],
})
expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
})
it('contains a deeply nested forged completion without overflowing the host meter', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
const value = [];
for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
value.push(null);
setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
// Prevent bootstrap's normal undefined completion from racing the forged terminal.
await new Promise(() => {});
`,
bindings: [],
})
expect(result.error).toBeUndefined()
let value = result.value
let depth = 0
while (Array.isArray(value)) {
expect(value).toHaveLength(1)
value = value[0]
depth += 1
}
expect(depth).toBe(3_000)
expect(value).toBeNull()
}, 15_000)
it('turns forged over-limit error text into output-limit at the host', async () => {
const { runtime } = await setup({ maxOutputBytes: 64 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
})
it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
bindings: workerRuntimeTools({ bad: async () => (() => 1) }),
})
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
})
it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
const { runtime } = await setup()
let calls = 0
const result = await runtime.run({
program: `
const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
const values = [new Date(), decorated, () => 1];
const failures = [];
for (const value of values) {
try { await tools.never(value) } catch (error) {
failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
}
}
return failures;
`,
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(result.value).toEqual(new Array(3).fill({
typed: true,
name: 'ToolCallError',
toolName: 'never',
message: 'binding arguments must be lossless JSON',
}))
})
it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
const { runtime } = await setup()
let calls = 0
const forgeObject = `
const prototype = Object.create(null);
const SpoofedObject = function Object() {};
SpoofedObject.prototype = prototype;
Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
const forged = Object.assign(Object.create(prototype), { value: 1 });
Function.prototype.toString = () => 'function Object() { [native code] }';
`
const argument = await runtime.run({
program: `${forgeObject}
try { await tools.never(forged) } catch (error) {
return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
}
`,
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(argument.value).toEqual({
typed: true,
name: 'ToolCallError',
toolName: 'never',
message: 'binding arguments must be lossless JSON',
})
const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
expect(completion).toEqual({
logs: [],
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
})
})
it('preserves binding and completion JSON after model code mutates boundary globals', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const arrayPrototype = Array.prototype;
const objectPrototype = Object.prototype;
const setPrototype = Set.prototype;
const stringPrototype = String.prototype;
Array.isArray = () => false;
arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') };
Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') };
Object.hasOwn = () => false;
Object.is = () => true;
objectPrototype.propertyIsEnumerable = () => false;
Number.isFinite = Number.isSafeInteger = () => false;
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') };
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') };
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') };
Buffer.byteLength = () => 0;
Function.prototype.toString = () => 'mutated';
objectPrototype.get = () => undefined;
objectPrototype.constructor = arrayPrototype.constructor = null;
globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined;
const echoed = await tools.echo({ request: ['€', 1] });
let failure;
try { await tools.fail({}) } catch (error) {
failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
}
return { echoed, failure, completion: { ok: true, amount: 42 } };
`,
bindings: workerRuntimeTools({ echo: async args => args, fail: async () => { throw new Error('nope') } }),
})
expect(result).toEqual({
logs: [],
value: {
echoed: { request: ['€', 1] },
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
completion: { ok: true, amount: 42 },
},
})
})
it('rejects forged lossy binding arguments again at the host boundary', async () => {
const { runtime } = await setup()
let calls = 0
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
const forged = (id, args) => new Promise((resolve) => {
const receive = (message) => {
if (message?.type !== 'reply' || message.id !== id) return;
parentPort.off('message', receive);
resolve(message);
};
parentPort.on('message', receive);
parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
});
const sparse = []; sparse.length = 1;
const cycle = {}; cycle.self = cycle;
return await Promise.all([
forged(8001, new Date()),
forged(8002, -0),
forged(8003, sparse),
forged(8004, cycle),
]);
`,
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
})
expect(calls).toBe(0)
expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
type: 'reply',
id,
ok: false,
message: 'binding arguments must be lossless JSON',
})))
})
it('contains throwing getters while snapshotting binding resolutions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
bindings: workerRuntimeTools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
})
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
})
it('revalidates a forged lossy completion at the host boundary', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: -0 });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
})
it('honors a forged worker-side output-limit signal', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'output-limit' });
for (;;) {}
`,
bindings: [],
})
expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
})
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
// Computed keys: a literal `'__proto__': …` entry would SET the record's
// prototype instead of declaring a binding of that name.
bindings: workerRuntimeTools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
})
expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
})
})
describe(`${label} — seam misuse and lifecycle`, () => {
it('rejects invalid and duplicate binding globals loudly', async () => {
const { runtime } = await setup()
const cases: [string, RegExp][] = [
['not valid!', /not a usable identifier/],
['await', /not a usable identifier/],
['console', /duplicate binding global/],
]
for (const [global, message] of cases) {
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
}
await expect(runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
})).rejects.toThrow(/duplicate binding global/)
await expect(runtime.run({
program: 'return typeof ToolCallError',
bindings: [{ global: 'ToolCallError', functions: {} }],
})).resolves.toMatchObject({ value: 'object' })
})
it('rejects malformed or colliding binding error-class declarations', async () => {
const { runtime } = await setup()
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
global,
functions: {},
errorClass: { name, memberNameProperty },
})
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
await expect(run([
namespace('tools', 'CallError'),
namespace('helpers', 'CallError'),
])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
})
it('rejects config values that are not positive numbers', async () => {
await expect(setup({ computeMs: -1 })).rejects.toThrow(/positive number/)
})
it('rejects a maxWallMs above Node\'s maximum timer delay', async () => {
// setTimeout clamps a delay past 2^31-1 ms to 1 ms, so the positivity check
// alone would accept a 25-day ceiling that expires on the first tick.
await expect(setup({ maxWallMs: 2_147_483_648 }))
.rejects.toThrow(/maxWallMs must be at most 2147483647/)
// The boundary itself is usable.
await expect(setup({ maxWallMs: 2_147_483_647 })).resolves.toBeTruthy()
})
it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
await expect(setup({ maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
await expect(setup({ maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
})
it('keeps runs isolated: no state survives from one run to the next', async () => {
const { runtime } = await setup()
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
expect(second.value).toBe('undefined')
})
it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
const { runtime, dispose } = await setup()
const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
// Give the worker a moment to actually start spinning.
await new Promise(resolve => setTimeout(resolve, 200))
await dispose()
const result = await inflight
expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
}, 15_000)
})
}

View File

@@ -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/subprocess/README.md
README.md: 5ea1ae3681f9340cfba798e659f454d6d05fdbfe
README.zh.md: 4e1cb9472d6e90062ecde61db4200a79a56b16f1
README.md: f2b19436da40feb14d067e2cfc706222625680b5
README.zh.md: 938312448dd5c0a691ed07ddc9843cf2c4445637

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
The shared process substrate for one execution world: canonical cwd, executable lookup, fully-specified managed child-process trees with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../bash/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../pty/README.md), [subprocess code runtime](../code-runtime/code-runtime-subprocess/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
The shared process substrate for one execution world: executable lookup, fully-specified managed child-process trees with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../bash/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../pty/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
| Package | ctx key | Role |
|---|---|---|
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: execution-world coordinates and executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary |
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary |
| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal |
The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
这里集中提供一个执行世界的共享进程基底:规范化 cwd、可执行文件查找、具有原始或收集式 stdio 的完整指定受管子进程树,以及一项深层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)、[PTY shell 后端](../pty/README.md)、[子进程代码运行时](../code-runtime/code-runtime-subprocess/README.md)与 [ACPAgent Client Protocolsubagent 后端](../subagent/README.md)。参见[subprocess seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完整指定受管子进程树,以及一项深层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)、[PTY shell 后端](../pty/README.md)与 [ACPAgent Client Protocolsubagent 后端](../subagent/README.md)。参见[subprocess seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
| 包package | ctx 键 | 角色 |
|---|---|---|
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:执行世界坐标与可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期以及共享的环境输出词汇 |
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期以及共享的环境输出词汇 |
| [`subprocess-local`](subprocess-local/README.md)`@deepseek-ai/dsh-subprocess-local` | 无 | 本地实现detached 进程树、有界收集spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的资源释放 |
即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。

View File

@@ -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/subprocess/subprocess-local/README.md
README.md: 4892ad49e571662b833b1dd6d64dc966f343ae3c
README.zh.md: ca0df5ff18e9feb992cc3f6d0db90dc06cb275d8
README.md: 85103c2634bd35b188acd71c7f037e3678a2542e
README.zh.md: 505eac4f2650e6723e042cdb1122f9ef504ad7df

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessService` resolves local executables, spawns ordinary detached process trees with explicit stdio, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling seams ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-pty-local`](../../pty/pty-local/README.md), and [`dsh-code-runtime-subprocess`](../../code-runtime/code-runtime-subprocess/README.md)).
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessService` resolves local executables, spawns ordinary detached process trees with explicit stdio, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling seams ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), and [`dsh-pty-local`](../../pty/pty-local/README.md)).
## Behavior (and where it came from)
@@ -10,7 +10,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
- **Execution-world coordinates** — `cwd` is the host process cwd, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions.
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative PATH entries resolve from the host process cwd.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现。`LocalSubprocessService` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方 seam[`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)[`dsh-pty-local`](../../pty/pty-local/README.md) 和 [`dsh-code-runtime-subprocess`](../../code-runtime/code-runtime-subprocess/README.md))。
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现。`LocalSubprocessService` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方 seam[`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)[`dsh-pty-local`](../../pty/pty-local/README.md))。
## 行为(以及设计来源)
@@ -10,7 +10,7 @@
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **执行世界坐标**`cwd` 是宿主进程 cwd`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索。
- **执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;相对 PATH 条目从宿主进程 cwd 解析
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。

View File

@@ -33,7 +33,6 @@ import { LocalTerminalHandle } from './terminal.ts'
* SIGTERM→grace→SIGKILL escalation.
*/
export class LocalSubprocessService extends SubprocessService {
readonly cwd = process.cwd()
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Live terminal sessions retained through whole-session quiescence. */
@@ -103,7 +102,7 @@ export class LocalSubprocessService extends SubprocessService {
? (environmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';')
: ['']
return path.split(delimiter).flatMap(directory =>
extensions.map(extension => resolve(this.cwd, directory, command + extension)))
extensions.map(extension => resolve(process.cwd(), directory, command + extension)))
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {

View File

@@ -31,9 +31,6 @@ describe('LocalSubprocessService', () => {
expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
PATH: relative(process.cwd(), dirname(process.execPath)) || '.',
})).toBe(process.execPath)
Reflect.set(ctx.subprocess, 'cwd', dirname(process.execPath))
expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), { PATH: '' }))
.toBe(process.execPath)
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
.rejects.toThrow('was not found on PATH')

View File

@@ -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/subprocess/subprocess/README.md
README.md: 08fb03d6f145c1026180d92de2421b647cb3ebbe
README.zh.md: 592fc83223092b3406e9070e11de2b20a63232a6
README.md: ec4a4e3328a5a600441d2e7983b3844f4ccc5e91
README.zh.md: 3da79995fad9a2c8f2a6020ae18152a0e4459c71

View File

@@ -2,12 +2,12 @@
English | [中文](README.zh.md)
The subprocess seam (`ctx.subprocess`) is the process half of one execution world. The abstract `SubprocessService` exposes its canonical `cwd`, executable lookup, ordinary managed `spawn`, and one terminal-process primitive; its vocabulary covers raw/collected stdio, process and terminal handles, exit facts, tree/session cleanup, and the managed `DSH_*` environment namespace. The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md).
The subprocess seam (`ctx.subprocess`) is the process half of one execution world. The abstract `SubprocessService` exposes executable lookup, ordinary managed `spawn`, and one terminal-process primitive; its vocabulary covers raw/collected stdio, process and terminal handles, exit facts, tree/session cleanup, and the managed `DSH_*` environment namespace. The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md).
## Contract
- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures.
- `cwd` and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides.
- Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement.
- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification).

View File

@@ -2,12 +2,12 @@
[English](README.md) | 中文
子进程 seam`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessService` 公开其规范化 `cwd`可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树会话清理以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。
子进程 seam`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessService` 公开可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树会话清理以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。
## 契约
- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- `cwd` 和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。
- spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧LSP 的 JSON-RPC、ACPAgent Client Protocol的 ndjson`'inherit'` 直通父进程描述符以承载诊断输出收集模式collect`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
- 终止在每个平台上都以进程树为范围POSIX 用 detached 进程组并以直接子进程回退Windows 用 `taskkill /T``terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止但绝不判定原因deadline、拆卸阶梯与原因分类归调用方所有

View File

@@ -1,6 +1,6 @@
/**
* The subprocess seam (`ctx.subprocess`): execution-world process coordinates,
* executable lookup, fully specified managed process trees with raw or
* The subprocess seam (`ctx.subprocess`): execution-world executable lookup,
* fully specified managed process trees with raw or
* collected stdio, and one terminal-process primitive. Command defaulting,
* shell semantics, deadlines, protocol framing, terminal readiness, and
* presentation belong to consumers. The local implementation lives in
@@ -78,8 +78,8 @@ declare module 'cordis' {
* duplicate-service behavior).
*
* Implementations must honor these semantics:
* - {@link cwd} and executable paths belong to one execution world shared
* with the mounted filesystem provider.
* - Executable paths belong to one execution world shared with the mounted
* filesystem provider.
* - {@link spawn} returns immediately with a live handle; `done` resolves at
* process close with exit facts and rejects only for spawn-level failures.
* - Collect-mode readers are offset-based and non-consuming, so independent
@@ -104,9 +104,6 @@ export abstract class SubprocessService extends Service {
super(ctx, 'subprocess')
}
/** Canonical default cwd in this provider's execution world. */
abstract readonly cwd: string
/**
* Resolve one configured executable in this provider's execution world.
* Absolute paths are verified; bare names use the provider's scrubbed PATH