feat(code-runtime): execute Node programs through confined processes
This commit is contained in:
1 parent
f95f7ec8dc
commit
75ed8da3e0
54 files changed
+1933
-2277
No files matched your search
@@ -18,7 +18,7 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { NodeCodeRuntime } from '@deepseek-ai/dsh-code-runtime-node'
|
||||
import { mountRuntime } from '../../../../../../packages/code-runtime/code-runtime-node/tests/setup.ts'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
|
||||
@@ -60,11 +60,11 @@ async function ptcModeHarness(cwd: string): Promise<Context> {
|
||||
await harness.plugin(AgentRegistry)
|
||||
await harness.plugin(AgentLoop, { agents: [] })
|
||||
await harness.plugin(LlmDeepSeek)
|
||||
await harness.plugin(LocalSubprocessRuntime)
|
||||
if (harness.get('subprocess') === undefined) await harness.plugin(LocalSubprocessRuntime)
|
||||
await harness.plugin(BashEnvPlugin)
|
||||
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await harness.plugin(ToolBash)
|
||||
await harness.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(harness)
|
||||
return harness
|
||||
}
|
||||
|
||||
@@ -81,14 +81,14 @@ async function workspacePtcModeHarness(): Promise<Context> {
|
||||
await harness.plugin(WorkspaceContext, { maxBytes: 65536 })
|
||||
await harness.plugin(AgentLoop, { agents: [] })
|
||||
await harness.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
|
||||
await harness.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(harness)
|
||||
return harness
|
||||
}
|
||||
|
||||
let keylessCall = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** Execute one outer PTC mode call through the real registry and worker. */
|
||||
/** Execute one outer PTC mode call through the real registry and Node process. */
|
||||
function runCode(
|
||||
harness: Context,
|
||||
code: string,
|
||||
@@ -114,28 +114,28 @@ function completion(result: ToolExecutionResult): unknown {
|
||||
return value.result
|
||||
}
|
||||
|
||||
/** Keyless real-worker harness for direct typed-binding acceptance tests. */
|
||||
/** Keyless real-process harness for direct typed-binding acceptance tests. */
|
||||
async function typedPtcModeHarness(): Promise<Context> {
|
||||
const harness = new Context()
|
||||
await harness.plugin(SystemPrompt)
|
||||
await harness.plugin(ToolRuntime, { mode: 'ptc' })
|
||||
await harness.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(harness)
|
||||
return harness
|
||||
}
|
||||
|
||||
/** Keyless real-worker harness with the task-owned bash lifecycle. */
|
||||
/** Keyless real-process harness with the task-owned bash lifecycle. */
|
||||
async function backgroundPtcModeHarness(cwd: string): Promise<Context> {
|
||||
const harness = await typedPtcModeHarness()
|
||||
await harness.plugin(LocalJobRegistry)
|
||||
await harness.plugin(ToolTasks, {})
|
||||
await harness.plugin(LocalSubprocessRuntime)
|
||||
if (harness.get('subprocess') === undefined) await harness.plugin(LocalSubprocessRuntime)
|
||||
await harness.plugin(BashEnvPlugin)
|
||||
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await harness.plugin(ToolBash)
|
||||
return harness
|
||||
}
|
||||
|
||||
describe('PTC mode typed values: keyless real-worker contracts', () => {
|
||||
describe('PTC mode typed values: keyless real-process contracts', () => {
|
||||
it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => {
|
||||
ctx = await typedPtcModeHarness()
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { NodeCodeRuntime } from '@deepseek-ai/dsh-code-runtime-node'
|
||||
import { mountRuntime } from '../../../code-runtime/code-runtime-node/tests/setup.ts'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
@@ -40,7 +40,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl
|
||||
await ctx.plugin(ToolRuntime, { mode: 'both' })
|
||||
await ctx.plugin(MemorySpillStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes })
|
||||
if (nested) await ctx.plugin(NodeCodeRuntime, {})
|
||||
if (nested) await mountRuntime(ctx, {})
|
||||
ctx.effect(() => ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: 'Return deterministic shell text without spawning a process.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-code-runtime-node",
|
||||
"description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
|
||||
"description": "Sandboxed Node process implementation of the DeepSeek Harness code-execution capability",
|
||||
"version": "0.1.5-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -18,15 +18,14 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./lib/types/worker.d.ts",
|
||||
"default": "./lib/worker.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./process": {
|
||||
"default": "./lib/process.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/worker.cjs",
|
||||
"lib/process.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
@@ -34,7 +33,11 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^"
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
@@ -44,6 +47,14 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^"
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Validate and capture the binding names available to one program. */
|
||||
import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeRunRequest } from '@deepseek-ai/dsh-code-runtime'
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
/**
|
||||
* Reject unusable namespaces before starting a process.
|
||||
* @param request - Host-owned binding declarations.
|
||||
* @returns Namespaces keyed by their declared global.
|
||||
*/
|
||||
export function validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
|
||||
// `console` is THIS backend's log-capture slot; the dunder entries exist
|
||||
// for the Python side — its seeded/wrapped slots plus the `__debug__`
|
||||
// compile-time constant — refused here too so the namespace list stays
|
||||
// portable across backends. The seam declaration is the single home for
|
||||
// why each entry is reserved.
|
||||
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: 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) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
const member = descriptor.memberNameProperty
|
||||
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
/**
|
||||
* Worker-side execution logic, written as plain functions over an injected port so the unit
|
||||
* suite can run every line IN-PROCESS against a fake port (a real worker thread is a separate
|
||||
* V8 isolate the coverage provider cannot observe).
|
||||
* Program evaluation, output capture, and host binding proxies over the process channel.
|
||||
* @module @deepseek-ai/dsh-code-runtime-node/src/bootstrap
|
||||
*/
|
||||
|
||||
import { inspect } from 'node:util'
|
||||
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import type { DoneMessage, ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire, snapshotCodeJsonValue } from './json-wire.ts'
|
||||
|
||||
const CapturedError = Error
|
||||
const capturedObjectCreate = Object.create
|
||||
@@ -24,7 +22,7 @@ function defineBindingErrorField(error: Error, key: string, value: string): void
|
||||
|
||||
/** The port API the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
|
||||
export interface BootstrapPort {
|
||||
postMessage(message: WorkerToHost): void
|
||||
postMessage(message: ProgramToHost): void
|
||||
on(event: 'message', listener: (message: ReplyMessage) => void): void
|
||||
}
|
||||
|
||||
@@ -121,7 +119,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
|
||||
|
||||
/**
|
||||
* Redirect a stream's `write` into the log buffer (the program-visible
|
||||
* `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order
|
||||
* `process.stdout`/`process.stderr` in the Node child), so raw writes land in emission order
|
||||
* alongside console output instead of racing down a pipe. It preserves Node's optional callback
|
||||
* contract: the callback runs asynchronously after admission, even when the log budget drops
|
||||
* the write.
|
||||
@@ -186,7 +184,7 @@ export function prepareCompletion(
|
||||
if (jsonValueBytesUpTo(snapshot, remainingOutputBytes) === undefined) {
|
||||
return outputLimit(maxOutputBytes)
|
||||
}
|
||||
return { value: encodeWorkerJson(snapshot) }
|
||||
return { value: encodeCodeJsonWire(snapshot) }
|
||||
}
|
||||
|
||||
/** Build the fixed overflow fragment without carrying rejected variable bytes. */
|
||||
@@ -265,7 +263,7 @@ function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberN
|
||||
* @returns constructors keyed by their owning namespace global.
|
||||
*/
|
||||
export function makeBindingErrorClasses(
|
||||
data: Pick<WorkerBootData, 'namespaces'>,
|
||||
data: Pick<ProgramBootData, 'namespaces'>,
|
||||
): Map<string, BindingErrorConstructor> {
|
||||
const classes = new Map<string, BindingErrorConstructor>()
|
||||
for (const namespace of data.namespaces) {
|
||||
@@ -278,7 +276,7 @@ export function makeBindingErrorClasses(
|
||||
* Route host replies into the pending-call map: each reply settles its call
|
||||
* at most once, and a reply for an unknown id (stray, or a duplicate answer
|
||||
* to an id already settled) is ignored. Shared wiring between
|
||||
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
|
||||
* {@link runProgram} and the tests that exercise {@link makeNamespaces}
|
||||
* standalone.
|
||||
* @param port - the port whose `message` events carry the replies.
|
||||
* @param pending - the id-keyed map of unsettled binding calls.
|
||||
@@ -289,7 +287,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
|
||||
if (!entry) return
|
||||
pending.delete(message.id)
|
||||
if (message.ok) {
|
||||
const value = decodeWorkerJson(message.value)
|
||||
const value = decodeCodeJsonWire(message.value)
|
||||
if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON'))
|
||||
else entry.resolve(value)
|
||||
} else {
|
||||
@@ -313,7 +311,7 @@ export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCal
|
||||
* @returns one namespace object per declaration, in declaration order.
|
||||
*/
|
||||
export function makeNamespaces(
|
||||
data: Pick<WorkerBootData, 'namespaces'>,
|
||||
data: Pick<ProgramBootData, 'namespaces'>,
|
||||
port: BootstrapPort,
|
||||
pending: Map<number, PendingCall>,
|
||||
nextId: { value: number },
|
||||
@@ -344,7 +342,7 @@ export function makeNamespaces(
|
||||
},
|
||||
})
|
||||
try {
|
||||
port.postMessage({ type: 'call', id, global, name, args: encodeWorkerJson(detached) })
|
||||
port.postMessage({ type: 'call', id, global, name, args: encodeCodeJsonWire(detached) })
|
||||
} catch (error: unknown) {
|
||||
pending.delete(id)
|
||||
const message = `binding arguments must be structured-cloneable: ${error instanceof CapturedError ? error.message : String(error)}`
|
||||
@@ -366,9 +364,9 @@ export function makeNamespaces(
|
||||
* @param streams - stdout/stderr objects captured as program logs.
|
||||
* @returns after posting the done message.
|
||||
*/
|
||||
export async function runWorkerMain(
|
||||
export async function runProgram(
|
||||
port: BootstrapPort,
|
||||
data: WorkerBootData,
|
||||
data: ProgramBootData,
|
||||
streams: { stdout: PatchableStream; stderr: PatchableStream },
|
||||
): Promise<void> {
|
||||
const logs = new LogBuffer(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/** Length-prefixed JSON transport with bounded input and queued writes. */
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { jsonValueBytesUpTo } from './output-json.ts'
|
||||
|
||||
const stringify = JSON.stringify
|
||||
const parse = JSON.parse
|
||||
|
||||
/** One co-shipped process channel; the consumer owns frame validation and terminal outcomes. */
|
||||
export class JsonChannel {
|
||||
private readonly stream: Duplex
|
||||
private readonly maxBytes: number
|
||||
private readonly receive: (message: unknown, bytes: number) => void
|
||||
private readonly failure: (error: Error, kind: 'io' | 'protocol') => void
|
||||
private readonly header = Buffer.alloc(4)
|
||||
private headerBytes = 0
|
||||
private payload: Buffer | undefined
|
||||
private payloadBytes = 0
|
||||
private queuedBytes = 0
|
||||
private closed = false
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
stream: Duplex,
|
||||
maxBytes: number,
|
||||
receive: (message: unknown, bytes: number) => void,
|
||||
failure: (error: Error, kind: 'io' | 'protocol') => void,
|
||||
) {
|
||||
this.stream = stream
|
||||
this.maxBytes = maxBytes
|
||||
this.receive = receive
|
||||
this.failure = failure
|
||||
stream.on('data', this.onData)
|
||||
stream.on('error', this.onError)
|
||||
stream.on('end', this.onEnd)
|
||||
}
|
||||
|
||||
private readonly onError = (error: Error): void => { if (!this.closed) this.failure(error, 'io') }
|
||||
private readonly onEnd = (): void => { if (!this.closed) this.failure(new Error('control channel ended before the program settled'), 'io') }
|
||||
private readonly onData = (chunk: Buffer): void => {
|
||||
if (this.closed) return
|
||||
try {
|
||||
let offset = 0
|
||||
while (offset < chunk.length && !this.closed) {
|
||||
if (this.payload === undefined) {
|
||||
const bytes = Math.min(4 - this.headerBytes, chunk.length - offset)
|
||||
chunk.copy(this.header, this.headerBytes, offset, offset + bytes)
|
||||
this.headerBytes += bytes
|
||||
offset += bytes
|
||||
if (this.headerBytes !== 4) continue
|
||||
const length = this.header.readUInt32BE(0)
|
||||
if (length === 0 || length > this.maxBytes) throw new Error(`control frame exceeds ${this.maxBytes} bytes or is empty`)
|
||||
this.payload = Buffer.allocUnsafe(length)
|
||||
this.payloadBytes = 0
|
||||
this.headerBytes = 0
|
||||
}
|
||||
const bytes = Math.min(this.payload.length - this.payloadBytes, chunk.length - offset)
|
||||
chunk.copy(this.payload, this.payloadBytes, offset, offset + bytes)
|
||||
this.payloadBytes += bytes
|
||||
offset += bytes
|
||||
if (this.payloadBytes !== this.payload.length) continue
|
||||
const frame = this.payload
|
||||
this.payload = undefined
|
||||
this.payloadBytes = 0
|
||||
this.receive(parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)), frame.length)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.failure(error instanceof Error ? error : new Error(String(error)), 'protocol')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a bounded frame and honor stream backpressure.
|
||||
* @param message - JSON-only co-shipped protocol value.
|
||||
* @returns Resolves when this frame has been written, or rejects after transport failure.
|
||||
*/
|
||||
send(message: unknown): Promise<void> {
|
||||
if (this.closed) return Promise.reject(new Error('control channel is closed'))
|
||||
const size = jsonValueBytesUpTo(message as CodeJsonValue, this.maxBytes)
|
||||
if (size === undefined || this.queuedBytes + size > this.maxBytes) {
|
||||
return Promise.reject(new Error(`control output exceeds ${this.maxBytes} queued bytes`))
|
||||
}
|
||||
const body = Buffer.from(stringify(message), 'utf8')
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(body.length)
|
||||
this.queuedBytes += body.length
|
||||
const write = async (): Promise<void> => {
|
||||
if (this.closed) throw new Error('control channel is closed')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stop = (error?: Error): void => {
|
||||
this.stream.off('error', onError)
|
||||
this.stream.off('close', onClose)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const onError = (error: Error): void => { stop(error) }
|
||||
const onClose = (): void => { stop(new Error('control channel closed during a write')) }
|
||||
this.stream.once('error', onError)
|
||||
this.stream.once('close', onClose)
|
||||
this.stream.cork()
|
||||
this.stream.write(header)
|
||||
this.stream.write(body, (error) => { stop(error ?? undefined) })
|
||||
this.stream.uncork()
|
||||
})
|
||||
}
|
||||
const result = this.tail.then(write).finally(() => { this.queuedBytes -= body.length })
|
||||
this.tail = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** Stop reads and close the owned endpoint; pending writes reject on closure. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.payload = undefined
|
||||
this.stream.off('data', this.onData)
|
||||
this.stream.off('end', this.onEnd)
|
||||
this.stream.destroy()
|
||||
}
|
||||
|
||||
/** Wait for accepted writes to finish or fail. */
|
||||
async drain(): Promise<void> { await this.tail }
|
||||
}
|
||||
@@ -1,560 +1,308 @@
|
||||
/**
|
||||
* Worker-thread code runtime: a fresh worker runs each host-type-stripped TypeScript program
|
||||
* and bridges bindings over its message port. This is containment, not a security boundary:
|
||||
* model code has bash-equivalent trust despite an empty environment, a heap cap, measured
|
||||
* event-loop busy-time and wall-time budgets, and termination that also stops synchronous loops.
|
||||
* @module @deepseek-ai/dsh-code-runtime-node
|
||||
*/
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
/** Confined Node programs with host-owned bindings, output limits, and managed process cleanup. */
|
||||
import { stripTypeScriptTypes } from 'node:module'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult, CodeRunSandbox, CodeRunSpec } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { MAX_TIMER_DELAY_MS, clampTimeout } from '@deepseek-ai/dsh-timeout'
|
||||
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from './worker-json.ts'
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
import { validateBindings } from './bindings.ts'
|
||||
import { JsonChannel } from './channel.ts'
|
||||
import { bootstrapArgs } from './launch.ts'
|
||||
import type { LaunchConfig } from './launch.ts'
|
||||
import { OutputLedger } from './output-ledger.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from './json-wire.ts'
|
||||
import type { ProgramBootData } from './protocol.ts'
|
||||
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
|
||||
* once the worker's MEASURED event-loop active time
|
||||
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
|
||||
* measured busy time — not wall time, not host-side pending-call
|
||||
* bookkeeping — is what makes the budget both fair (a program awaiting a
|
||||
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
|
||||
* or not a decoy dispatch is in flight).
|
||||
*/
|
||||
computeMs?: number
|
||||
/**
|
||||
* Wall-clock ceiling in milliseconds; never pauses for anything. The
|
||||
* backstop for what busy-time cannot see (a program awaiting a promise
|
||||
* nobody will resolve). At most `2_147_483_647` (Node's maximum
|
||||
* `setTimeout` delay, about 24.9 days): a longer value is rejected at load
|
||||
* because `setTimeout` would clamp it to 1 ms.
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/**
|
||||
* Hard cap for serialized log-array, completion-value, and failure-message payloads;
|
||||
* fixed result-envelope syntax is excluded.
|
||||
*/
|
||||
/** Deployment-varying runtime bounds and launch choices. */
|
||||
export interface Config extends LaunchConfig {
|
||||
/** Default elapsed deadline, including nested tool and approval waits. */
|
||||
timeoutMs?: number
|
||||
/** Maximum elapsed deadline accepted by resolve. */
|
||||
maxTimeoutMs?: number
|
||||
/** Combined serialized logs, completion and diagnostic byte cap. */
|
||||
maxOutputBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
/** Managed process termination and output-drain grace in milliseconds. */
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/** {@link Config} after schemastery fills the defaults (every field present). */
|
||||
type ResolvedConfig = Required<Config>
|
||||
type ResolvedConfig = Required<Omit<Config, 'bootstrapPath'>> & Pick<Config, 'bootstrapPath'>
|
||||
interface LiveRun { controller: AbortController; finished: Promise<void> }
|
||||
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
|
||||
const STRIP_SUFFIX = '\n}'
|
||||
|
||||
/**
|
||||
* How often the host samples the worker's event-loop utilization for the
|
||||
* `computeMs` budget. An internal cadence, not config: the only effect of
|
||||
* the interval is budget-expiry granularity (a run can overshoot by up to
|
||||
* one interval), and nothing a deployment could tune here improves that
|
||||
* without burning host CPU.
|
||||
*/
|
||||
const ELU_POLL_INTERVAL_MS = 25
|
||||
function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
|
||||
function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
|
||||
|
||||
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
|
||||
const MIN_OUTPUT_BYTES = 4
|
||||
|
||||
/**
|
||||
* The seam's language-portable identifier subset (see
|
||||
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
|
||||
* namespace list must be usable against every backend regardless of language.
|
||||
*/
|
||||
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* The shell a program is wrapped in for the type-strip, matching the
|
||||
* grammatical context it will execute in (an async function body, where
|
||||
* top-level `return` and `await` are legal — a bare module parse would
|
||||
* reject the `return`). Strip mode is position-preserving (removed syntax
|
||||
* becomes whitespace, nothing shifts), so the wrapper survives the strip
|
||||
* byte-identical and the body slices back out with the model's own
|
||||
* line/column positions intact.
|
||||
*/
|
||||
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
|
||||
|
||||
/** One in-flight run's host-side state, tracked for disposal. */
|
||||
interface LiveRun {
|
||||
worker: Worker
|
||||
settle(failure: CodeRunFailure): void
|
||||
finished: Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable
|
||||
* directly on this repo's Node range via native type stripping — the file
|
||||
* is erasable-only with type-only relative imports); the built package
|
||||
* ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown
|
||||
* entry) because pkg's VFS Worker hook compiles string-path entries as
|
||||
* CommonJS.
|
||||
* The URL *pathname*'s extension says which world this module is in —
|
||||
* pathname, because dev-time module runners (vitest) may suffix
|
||||
* `import.meta.url` with a query string; relative resolution drops it. Worker
|
||||
* receives a filesystem string so pkg's VFS Worker hook can resolve it.
|
||||
*/
|
||||
/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
|
||||
const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url))
|
||||
|
||||
/** Render an unknown thrown value as a message, `Error` or not. */
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/** Resolve after a worker pipe emits all queued data, or closes/errors during termination. */
|
||||
function waitForPipeDrain(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)
|
||||
// Close the event-registration race if termination finished between the
|
||||
// initial state check and the listeners above.
|
||||
/* v8 ignore next -- this race cannot be scheduled deterministically between the adjacent state check and listener registration. */
|
||||
if (stream.readableEnded || stream.destroyed) done()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
|
||||
* can post anything — `null`, primitives, objects with poisoned fields — so
|
||||
* the compile-time `WorkerToHost` type means nothing here: everything is
|
||||
* re-validated and REBUILT field by field (a forged extra field never rides
|
||||
* along; a non-number call id can never be echoed into a reply). Junk returns
|
||||
* `undefined` and is dropped — a throw in the host's `message` listener would
|
||||
* crash the host process.
|
||||
*/
|
||||
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
const m = raw as Record<string, unknown>
|
||||
switch (m.type) {
|
||||
case 'call': {
|
||||
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
|
||||
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args as WorkerJsonWire }
|
||||
}
|
||||
case 'log': {
|
||||
if (typeof m.text !== 'string') return undefined
|
||||
return { type: 'log', text: m.text }
|
||||
}
|
||||
case 'output-limit': return { type: 'output-limit' }
|
||||
case 'done': {
|
||||
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value as WorkerJsonWire } : {} }
|
||||
const error = m.error
|
||||
if (typeof error !== 'object' || error === null) return undefined
|
||||
const { kind, message } = error as Record<string, unknown>
|
||||
if ((kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') || typeof message !== 'string') return undefined
|
||||
return { type: 'done', error: { kind, message } }
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that 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 absent-or-JSON completion against the combined cap. */
|
||||
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 a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||
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 its returned prefix fits the same budget. */
|
||||
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
|
||||
retained.push(prefix)
|
||||
retainedBytes += prefixBytes + separatorBytes
|
||||
}
|
||||
break
|
||||
}
|
||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
|
||||
return { logs: retained, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
|
||||
* the `codeRuntime` service; every cap comes from validated config. See the
|
||||
* module doc for the containment model and the Service Definition's class JSDoc for
|
||||
* the contract this implements (error-as-field, hostile-peer port,
|
||||
* no cross-run state, dispose to quiescence).
|
||||
*/
|
||||
/** Node provider; direct file effects use the same sandbox service as Bash. */
|
||||
export class NodeCodeRuntime extends CodeRuntime {
|
||||
static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
|
||||
static Config: z<Config> = z.object({
|
||||
computeMs: z.number().default(60_000),
|
||||
maxWallMs: z.number().default(600_000),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(67_108_864),
|
||||
maxOldGenerationSizeMb: z.number().default(512),
|
||||
maxMessageBytes: z.number().default(134_217_728),
|
||||
maxPendingCalls: z.number().default(128),
|
||||
graceMs: z.number().default(3_000),
|
||||
nodeExecutable: z.string(),
|
||||
bootstrapPath: z.string(),
|
||||
})
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'worker-thread'
|
||||
|
||||
readonly isolation = 'process'
|
||||
private readonly config: ResolvedConfig
|
||||
private readonly live = new Set<LiveRun>()
|
||||
private disposed = false
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery filled the defaults; the cast records that. Positivity is a
|
||||
// semantic check the schema's plain number type does not carry.
|
||||
this.config = config as ResolvedConfig
|
||||
this.config = { ...config, nodeExecutable: config.nodeExecutable ?? process.execPath } as ResolvedConfig
|
||||
for (const [key, value] of Object.entries(this.config)) {
|
||||
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-node: config.${key} must be a positive number, got ${String(value)}`)
|
||||
if (typeof value === 'number' && (!Number.isFinite(value) || value <= 0)) throw new Error(`code-runtime-node: ${key} must be positive and finite`)
|
||||
}
|
||||
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < MIN_OUTPUT_BYTES) {
|
||||
throw new Error(`dsh-code-runtime-node: config.maxOutputBytes must be a safe integer of at least ${MIN_OUTPUT_BYTES}, got ${String(this.config.maxOutputBytes)}`)
|
||||
for (const key of ['timeoutMs', 'maxTimeoutMs', 'graceMs'] as const) {
|
||||
if (this.config[key] > MAX_TIMER_DELAY_MS) throw new Error(`code-runtime-node: ${key} exceeds the supported timer range`)
|
||||
}
|
||||
// maxWallMs reaches setTimeout, which clamps any delay above
|
||||
// MAX_TIMER_DELAY_MS to 1 ms; the positivity check above accepts such a
|
||||
// value, so a 25-day ceiling would time the run out immediately.
|
||||
if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`dsh-code-runtime-node: config.maxWallMs must be at most ${MAX_TIMER_DELAY_MS} (Node clamps a longer setTimeout delay to 1ms), got ${String(this.config.maxWallMs)}`)
|
||||
if (!Number.isSafeInteger(this.config.maxOutputBytes) || this.config.maxOutputBytes < 4) throw new Error('code-runtime-node: maxOutputBytes must be an integer of at least 4')
|
||||
if (!Number.isSafeInteger(this.config.maxMessageBytes) || this.config.maxMessageBytes > 0xffff_ffff) throw new Error('code-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length')
|
||||
if (!Number.isSafeInteger(this.config.maxPendingCalls)) throw new Error('code-runtime-node: maxPendingCalls must be an integer')
|
||||
if (!Number.isSafeInteger(this.config.maxOldGenerationSizeMb)) throw new Error('code-runtime-node: maxOldGenerationSizeMb must be an integer')
|
||||
if (this.config.nodeExecutable.length === 0) throw new Error('code-runtime-node: nodeExecutable must be non-empty')
|
||||
if (this.config.bootstrapPath !== undefined && !isAbsolute(this.config.bootstrapPath)) throw new Error('code-runtime-node: bootstrapPath must be absolute')
|
||||
ctx.effect(() => async () => {
|
||||
this.disposed = true
|
||||
const active = [...this.live]
|
||||
for (const run of active) run.controller.abort('runtime disposed')
|
||||
await Promise.all(active.map(run => run.finished))
|
||||
}, 'Node code-runtime cleanup')
|
||||
}
|
||||
|
||||
override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode }
|
||||
|
||||
/**
|
||||
* Resolve an execution under explicit or deployment policy.
|
||||
* @param request - Program, bindings, optional cwd/deadline, and resolved authority.
|
||||
* @returns Complete execution inputs with a capped deadline.
|
||||
*/
|
||||
resolve(request: CodeRunRequest): CodeRunSpec {
|
||||
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')
|
||||
return {
|
||||
...request,
|
||||
cwd,
|
||||
timeoutMs: clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'code-runtime-node: timeoutMs'),
|
||||
sandboxPolicy,
|
||||
}
|
||||
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose to quiescence: mark the service unusable, fail every in-flight
|
||||
* run as aborted, and AWAIT each worker's exit so no worker outlives the
|
||||
* fiber.
|
||||
* Run a resolved program in a fresh managed and confined Node process.
|
||||
* @param spec - Inputs returned by resolve; missing authority is caller misuse.
|
||||
* @returns Output and file-confinement facts after managed cleanup.
|
||||
*/
|
||||
private async teardown(): Promise<void> {
|
||||
this.disposed = true
|
||||
const runs = [...this.live]
|
||||
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
|
||||
await Promise.all(runs.map(run => run.finished))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one program in a fresh worker. Program outcomes — including a
|
||||
* type-strip syntax error, which never spawns a worker — resolve with
|
||||
* `result.error`; the method rejects only for Service Definition contract misuse (a disposed
|
||||
* runtime, an invalid binding namespace).
|
||||
* @param request - the program, its bindings, and the abort signal.
|
||||
* @returns the run's outcome per the seam contract.
|
||||
*/
|
||||
async run(request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
if (this.disposed) throw new Error('dsh-code-runtime-node: run() after disposal')
|
||||
const bindings = this.validateBindings(request)
|
||||
if (request.signal?.aborted) {
|
||||
return this.failureBeforeWorker({ kind: 'abort', message: String(request.signal.reason) })
|
||||
}
|
||||
|
||||
let code: string
|
||||
async run(spec: CodeRunSpec): Promise<CodeRunResult> {
|
||||
if (this.disposed) throw new Error('code-runtime-node: run after disposal')
|
||||
if (spec.sandboxPolicy === undefined) throw new Error('code-runtime-node: run requires a resolved sandbox policy')
|
||||
if (!isAbsolute(spec.cwd) || !Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs) throw new Error('code-runtime-node: run requires resolved cwd and timeout')
|
||||
const bindings = validateBindings(spec)
|
||||
const controller = new AbortController()
|
||||
const completion = Promise.withResolvers<void>()
|
||||
const live = { controller, finished: completion.promise }
|
||||
this.live.add(live)
|
||||
try {
|
||||
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
|
||||
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
|
||||
} catch (error: unknown) {
|
||||
// A program that does not survive the type-strip (syntax error,
|
||||
// non-erasable syntax like `enum`) is a program failure, reported the
|
||||
// same way a thrown exception would be — and no worker ever spawns.
|
||||
return this.failureBeforeWorker({ kind: 'exception', message: messageOf(error) })
|
||||
return await this.execute(spec, spec.sandboxPolicy, bindings, controller)
|
||||
} finally {
|
||||
this.live.delete(live)
|
||||
completion.resolve()
|
||||
}
|
||||
|
||||
return await this.execute(request, code, bindings)
|
||||
}
|
||||
|
||||
/** Apply the outer-output ledger to failures that occur before a worker owns one. */
|
||||
private failureBeforeWorker(error: CodeRunFailure): CodeRunResult {
|
||||
return new OutputLedger(this.config.maxOutputBytes).failure([], error)
|
||||
}
|
||||
|
||||
/** Reject malformed binding globals or typed-error declarations as Service Definition contract misuse. */
|
||||
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
|
||||
const bindings = new Map<string, CodeBindingNamespace>()
|
||||
for (const namespace of request.bindings) {
|
||||
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
|
||||
}
|
||||
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
|
||||
// `console` is THIS backend's log-capture slot; the dunder entries exist
|
||||
// for the Python side — its seeded/wrapped slots plus the `__debug__`
|
||||
// compile-time constant — refused here too so the namespace list stays
|
||||
// portable across backends. The seam declaration is the single home for
|
||||
// why each entry is reserved.
|
||||
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(namespace.global)}`)
|
||||
}
|
||||
if (bindings.has(namespace.global)) {
|
||||
throw new Error(`dsh-code-runtime-node: 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) continue
|
||||
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
|
||||
}
|
||||
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: reserved binding global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
|
||||
throw new Error(`dsh-code-runtime-node: duplicate injected global ${JSON.stringify(descriptor.name)}`)
|
||||
}
|
||||
const member = descriptor.memberNameProperty
|
||||
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
|
||||
throw new Error(`dsh-code-runtime-node: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
|
||||
}
|
||||
errorClassNames.add(descriptor.name)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
|
||||
private execute(
|
||||
request: CodeRunRequest,
|
||||
code: string,
|
||||
private async execute(
|
||||
spec: CodeRunSpec,
|
||||
policy: SandboxExecutionPolicy,
|
||||
bindings: Map<string, CodeBindingNamespace>,
|
||||
controller: AbortController,
|
||||
): Promise<CodeRunResult> {
|
||||
const bootData: WorkerBootData = {
|
||||
code,
|
||||
namespaces: [...bindings].map(([global, namespace]) => ({
|
||||
global,
|
||||
names: Object.keys(namespace.functions),
|
||||
...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
|
||||
})),
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
const worker = new Worker(WORKER_PATH, {
|
||||
workerData: bootData,
|
||||
// Model code gets NO ambient environment — stronger than the scrubbed
|
||||
// env the defensive-patterns rule requires for spawned commands.
|
||||
env: {},
|
||||
// Hermetic flags too: without this the worker inherits the host process's execArgv (a
|
||||
// test runner's or tsx's loader hooks), which a bare isolate with an empty environment
|
||||
// cannot satisfy.
|
||||
execArgv: [],
|
||||
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
|
||||
// Backstop capture: the bootstrap patches JS-level writes into its own
|
||||
// ordered buffer, so these pipes normally stay silent; anything that
|
||||
// still arrives (native-level writes) is appended after the done logs.
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
})
|
||||
|
||||
return new Promise<CodeRunResult>((resolve) => {
|
||||
let settled = false
|
||||
const answered = new Set<number>()
|
||||
const logs: string[] = []
|
||||
const strayLogs: string[] = []
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
let terminalOverride: CodeRunResult | undefined
|
||||
|
||||
// Pipe and message-port delivery are independent. Continue bounded pipe
|
||||
// capture after a terminal message while worker termination drains bytes
|
||||
// that were already queued; `finish` materializes the result only after
|
||||
// termination completes.
|
||||
const captureStray = (chunk: Buffer): void => {
|
||||
/* v8 ignore next -- a second post-overflow chunk races immediate worker termination; the first overflow path is covered. */
|
||||
if (terminalOverride !== undefined) return
|
||||
const text = chunk.toString('utf8')
|
||||
if (!output.admit(text, strayLogs)) {
|
||||
const limited = output.limit([...logs, ...strayLogs, text])
|
||||
terminalOverride = limited
|
||||
finish(limited)
|
||||
}
|
||||
}
|
||||
worker.stdout.on('data', captureStray)
|
||||
worker.stderr.on('data', captureStray)
|
||||
|
||||
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
|
||||
// logs captured before timeout, abort, or failure remain in the result.
|
||||
let finishResolve!: () => void
|
||||
const finished = new Promise<void>((done) => { finishResolve = done })
|
||||
const finish = (finalize: CodeRunResult | (() => CodeRunResult)): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearInterval(eluTimer)
|
||||
clearTimeout(wallTimer)
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
this.live.delete(live)
|
||||
// Let the poll phase deliver pipe bytes already queued independently
|
||||
// of the terminal port message before termination closes the streams.
|
||||
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
|
||||
const stdoutDrained = waitForPipeDrain(worker.stdout)
|
||||
const stderrDrained = waitForPipeDrain(worker.stderr)
|
||||
await Promise.all([worker.terminate(), stdoutDrained, stderrDrained])
|
||||
const result = terminalOverride ?? (typeof finalize === 'function' ? finalize() : finalize)
|
||||
finishResolve()
|
||||
resolve(result)
|
||||
})
|
||||
}
|
||||
|
||||
const onDone = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'done') return
|
||||
if (message.error) {
|
||||
const error = message.error
|
||||
finish(() => output.failure([...logs, ...strayLogs], error))
|
||||
return
|
||||
}
|
||||
if (message.value === undefined) {
|
||||
finish(() => output.success([...logs, ...strayLogs]))
|
||||
return
|
||||
}
|
||||
const value = decodeWorkerJson(message.value)
|
||||
if (value === undefined) {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'invalid-output', message: 'program completion must be lossless JSON' }))
|
||||
} else {
|
||||
finish(() => output.success([...logs, ...strayLogs], value))
|
||||
}
|
||||
}
|
||||
|
||||
const onCall = (message: WorkerToHost): void => {
|
||||
if (message.type !== 'call' || settled) return
|
||||
// Hostile-peer rules: a duplicate id is ignored, an unknown name is
|
||||
// answered with a failure, and a binding throw/reject becomes the
|
||||
// program-side rejection — contained here, never a host crash.
|
||||
if (answered.has(message.id)) return
|
||||
answered.add(message.id)
|
||||
const reply = (payload: ReplyMessage): void => {
|
||||
if (settled) return
|
||||
// Canonical resolutions were snapshotted as lossless JSON before
|
||||
// this point, so this payload is structured-cloneable by contract.
|
||||
worker.postMessage(payload)
|
||||
}
|
||||
const record = bindings.get(message.global)?.functions
|
||||
// Own-property lookup only: a forged name like 'constructor' or
|
||||
// 'hasOwnProperty' must not walk the record's prototype chain and
|
||||
// reach a callable the consumer never declared.
|
||||
const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
|
||||
if (typeof fn !== 'function') {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
|
||||
return
|
||||
}
|
||||
const args = decodeWorkerJson(message.args)
|
||||
if (args === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const output = new OutputLedger(this.config.maxOutputBytes)
|
||||
const logs: string[] = []
|
||||
const sandbox: CodeRunSandbox = { mode: policy.mode, denied: false }
|
||||
const result = Promise.withResolvers<CodeRunResult>()
|
||||
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
|
||||
let handle: SubprocessHandle | undefined
|
||||
let channel: JsonChannel | undefined
|
||||
let confined: ConfinedArgv | undefined
|
||||
let settled = false
|
||||
let timedOut = false
|
||||
let outputOverflow = false
|
||||
let stderr = ''
|
||||
let parsing = true
|
||||
const wallTimer = setTimeout(() => { timedOut = true; controller.abort('execution deadline reached') }, spec.timeoutMs)
|
||||
const finish = (failure?: CodeRunFailure, value?: CodeJsonValue): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(wallTimer)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
channel?.close()
|
||||
void (async () => {
|
||||
if (handle !== undefined) {
|
||||
handle.terminate()
|
||||
try {
|
||||
const resolved = await fn(args)
|
||||
let value: CodeJsonValue | undefined
|
||||
try {
|
||||
value = snapshotJsonValue(resolved)
|
||||
} catch {
|
||||
value = undefined
|
||||
}
|
||||
if (value === undefined) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
|
||||
} else {
|
||||
reply({ type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(value) })
|
||||
}
|
||||
await Promise.all([handle.done.catch(() => {}), handle.waitForExit()])
|
||||
} catch (error: unknown) {
|
||||
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
|
||||
failure = { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(error)}` }
|
||||
} finally {
|
||||
handle.stdout?.destroy()
|
||||
handle.stderr?.destroy()
|
||||
}
|
||||
})()
|
||||
}
|
||||
const outcome = outputOverflow ? output.limit(logs)
|
||||
: failure === undefined ? output.success(logs, value) : output.failure(logs, failure)
|
||||
result.resolve({ ...outcome, sandbox: { ...sandbox } })
|
||||
})()
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
finish(timedOut
|
||||
? { kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms)` }
|
||||
: { kind: 'abort', message: messageOf(signal.reason) })
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
try {
|
||||
if (settled) return await result.promise
|
||||
const stripped = stripTypeScriptTypes(STRIP_PREFIX + spec.program + STRIP_SUFFIX)
|
||||
parsing = false
|
||||
const data: ProgramBootData = {
|
||||
code: stripped.slice(STRIP_PREFIX.length, stripped.length - STRIP_SUFFIX.length),
|
||||
namespaces: [...bindings.values()].map(binding => ({
|
||||
global: binding.global,
|
||||
names: Object.keys(binding.functions),
|
||||
...binding.errorClass ? { errorClass: binding.errorClass } : {},
|
||||
})),
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
}
|
||||
|
||||
worker.on('message', (raw: unknown) => {
|
||||
// Parse before touching: the peer can post ANY shape, and a throw in
|
||||
// this listener would crash the host process. Junk drops silently.
|
||||
const message = parseWorkerMessage(raw)
|
||||
if (!message) return
|
||||
if (message.type === 'log' && !settled && !output.admit(message.text, logs)) {
|
||||
const limited = output.limit([...logs, ...strayLogs, message.text])
|
||||
finish(limited)
|
||||
const executable = await this.ctx.subprocess.resolveExecutable(this.config.nodeExecutable, undefined, signal)
|
||||
if (settled) return await result.promise
|
||||
const argv = [executable, `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`, ...bootstrapArgs(this.ctx.fs, this.config, this.config.maxMessageBytes)]
|
||||
confined = policy.mode === 'danger-full-access' ? undefined : this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode })
|
||||
if (confined !== undefined) sandbox.enforcement = confined.enforcement
|
||||
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env).map(key => [key, undefined]))
|
||||
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')
|
||||
const admit = (text: string): void => {
|
||||
if (outputOverflow || text.length === 0) return
|
||||
if (!output.admit(text, logs)) {
|
||||
outputOverflow = true
|
||||
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.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = stderrDecoder.write(chunk)
|
||||
stderr = (stderr + text).slice(-this.config.maxOutputBytes)
|
||||
admit(text)
|
||||
})
|
||||
handle.stderr?.on('end', () => { admit(stderrDecoder.end()) })
|
||||
let ready = false
|
||||
let nextId = 1
|
||||
let pending = 0
|
||||
let pendingBytes = 0
|
||||
const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) }
|
||||
const processFinished = (outcome: SubprocessOutcome): void => {
|
||||
if (settled) return
|
||||
finish((confined !== undefined && classifyRunnerFailure(outcome.exitCode, stderr, confined.runnerFailureRules) !== undefined)
|
||||
? { kind: 'sandbox-unavailable', message: stderr }
|
||||
: { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` })
|
||||
}
|
||||
const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => {
|
||||
if (settled) return
|
||||
if (!record(raw)) { protocolFailure('invalid control frame'); return }
|
||||
if (!ready) {
|
||||
if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return }
|
||||
ready = true
|
||||
void transport.send({ type: 'boot', data }).catch((error) => { protocolFailure(messageOf(error)) })
|
||||
return
|
||||
}
|
||||
if (message.type === 'output-limit' && !settled) {
|
||||
const limited = output.limit([...logs, ...strayLogs])
|
||||
finish(limited)
|
||||
return
|
||||
switch (raw.type) {
|
||||
case 'log':
|
||||
if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return }
|
||||
admit(raw.text)
|
||||
return
|
||||
case 'output-limit': outputOverflow = true; finish(); return
|
||||
case 'done': {
|
||||
if (raw.error !== undefined) {
|
||||
if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return }
|
||||
const failure = { kind: raw.error.kind, message: raw.error.message } as CodeRunFailure
|
||||
if (confined !== undefined) {
|
||||
sandbox.denied = confined.denialSignatures.some(signature =>
|
||||
failure.message.toLowerCase().includes(signature.toLowerCase()))
|
||||
}
|
||||
if (failure.kind === 'output-limit') outputOverflow = true
|
||||
finish(failure)
|
||||
return
|
||||
}
|
||||
const value = raw.value === undefined ? undefined : decodeCodeJsonWire(raw.value)
|
||||
if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
else finish(undefined, value)
|
||||
return
|
||||
}
|
||||
case 'call': {
|
||||
if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return }
|
||||
nextId += 1
|
||||
const functions = bindings.get(raw.global)?.functions
|
||||
const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined
|
||||
if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return }
|
||||
const args = decodeCodeJsonWire(raw.args)
|
||||
if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return }
|
||||
if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return }
|
||||
const id = raw.id as number
|
||||
void (async () => {
|
||||
let reply: unknown
|
||||
try {
|
||||
const value = snapshotJsonValue(await fn(args))
|
||||
if (value === undefined) throw new Error('binding resolution must be lossless JSON')
|
||||
reply = { type: 'reply', id, ok: true, value: encodeCodeJsonWire(value) }
|
||||
} catch (error: unknown) {
|
||||
reply = { type: 'reply', id, ok: false, message: messageOf(error) }
|
||||
} finally {
|
||||
pending -= 1
|
||||
pendingBytes -= bytes
|
||||
}
|
||||
if (!settled) await transport.send(reply)
|
||||
})().catch((error) => { protocolFailure(messageOf(error)) })
|
||||
return
|
||||
}
|
||||
default: protocolFailure('unknown control message')
|
||||
}
|
||||
onCall(message)
|
||||
onDone(message)
|
||||
}, (error, kind) => {
|
||||
if (kind === 'protocol') protocolFailure(messageOf(error))
|
||||
else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) })
|
||||
else void launched.done.then(processFinished, (failure) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
|
||||
})
|
||||
worker.on('error', (error: Error) => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker error: ${error.message}` }))
|
||||
})
|
||||
worker.on('exit', (exitCode: number) => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` }))
|
||||
})
|
||||
|
||||
// The compute budget reads the worker's own measured busy time, so a
|
||||
// hot loop expires it no matter what dispatches are in flight, while a
|
||||
// program idling on a slow binding accrues nothing.
|
||||
const eluTimer = setInterval(() => {
|
||||
const elu = worker.performance.eventLoopUtilization()
|
||||
if (elu.active > this.config.computeMs) {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` }))
|
||||
}
|
||||
}, ELU_POLL_INTERVAL_MS)
|
||||
const wallTimer = setTimeout(() => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` }))
|
||||
}, this.config.maxWallMs)
|
||||
const onAbort = (): void => {
|
||||
finish(() => output.failure([...logs, ...strayLogs], { kind: 'abort', message: String(request.signal?.reason) }))
|
||||
}
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const live: LiveRun = {
|
||||
worker,
|
||||
finished,
|
||||
settle: (failure: CodeRunFailure) => { finish(() => output.failure([...logs, ...strayLogs], failure)) },
|
||||
}
|
||||
this.live.add(live)
|
||||
})
|
||||
channel = transport
|
||||
void launched.done.then((outcome) => { setImmediate(() => { processFinished(outcome) }) }, (error) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
|
||||
} catch (error: unknown) {
|
||||
finish({ kind: error instanceof SandboxUnavailableError ? 'sandbox-unavailable' : parsing ? 'exception' : 'worker-exit', message: messageOf(error) })
|
||||
}
|
||||
return await result.promise
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Lossless-JSON snapshots for the dependency-free source worker closure.
|
||||
* @module @deepseek-ai/dsh-code-runtime-node/worker-json
|
||||
* @module @deepseek-ai/dsh-code-runtime-node/json-wire
|
||||
*/
|
||||
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
@@ -249,15 +249,15 @@ type WorkerJsonToken = null | boolean | number | string | ArrayWireToken | Objec
|
||||
* markers and scalar leaves share one flat token array, so `worker_threads`
|
||||
* never has to structured-clone the value's application nesting.
|
||||
*/
|
||||
export type WorkerJsonWire = WorkerJsonToken[]
|
||||
export type CodeJsonWire = WorkerJsonToken[]
|
||||
|
||||
/**
|
||||
* Flatten one validated JSON value for the worker-thread message port.
|
||||
* @param value - the lossless JSON value to transport.
|
||||
* @returns a pre-order token stream whose own nesting is bounded.
|
||||
*/
|
||||
export function encodeWorkerJson(value: CodeJsonValue): WorkerJsonWire {
|
||||
const wire: WorkerJsonWire = []
|
||||
export function encodeCodeJsonWire(value: CodeJsonValue): CodeJsonWire {
|
||||
const wire: CodeJsonWire = []
|
||||
const pending: CodeJsonValue[] = [value]
|
||||
for (let current = takeLast(pending); current !== undefined; current = takeLast(pending)) {
|
||||
if (current === null || typeof current === 'boolean' || typeof current === 'number' || typeof current === 'string') {
|
||||
@@ -346,7 +346,7 @@ function containerToken(value: object): ArrayWireToken | ObjectWireToken | undef
|
||||
* @param input - untrusted message-port payload.
|
||||
* @returns the detached JSON value, or `undefined` when the wire is invalid.
|
||||
*/
|
||||
export function decodeWorkerJson(input: unknown): CodeJsonValue | undefined {
|
||||
export function decodeCodeJsonWire(input: unknown): CodeJsonValue | undefined {
|
||||
try {
|
||||
if (!intrinsicArrayIsArray(input) || !isDenseArray(input) || input.length === 0) return undefined
|
||||
const wire = input as unknown[]
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Select source or built bootstrap assets in the mounted execution world. */
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
|
||||
export interface LaunchConfig {
|
||||
/** Executable in the subprocess world; defaults to the current Node executable. */
|
||||
nodeExecutable?: string
|
||||
/** Absolute preinstalled built bootstrap in the execution world. */
|
||||
bootstrapPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Select explicit arguments without inheriting host loader or inspector flags.
|
||||
* @param fs - Filesystem mapping host bootstrap assets into the process world.
|
||||
* @param config - Optional preinstalled built bootstrap.
|
||||
* @param maxMessageBytes - Validated frame and queued-write limit.
|
||||
* @returns Arguments following the resolved Node executable.
|
||||
*/
|
||||
export function bootstrapArgs(fs: FileSystem, config: LaunchConfig, maxMessageBytes: number): string[] {
|
||||
if (config.bootstrapPath !== undefined) return [config.bootstrapPath, String(maxMessageBytes)]
|
||||
if ('pkg' in process) return [String(maxMessageBytes)]
|
||||
const mapped = (path: string): string => {
|
||||
const result = fs.processPathFromHostPath(path)
|
||||
if (result === undefined) throw new Error(`code runtime bootstrap is unavailable in the subprocess execution world: ${path}`)
|
||||
return result
|
||||
}
|
||||
if (!new URL(import.meta.url).pathname.endsWith('.ts')) {
|
||||
return [mapped(fileURLToPath(new URL('./process.js', import.meta.url))), String(maxMessageBytes)]
|
||||
}
|
||||
const entry = mapped(fileURLToPath(new URL('./process.ts', import.meta.url)))
|
||||
const subprocess = dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess/package.json')))
|
||||
const helper = mapped(resolve(subprocess, 'src/control.ts'))
|
||||
const source = `const {openInheritedControlChannel}=await import(${JSON.stringify(pathToFileURL(helper).href)});const {runNodeMain}=await import(${JSON.stringify(pathToFileURL(entry).href)});await runNodeMain(openInheritedControlChannel(),${maxMessageBytes},process);`
|
||||
return ['--input-type=module', '--eval', source]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Combined byte accounting for retained program output. */
|
||||
import type { CodeJsonValue, CodeRunFailure, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||
|
||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||
export class OutputLedger {
|
||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||
private entries = 0
|
||||
|
||||
constructor(private readonly maxBytes: number) {}
|
||||
|
||||
/** Admit one exact log entry, or report that 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 absent-or-JSON completion against the combined cap. */
|
||||
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 a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||
return { logs, error }
|
||||
}
|
||||
|
||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||
limit(logs: string[]): CodeRunResult {
|
||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||
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 its returned prefix fits the same budget. */
|
||||
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
|
||||
retained.push(prefix)
|
||||
retainedBytes += prefixBytes + separatorBytes
|
||||
}
|
||||
break
|
||||
}
|
||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
|
||||
return { logs: retained, error: { kind: 'output-limit', message } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Private built Node-runtime child entry; source execution calls runNodeMain directly. */
|
||||
import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control'
|
||||
import { runNodeMain } from './process.ts'
|
||||
|
||||
await runNodeMain(openInheritedControlChannel(), Number(process.argv[2]), process)
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Node child execution over an inherited control channel; no Harness services run here. */
|
||||
import type { Duplex } from 'node:stream'
|
||||
import { JsonChannel } from './channel.ts'
|
||||
import { runProgram } from './bootstrap.ts'
|
||||
import type { PatchableStream } from './bootstrap.ts'
|
||||
import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts'
|
||||
|
||||
/** Process-owned environment and output streams consumed by the child bootstrap. */
|
||||
export interface ProgramProcess {
|
||||
env: NodeJS.ProcessEnv
|
||||
stdout: PatchableStream
|
||||
stderr: PatchableStream
|
||||
exitCode: string | number | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one host-supplied program after the control handshake.
|
||||
* @param stream - Inherited, already-adopted control endpoint.
|
||||
* @param maxMessageBytes - Host-validated maximum frame and queued-write bytes.
|
||||
* @param processState - Environment, output streams and exit status of this Node child.
|
||||
* @returns After the program has settled and its control output has flushed.
|
||||
*/
|
||||
export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise<void> {
|
||||
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit')
|
||||
for (const key of Object.keys(processState.env)) Reflect.deleteProperty(processState.env, key)
|
||||
const boot = Promise.withResolvers<ProgramBootData>()
|
||||
const listeners: Array<(message: ReplyMessage) => void> = []
|
||||
let started = false
|
||||
let failed = false
|
||||
const channel = new JsonChannel(stream, maxMessageBytes, (raw) => {
|
||||
if (!started) {
|
||||
started = true
|
||||
if (typeof raw !== 'object' || raw === null || (raw as { type?: unknown }).type !== 'boot') {
|
||||
boot.reject(new Error('expected program boot frame'))
|
||||
return
|
||||
}
|
||||
boot.resolve((raw as { data: ProgramBootData }).data)
|
||||
return
|
||||
}
|
||||
for (const listener of listeners) listener(raw as ReplyMessage)
|
||||
}, (error) => {
|
||||
failed = true
|
||||
boot.reject(error)
|
||||
channel.close()
|
||||
processState.exitCode = 1
|
||||
})
|
||||
const pending = new Set<Promise<void>>()
|
||||
const send = (message: ProgramToHost): void => {
|
||||
const task = channel.send(message).catch(() => {
|
||||
failed = true
|
||||
channel.close()
|
||||
processState.exitCode = 1
|
||||
}).finally(() => { pending.delete(task) })
|
||||
pending.add(task)
|
||||
}
|
||||
try {
|
||||
await channel.send({ type: 'ready' })
|
||||
const data = await boot.promise
|
||||
await runProgram({
|
||||
postMessage: send,
|
||||
on: (_event, listener) => { listeners.push(listener) },
|
||||
}, data, { stdout: processState.stdout, stderr: processState.stderr })
|
||||
while (pending.size > 0) await Promise.all(pending)
|
||||
await channel.drain()
|
||||
} finally {
|
||||
channel.close()
|
||||
if (failed) processState.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Versionless, structured-clone wire protocol between co-shipped host and worker code. The host
|
||||
* treats inbound traffic as hostile because model code can forge `parentPort` messages; the
|
||||
* worker trusts host replies.
|
||||
* JSON message vocabulary carried by the bounded process channel. The host validates every
|
||||
* message because model code can write directly to the inherited descriptor.
|
||||
* @module @deepseek-ai/dsh-code-runtime-node/src/protocol
|
||||
*/
|
||||
|
||||
import type { WorkerJsonWire } from './worker-json.ts'
|
||||
import type { CodeJsonWire } from './json-wire.ts'
|
||||
|
||||
/** What the host hands the worker at spawn, via `workerData`. */
|
||||
export interface WorkerBootData {
|
||||
/** What the host hands the program at spawn, after the readiness handshake. */
|
||||
export interface ProgramBootData {
|
||||
/** The type-stripped (plain JS) program body. */
|
||||
code: string
|
||||
/** Binding namespaces to materialize; functions themselves stay host-side. */
|
||||
@@ -21,32 +20,32 @@ export interface WorkerBootData {
|
||||
maxOutputBytes: number
|
||||
}
|
||||
|
||||
/** Worker → host: one bridged binding call. */
|
||||
/** Program → host: one bridged binding call. */
|
||||
interface CallMessage {
|
||||
type: 'call'
|
||||
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
|
||||
/** Program-issued correlation id; the host answers each id at most once and ignores duplicates. */
|
||||
id: number
|
||||
/** The namespace global the call targets. */
|
||||
global: string
|
||||
/** The function name within the namespace. */
|
||||
name: string
|
||||
/** The single argument as a flat lossless-JSON wire value. */
|
||||
args: WorkerJsonWire
|
||||
args: CodeJsonWire
|
||||
}
|
||||
|
||||
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
|
||||
/** Program → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
|
||||
interface LogMessage {
|
||||
type: 'log'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Worker → host: worker-side capture or completion measurement exceeded the outer cap. */
|
||||
/** Program → host: program-side capture or completion measurement exceeded the outer cap. */
|
||||
interface OutputLimitMessage {
|
||||
type: 'output-limit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker → host: the program settled. `error` carries a program exception,
|
||||
* Program → host: the program settled. `error` carries a program exception,
|
||||
* invalid completion, or output overflow (budgets, aborts, and substrate death
|
||||
* are observed host-side). `value` is present only on a clean completion that
|
||||
* produced one, as a flat wire value already lossless and admitted against
|
||||
@@ -55,14 +54,14 @@ interface OutputLimitMessage {
|
||||
*/
|
||||
export interface DoneMessage {
|
||||
type: 'done'
|
||||
value?: WorkerJsonWire
|
||||
value?: CodeJsonWire
|
||||
error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string }
|
||||
}
|
||||
|
||||
/** Every message the worker sends. */
|
||||
export type WorkerToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
|
||||
/** Every message the program sends. */
|
||||
export type ProgramToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage
|
||||
|
||||
/** Host → worker: the answer to one {@link CallMessage}. */
|
||||
/** Host → program: the answer to one {@link CallMessage}. */
|
||||
export type ReplyMessage =
|
||||
| { type: 'reply'; id: number; ok: true; value: WorkerJsonWire }
|
||||
| { type: 'reply'; id: number; ok: true; value: CodeJsonWire }
|
||||
| { type: 'reply'; id: number; ok: false; message: string }
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Spawn-only worker entrypoint over {@link runWorkerMain}. Executable logic stays in
|
||||
* `bootstrap.ts` for in-process coverage; real-worker tests cover this glue.
|
||||
* @module @deepseek-ai/dsh-code-runtime-node/src/worker
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { runWorkerMain } from './bootstrap.ts'
|
||||
import type { WorkerBootData } from './protocol.ts'
|
||||
|
||||
// A worker always has a parent port; guard loudly rather than run detached.
|
||||
if (!parentPort) throw new Error('dsh-code-runtime-node: worker entry loaded outside a worker thread')
|
||||
|
||||
void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateBindings } from '../src/bindings.ts'
|
||||
|
||||
describe('program binding validation', () => {
|
||||
it.each(['$name', '', 'while', 'lambda', 'console'])('rejects unusable global %s', (global) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global, functions: {} }] })).toThrow()
|
||||
})
|
||||
it('rejects duplicated globals and accepts arbitrary own member names', () => {
|
||||
const functions = Object.create(null) as Record<string, () => Promise<string>>
|
||||
functions.__proto__ = async () => 'own'
|
||||
expect(validateBindings({ program: '', bindings: [{ global: 'tools', functions }] }).get('tools')?.functions).toBe(functions)
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions }, { global: 'tools', functions }] })).toThrow('duplicate')
|
||||
})
|
||||
it.each(['$name', 'while', 'console', 'tools'])('rejects invalid error class name %s', (name) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions: {}, errorClass: { name, memberNameProperty: 'member' } }] })).toThrow()
|
||||
})
|
||||
it.each(['', 'name', '__member__'])('rejects invalid error member %s', (memberNameProperty) => {
|
||||
expect(() => validateBindings({ program: '', bindings: [{ global: 'tools', functions: {}, errorClass: { name: 'ToolError', memberNameProperty } }] })).toThrow()
|
||||
})
|
||||
it('rejects two injected constructors with the same name', () => {
|
||||
expect(() => validateBindings({ program: '', bindings: ['first', 'second'].map(global => ({ global, functions: {}, errorClass: { name: 'ToolError', memberNameProperty: 'member' } })) })).toThrow('duplicate')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
import type { ReplyMessage, ProgramToHost } from '../src/protocol.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
/**
|
||||
* An in-process stand-in for the worker's parentPort: the test plays the
|
||||
@@ -12,12 +12,12 @@ import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
* isolate (real-worker behavior is pinned by runtime.spec.ts).
|
||||
*/
|
||||
class FakePort implements BootstrapPort {
|
||||
sent: WorkerToHost[] = []
|
||||
sent: ProgramToHost[] = []
|
||||
private readonly emitter = new EventEmitter()
|
||||
/** Host-scripted responder; return undefined to leave the call pending. */
|
||||
respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
|
||||
respond: (message: ProgramToHost) => ReplyMessage | undefined = () => undefined
|
||||
|
||||
postMessage(message: WorkerToHost): void {
|
||||
postMessage(message: ProgramToHost): void {
|
||||
this.sent.push(message)
|
||||
const reply = this.respond(message)
|
||||
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
|
||||
@@ -35,13 +35,13 @@ class FakePort implements BootstrapPort {
|
||||
return this.sent.filter(message => message.type === 'log').map(message => message.text)
|
||||
}
|
||||
|
||||
done(): WorkerToHost | undefined {
|
||||
done(): ProgramToHost | undefined {
|
||||
return this.sent.find(message => message.type === 'done')
|
||||
}
|
||||
|
||||
doneValue(): unknown {
|
||||
const done = this.done()
|
||||
return done?.type === 'done' && done.value !== undefined ? decodeWorkerJson(done.value) : undefined
|
||||
return done?.type === 'done' && done.value !== undefined ? decodeCodeJsonWire(done.value) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('captureStreamWrites', () => {
|
||||
describe('prepareCompletion', () => {
|
||||
it('omits undefined and passes lossless JSON values exactly', () => {
|
||||
expect(prepareCompletion(undefined, 100)).toEqual({})
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeWorkerJson({ a: [1, 'two'] }) })
|
||||
expect(prepareCompletion({ a: [1, 'two'] }, 100)).toEqual({ value: encodeCodeJsonWire({ a: [1, 'two'] }) })
|
||||
})
|
||||
|
||||
it('turns every lossy completion shape into invalid-output', () => {
|
||||
@@ -162,7 +162,7 @@ describe('prepareCompletion', () => {
|
||||
})
|
||||
|
||||
it('measures the exact JSON serialization at and over the boundary', () => {
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: encodeWorkerJson('€') })
|
||||
expect(prepareCompletion('€', 5)).toEqual({ value: encodeCodeJsonWire('€') })
|
||||
expect(prepareCompletion('€', 4)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
@@ -216,7 +216,7 @@ describe('makeNamespaces', () => {
|
||||
it('exposes prototype-colliding names as ordinary own properties', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(`${message.name}-ok`) }
|
||||
? { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(`${message.name}-ok`) }
|
||||
: undefined
|
||||
const pending = new Map<number, PendingCall>()
|
||||
wireReplies(port, pending)
|
||||
@@ -311,15 +311,15 @@ describe('makeNamespaces', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorkerMain', () => {
|
||||
describe('runProgram', () => {
|
||||
it('runs a program end-to-end: bindings, console, return value', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
const args = decodeWorkerJson(message.args) as { n: number }
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson(args.n * 2) }
|
||||
const args = decodeCodeJsonWire(message.args) as { n: number }
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(args.n * 2) }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
|
||||
namespaces: [{ global: 'tools', names: ['double'] }],
|
||||
@@ -330,7 +330,7 @@ describe('runWorkerMain', () => {
|
||||
|
||||
it('reports worker-side log capture overflow before completing', async () => {
|
||||
const port = new FakePort()
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
maxOutputBytes: 4,
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
@@ -345,7 +345,7 @@ describe('runWorkerMain', () => {
|
||||
|
||||
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())
|
||||
await runProgram(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
|
||||
const done = port.done()
|
||||
expect(done?.type).toBe('done')
|
||||
expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception')
|
||||
@@ -355,17 +355,17 @@ describe('runWorkerMain', () => {
|
||||
|
||||
it('renders non-Error throws and stack-less Errors on the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
await runProgram(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } })
|
||||
|
||||
const barePort = new FakePort()
|
||||
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
await runProgram(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, {
|
||||
await runProgram(rawPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw "x".repeat(1_000_000)',
|
||||
namespaces: [],
|
||||
@@ -376,7 +376,7 @@ describe('runWorkerMain', () => {
|
||||
})
|
||||
|
||||
const stackPort = new FakePort()
|
||||
await runWorkerMain(stackPort, {
|
||||
await runProgram(stackPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw new Error("x".repeat(1_000_000))',
|
||||
namespaces: [],
|
||||
@@ -390,7 +390,7 @@ describe('runWorkerMain', () => {
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [toolNamespace(['x'])],
|
||||
@@ -403,7 +403,7 @@ describe('runWorkerMain', () => {
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
|
||||
namespaces: [{
|
||||
@@ -420,10 +420,10 @@ describe('runWorkerMain', () => {
|
||||
port.respond = (message) => {
|
||||
if (message.type !== 'call') return undefined
|
||||
// Deliver a stray reply first; the real one follows.
|
||||
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeWorkerJson('stray') })
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeWorkerJson('real') }
|
||||
port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodeCodeJsonWire('stray') })
|
||||
return { type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire('real') }
|
||||
}
|
||||
await runWorkerMain(port, {
|
||||
await runProgram(port, {
|
||||
...BOOT,
|
||||
code: 'return await tools.x({})',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
@@ -434,7 +434,7 @@ describe('runWorkerMain', () => {
|
||||
it('captures raw stream writes through the patched process streams', async () => {
|
||||
const port = new FakePort()
|
||||
const streams = fakeStreams()
|
||||
await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
|
||||
await runProgram(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
|
||||
streams.stdout.write('never seen — already restored? no: patch persists in worker')
|
||||
// The patch stays installed for the worker's lifetime; writes during the
|
||||
// program landed in order. Here the program wrote nothing via streams, so
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/** Host budget decisions use controlled clocks and ELU samples; worker execution and binding transport stay real. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { NodeCodeRuntime } from '@deepseek-ai/dsh-code-runtime-node'
|
||||
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
const meter = vi.hoisted(() => ({ sample: vi.fn() }))
|
||||
|
||||
vi.mock('node:worker_threads', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:worker_threads')>()
|
||||
return {
|
||||
...original,
|
||||
Worker: class extends original.Worker {
|
||||
constructor(...args: ConstructorParameters<typeof original.Worker>) {
|
||||
super(...args)
|
||||
this.performance.eventLoopUtilization = meter.sample
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('worker budgets with controlled ELU samples and real binding transport', () => {
|
||||
let ctx: Context
|
||||
let controller: AbortController
|
||||
let run: Promise<CodeRunResult> | undefined
|
||||
let release: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = new Context()
|
||||
controller = new AbortController()
|
||||
run = undefined
|
||||
release = undefined
|
||||
meter.sample.mockReset().mockReturnValue({ active: 10, idle: 0, utilization: 1 })
|
||||
// Worker bootstrap and scheduling contribute to ELU active time; only the
|
||||
// measured input and host deadlines are controlled, not worker execution.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const owned = { ctx, controller, run, release }
|
||||
try {
|
||||
owned.controller.abort('test cleanup')
|
||||
owned.release?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
try {
|
||||
await owned.run
|
||||
} finally {
|
||||
await owned.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function pendingBinding(): Promise<void> {
|
||||
await ctx.plugin(NodeCodeRuntime, { computeMs: 1_000, maxWallMs: 30_000 })
|
||||
let entered!: () => void
|
||||
const ready = new Promise<void>((resolve) => { entered = resolve })
|
||||
const binding = new Promise<string>((resolve) => { release = () => { resolve('slow-done') } })
|
||||
run = ctx.codeRuntime.run({
|
||||
program: 'return await tools.slow({})',
|
||||
bindings: [{ global: 'tools', functions: { slow: () => { entered(); return binding } } }],
|
||||
signal: controller.signal,
|
||||
})
|
||||
await Promise.race([
|
||||
ready,
|
||||
run.then((result) => { throw new Error('Worker settled before binding entry: ' + JSON.stringify(result)) }),
|
||||
])
|
||||
}
|
||||
|
||||
it('does not charge a binding wait longer than the compute budget', async () => {
|
||||
await pendingBinding()
|
||||
const settled = vi.fn()
|
||||
void run!.then(settled, settled)
|
||||
meter.sample.mockReturnValue({ active: 10, idle: 1_500, utilization: 10 / 1_510 })
|
||||
await vi.advanceTimersByTimeAsync(1_500)
|
||||
expect(meter.sample).toHaveBeenCalled()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
release!()
|
||||
expect(await run).toEqual({ logs: [], value: 'slow-done' })
|
||||
})
|
||||
|
||||
it('expires active time even while a binding is pending', async () => {
|
||||
await pendingBinding()
|
||||
meter.sample.mockReturnValue({ active: 1_001, idle: 1_500, utilization: 1_001 / 2_501 })
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'compute budget exhausted (1000ms busy)' } })
|
||||
})
|
||||
|
||||
it('expires the wall ceiling while active time remains below the compute budget', async () => {
|
||||
await pendingBinding()
|
||||
meter.sample.mockReturnValue({ active: 10, idle: 30_000, utilization: 10 / 30_010 })
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(await run).toEqual({ logs: [], error: { kind: 'timeout', message: 'wall-clock ceiling reached (30000ms)' } })
|
||||
})
|
||||
})
|
||||
@@ -6,23 +6,29 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports the package by name through its exports map,
|
||||
* then exercises type stripping, sibling `worker.cjs` loading, bindings, and logs. Unit tests use
|
||||
* `src/worker.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* then exercises type stripping, sibling `process.js` loading, bindings, and logs. Unit tests use
|
||||
* `src/process.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* and CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file)))
|
||||
const built = ['lib/index.js', 'lib/process.js'].every(file => existsSync(join(pkgDir, file)))
|
||||
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => {
|
||||
it('runs a TypeScript program with a binding through lib/index.js and its lib/process.js entry', async () => {
|
||||
const script = `
|
||||
const { Context } = await import('@deepseek-ai/cordis')
|
||||
const { NodeCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-node')
|
||||
const ctx = new Context()
|
||||
for (const name of ['session-projection', 'fs-local', 'subprocess-local', 'sandbox-local']) {
|
||||
const plugin = await import('@deepseek-ai/dsh-' + name)
|
||||
await ctx.plugin(plugin.default, {})
|
||||
}
|
||||
const { default: SandboxPolicy } = await import('@deepseek-ai/dsh-sandbox-policy')
|
||||
await ctx.plugin(SandboxPolicy, { mode: 'read-only' })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
const result = await ctx.codeRuntime.run({
|
||||
const result = await ctx.codeRuntime.run(ctx.codeRuntime.resolve({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
@@ -32,9 +38,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
},
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
})
|
||||
}))
|
||||
await ctx.fiber.dispose()
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
`
|
||||
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: pkgDir,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
|
||||
function pair() {
|
||||
const left = new PassThrough()
|
||||
const right = new PassThrough()
|
||||
const a = Duplex.from({ readable: left, writable: right })
|
||||
const b = Duplex.from({ readable: right, writable: left })
|
||||
// Duplex.from forwards peer destruction as ABORT_ERR; channel owners observe their own failures.
|
||||
a.on('error', () => {})
|
||||
b.on('error', () => {})
|
||||
onTestFinished(() => { a.destroy(); b.destroy() })
|
||||
return { a, b }
|
||||
}
|
||||
|
||||
function frame(value: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(value))
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(body.length)
|
||||
return Buffer.concat([header, body])
|
||||
}
|
||||
|
||||
describe('bounded process frames', () => {
|
||||
it('reassembles split binary headers and UTF-8 bodies', async () => {
|
||||
const { a, b } = pair()
|
||||
const received = Promise.withResolvers<unknown>()
|
||||
const channel = new JsonChannel(a, 1000, value => received.resolve(value), error => received.reject(error))
|
||||
onTestFinished(() => channel.close())
|
||||
const bytes = frame({ text: '你好🙂' })
|
||||
for (const byte of bytes) b.write(Buffer.from([byte]))
|
||||
expect(await received.promise).toEqual({ text: '你好🙂' })
|
||||
})
|
||||
|
||||
it('accepts consecutive frames and writes complete responses', async () => {
|
||||
const { a, b } = pair()
|
||||
const received: unknown[] = []
|
||||
const done = Promise.withResolvers<undefined>()
|
||||
const channel = new JsonChannel(a, 1000, (value) => {
|
||||
received.push(value)
|
||||
if (received.length === 2) done.resolve(undefined)
|
||||
}, error => done.reject(error))
|
||||
const peer = new JsonChannel(b, 1000, () => {}, error => done.reject(error))
|
||||
onTestFinished(() => { channel.close(); peer.close() })
|
||||
await Promise.all([peer.send({ n: 1 }), peer.send({ n: 2 })])
|
||||
await done.promise
|
||||
expect(received).toEqual([{ n: 1 }, { n: 2 }])
|
||||
await peer.drain()
|
||||
})
|
||||
|
||||
it.each([0, 65])('rejects a declared %i-byte frame before accepting its body', async (length) => {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('must not dispatch') }, error => failure.resolve(error))
|
||||
onTestFinished(() => channel.close())
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(length)
|
||||
b.write(header)
|
||||
expect((await failure.promise).message).toContain('control frame')
|
||||
})
|
||||
|
||||
it('contains malformed JSON, invalid UTF-8 and receiver failures', async () => {
|
||||
for (const payload of [Buffer.from('{'), Buffer.from([0xff]), Buffer.from('{}')]) {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('receiver failed') }, error => failure.resolve(error))
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(payload.length)
|
||||
b.write(Buffer.concat([header, payload]))
|
||||
expect(await failure.promise).toBeInstanceOf(Error)
|
||||
channel.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects oversized writes and closed channels', async () => {
|
||||
const { a } = pair()
|
||||
const channel = new JsonChannel(a, 20, () => {}, () => {})
|
||||
await expect(channel.send({ text: 'x'.repeat(30) })).rejects.toThrow('queued bytes')
|
||||
channel.close()
|
||||
channel.close()
|
||||
await expect(channel.send({})).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it('reports peer EOF while a program is active', async () => {
|
||||
const { a, b } = pair()
|
||||
const failure = Promise.withResolvers<Error>()
|
||||
const channel = new JsonChannel(a, 64, () => {}, error => failure.resolve(error))
|
||||
onTestFinished(() => channel.close())
|
||||
b.end()
|
||||
expect((await failure.promise).message).toContain('ended')
|
||||
})
|
||||
})
|
||||
|
||||
it('retains a partial header and partial payload across distinct stream deliveries', () => {
|
||||
const { a } = pair()
|
||||
const values: unknown[] = []
|
||||
const channel = new JsonChannel(a, 64, (value) => { values.push(value) }, (error) => { throw error })
|
||||
const bytes = frame({ value: 1 })
|
||||
a.emit('data', bytes.subarray(0, 2))
|
||||
a.emit('data', bytes.subarray(2, 6))
|
||||
a.emit('data', bytes.subarray(6))
|
||||
expect(values).toEqual([{ value: 1 }])
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('contains a receiver throwing a non-Error value', () => {
|
||||
const { a } = pair()
|
||||
const failures: string[] = []
|
||||
const channel = new JsonChannel(a, 64, () => { throw 'receiver failure' }, (error) => { failures.push(error.message) })
|
||||
a.emit('data', frame({}))
|
||||
expect(failures).toEqual(['receiver failure'])
|
||||
channel.close()
|
||||
})
|
||||
|
||||
it('ignores callbacks already captured by an emission when an earlier listener closes the channel', () => {
|
||||
for (const event of ['data', 'end']) {
|
||||
const { a } = pair()
|
||||
let failures = 0
|
||||
const channel = new JsonChannel(a, 64, () => { throw new Error('closed channel dispatched') }, () => { failures += 1 })
|
||||
a.prependOnceListener(event, () => channel.close())
|
||||
a.emit(event, frame({}))
|
||||
a.emit('error', new Error('late closed stream error'))
|
||||
expect(failures).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects queued writes if the owner closes before they start', async () => {
|
||||
const { a } = pair()
|
||||
const channel = new JsonChannel(a, 64, () => {}, () => {})
|
||||
const pending = channel.send({ n: 1 })
|
||||
channel.close()
|
||||
await expect(pending).rejects.toThrow('closed')
|
||||
await channel.drain()
|
||||
})
|
||||
|
||||
it('bounds queued frames while a receiver is not reading', async () => {
|
||||
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} })
|
||||
const channel = new JsonChannel(stream, 32, () => {}, () => {})
|
||||
const pending = channel.send({ value: '1234567890' })
|
||||
await expect(channel.send({ value: '1234567890' })).rejects.toThrow('queued bytes')
|
||||
channel.close()
|
||||
await expect(pending).rejects.toThrow('closed')
|
||||
})
|
||||
|
||||
it.each(['error', 'close'])('settles a blocked write when the stream emits %s', async (event) => {
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } })
|
||||
let failure: Error | undefined
|
||||
const channel = new JsonChannel(stream, 64, () => {}, (error) => { failure = error })
|
||||
const pending = channel.send({ value: 1 })
|
||||
await entered.promise
|
||||
stream.emit(event, new Error('transport failed'))
|
||||
await expect(pending).rejects.toThrow(event === 'error' ? 'transport failed' : 'closed')
|
||||
if (event === 'error') expect(failure?.message).toBe('transport failed')
|
||||
channel.close()
|
||||
})
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from '../src/worker-json.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire, snapshotCodeJsonValue } from '../src/json-wire.ts'
|
||||
|
||||
describe('snapshotCodeJsonValue', () => {
|
||||
it('matches the canonical scalar boundary', () => {
|
||||
@@ -176,9 +176,9 @@ describe('flat worker JSON wire', () => {
|
||||
for (const value of values) {
|
||||
const snapshot = snapshotCodeJsonValue(value)
|
||||
expect(snapshot).not.toBeUndefined()
|
||||
expect(decodeWorkerJson(encodeWorkerJson(snapshot!))).toEqual(snapshot)
|
||||
expect(decodeCodeJsonWire(encodeCodeJsonWire(snapshot!))).toEqual(snapshot)
|
||||
}
|
||||
const decoded = decodeWorkerJson(encodeWorkerJson(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
|
||||
const decoded = decodeCodeJsonWire(encodeCodeJsonWire(snapshotCodeJsonValue(withPrototypeKey)!)) as Record<string, unknown>
|
||||
expect(Object.hasOwn(decoded, '__proto__')).toBe(true)
|
||||
expect(decoded.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
@@ -187,10 +187,10 @@ describe('flat worker JSON wire', () => {
|
||||
let value: unknown = 'leaf'
|
||||
for (let depth = 0; depth < 5_000; depth++) value = [value]
|
||||
const snapshot = snapshotCodeJsonValue(value)!
|
||||
const wire = encodeWorkerJson(snapshot)
|
||||
const wire = encodeCodeJsonWire(snapshot)
|
||||
expect(wire).toHaveLength(5_001)
|
||||
|
||||
let cursor = decodeWorkerJson(wire)
|
||||
let cursor = decodeCodeJsonWire(wire)
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
@@ -246,12 +246,12 @@ describe('flat worker JSON wire', () => {
|
||||
[{ kind: 'object', keys: ['x'] }],
|
||||
[{ kind: 'object', keys: [], extra: true }],
|
||||
]) {
|
||||
expect(decodeWorkerJson(value)).toBeUndefined()
|
||||
expect(decodeCodeJsonWire(value)).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid values passed through a forged static type', () => {
|
||||
expect(() => encodeWorkerJson([undefined] as never)).toThrow(/sparse JSON array/)
|
||||
expect(() => encodeWorkerJson({ value: undefined } as never)).toThrow(/undefined JSON object property/)
|
||||
expect(() => encodeCodeJsonWire([undefined] as never)).toThrow(/sparse JSON array/)
|
||||
expect(() => encodeCodeJsonWire({ value: undefined } as never)).toThrow(/undefined JSON object property/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Duplex, PassThrough } from 'node:stream'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { runNodeMain } from '../src/process.ts'
|
||||
import type { ProgramProcess } from '../src/process.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
function endpoints() {
|
||||
const first = new PassThrough()
|
||||
const second = new PassThrough()
|
||||
const child = Duplex.from({ readable: first, writable: second })
|
||||
const host = Duplex.from({ readable: second, writable: first })
|
||||
child.on('error', () => {})
|
||||
host.on('error', () => {})
|
||||
onTestFinished(() => { child.destroy(); host.destroy() })
|
||||
return { child, host }
|
||||
}
|
||||
function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } }
|
||||
|
||||
it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const messages: Record<string, unknown>[] = []
|
||||
const peer = new JsonChannel(host, 4096, (raw) => {
|
||||
const message = raw as Record<string, unknown>
|
||||
messages.push(message)
|
||||
if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } })
|
||||
if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(42) })
|
||||
}, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await runNodeMain(child, 4096, state)
|
||||
expect(state.env).toEqual({})
|
||||
expect(state.exitCode).toBeUndefined()
|
||||
expect(decodeCodeJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42)
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => {
|
||||
const { child } = endpoints()
|
||||
await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit')
|
||||
})
|
||||
|
||||
it('rejects an unexpected first control frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot')
|
||||
})
|
||||
|
||||
it('records an I/O failure before the boot frame', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {})
|
||||
await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error)
|
||||
expect(state.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('contains program writes that exceed queued control output', async () => {
|
||||
const { child, host } = endpoints()
|
||||
const state = processState()
|
||||
const peer = new JsonChannel(host, 1024, (raw) => {
|
||||
if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } })
|
||||
}, () => {})
|
||||
onTestFinished(() => peer.close())
|
||||
await runNodeMain(child, 1024, state)
|
||||
expect(state.exitCode).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
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 { expect, it, onTestFinished } from 'vitest'
|
||||
import { JsonChannel } from '../src/channel.ts'
|
||||
import { decodeCodeJsonWire, encodeCodeJsonWire } from '../src/json-wire.ts'
|
||||
|
||||
it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-'))
|
||||
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
|
||||
for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts']) {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}
|
||||
const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '--eval', source], {
|
||||
env: { PLACEHOLDER_SECRET: 'fixture-only' },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ignore', 'ignore', 'ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const finished = new Promise<void>((resolve) => { child.once('close', () => { resolve() }) })
|
||||
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 message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown }
|
||||
if (message.type === 'ready') {
|
||||
void channel.send({ type: 'boot', data: {
|
||||
code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }',
|
||||
namespaces: [{ global: 'tools', names: ['echo'] }],
|
||||
maxOutputBytes: 10_000,
|
||||
} }).catch(error => completed.reject(error))
|
||||
} else if (message.type === 'call') {
|
||||
expect(decodeCodeJsonWire(message.args)).toEqual({ n: 21 })
|
||||
void channel.send({ type: 'reply', id: message.id, ok: true, value: encodeCodeJsonWire(42) }).catch(error => completed.reject(error))
|
||||
} else if (message.type === 'done') {
|
||||
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
|
||||
else completed.resolve(decodeCodeJsonWire(message.value))
|
||||
}
|
||||
}, error => completed.reject(error))
|
||||
onTestFinished(async () => { channel.close(); child.kill(); await finished })
|
||||
expect(await completed.promise).toEqual({ answer: 42, env: {} })
|
||||
await finished
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionProjections from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
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 } = {}) {
|
||||
if (!ctx.get('fs')) await ctx.plugin(FileSystem)
|
||||
if (!ctx.get('subprocess')) await ctx.plugin(Subprocess)
|
||||
if (!ctx.get('sandbox')) await ctx.plugin(Sandbox, {})
|
||||
if (!ctx.get('sessionProjections')) await ctx.plugin(SessionProjections)
|
||||
if (!ctx.get('sandboxPolicy')) await ctx.plugin(SandboxPolicy, { mode: 'danger-full-access', ...policy })
|
||||
await ctx.plugin(NodeRuntime, config)
|
||||
return ctx.codeRuntime as NodeRuntime
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { expect, it } from 'vitest'
|
||||
import { decodeWorkerJson } from '../src/worker-json.ts'
|
||||
|
||||
/**
|
||||
* Prove the unbuilt worker is a self-contained source closure. Copying it out
|
||||
* of the workspace makes any package runtime import fail even when local
|
||||
* `lib/` artifacts happen to exist.
|
||||
*/
|
||||
it('boots the source worker without workspace package outputs', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-code-source-worker-'))
|
||||
let worker: Worker | undefined
|
||||
try {
|
||||
const files = ['worker.ts', 'bootstrap.ts', 'protocol.ts', 'worker-json.ts', 'output-json.ts']
|
||||
await Promise.all(files.map(async (file) => {
|
||||
await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file))
|
||||
}))
|
||||
|
||||
worker = new Worker(join(directory, 'worker.ts'), {
|
||||
workerData: { code: 'return { answer: 42 }', namespaces: [], maxOutputBytes: 65_536 },
|
||||
env: {},
|
||||
execArgv: [],
|
||||
})
|
||||
const message = await new Promise<unknown>((resolve, reject) => {
|
||||
worker?.once('message', resolve)
|
||||
worker?.once('error', reject)
|
||||
})
|
||||
|
||||
expect(message).toMatchObject({ type: 'done' })
|
||||
const value = typeof message === 'object' && message !== null ? (message as { value?: unknown }).value : undefined
|
||||
expect(decodeWorkerJson(value)).toEqual({ answer: 42 })
|
||||
} finally {
|
||||
if (worker) await worker.terminate()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -25,6 +25,18 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,29 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Separate entry bundles keep the private process bootstrap self-contained. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/worker.js'],
|
||||
outDir: 'lib',
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false },
|
||||
{ entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] },
|
||||
])
|
||||
@@ -27,9 +27,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { CodeRunRequest, CodeRunResult } from './types.ts'
|
||||
import type { CodeRunRequest, CodeRunResult, CodeRunSpec } from './types.ts'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
export type {
|
||||
CodeBindingErrorClass,
|
||||
@@ -15,6 +16,8 @@ export type {
|
||||
CodeRunFailure,
|
||||
CodeRunRequest,
|
||||
CodeRunResult,
|
||||
CodeRunSpec,
|
||||
CodeRunSandbox,
|
||||
} from './types.ts'
|
||||
|
||||
/**
|
||||
@@ -118,20 +121,27 @@ export abstract class CodeRuntime extends Service {
|
||||
*/
|
||||
abstract readonly isolation: string
|
||||
|
||||
/** Deployment file-policy mode, or undefined for a provider without confinement support. */
|
||||
get sandboxMode(): SandboxMode | undefined { return undefined }
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'codeRuntime')
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one program against the request's bindings and capture what it
|
||||
* emitted. See the class doc for the resolution contract (error is a result
|
||||
* field; rejection means Service Definition contract misuse only).
|
||||
* @param request - the program, its bindings, and the abort signal; the
|
||||
* request carries everything the runtime acts on, with no hidden defaults.
|
||||
* @returns the run's outcome: completion value (when transferable), the
|
||||
* ordered log capture, and the failure (if any).
|
||||
* Resolve supported options and provider defaults before execution.
|
||||
* @param request - Program, bindings, cancellation and optional execution choices.
|
||||
* @returns Complete directory, deadline and supported authority for run.
|
||||
* @throws When an explicit choice is invalid or unsupported by this provider.
|
||||
*/
|
||||
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
|
||||
abstract resolve(request: CodeRunRequest): CodeRunSpec
|
||||
|
||||
/**
|
||||
* Execute resolved inputs; program outcomes resolve as result fields.
|
||||
* @param spec - directory, deadline, program, bindings, cancellation and supported policy.
|
||||
* @returns Captured output and the execution outcome.
|
||||
*/
|
||||
abstract run(spec: CodeRunSpec): Promise<CodeRunResult>
|
||||
}
|
||||
|
||||
export default CodeRuntime
|
||||
@@ -6,6 +6,8 @@
|
||||
* @module @deepseek-ai/dsh-code-runtime/src/types
|
||||
*/
|
||||
|
||||
import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/**
|
||||
* One host-side function exposed to the program as an async callable. The
|
||||
* runtime bridges calls to it (possibly across a serialization boundary), so
|
||||
@@ -65,10 +67,8 @@ export interface CodeBindingNamespace {
|
||||
}
|
||||
|
||||
/**
|
||||
* One run: the program source plus everything the runtime acts on. Per the
|
||||
* explicit-over-implicit convention, defaulting (time budgets, output caps)
|
||||
* is the implementation's validated config — a request carries no optional
|
||||
* tuning knobs for a hidden `??` to fill in.
|
||||
* Caller inputs for one program. The provider's resolve method validates supported
|
||||
* options and supplies directory, deadline, and authority before execution.
|
||||
*/
|
||||
export interface CodeRunRequest {
|
||||
/**
|
||||
@@ -80,6 +80,12 @@ export interface CodeRunRequest {
|
||||
program: string
|
||||
/** Host functions exposed to the program, one global object per namespace. */
|
||||
bindings: CodeBindingNamespace[]
|
||||
/** Working directory in the mounted filesystem and subprocess execution world. */
|
||||
cwd?: string
|
||||
/** Requested elapsed execution time; the provider's resolver validates and caps it. */
|
||||
timeoutMs?: number
|
||||
/** Resolved authority for this execution. Providers without confinement reject an explicit policy. */
|
||||
sandboxPolicy?: SandboxExecutionPolicy
|
||||
/**
|
||||
* Abort the run: the runtime stops the program (hard, even mid-loop) and
|
||||
* resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight
|
||||
@@ -88,6 +94,24 @@ export interface CodeRunRequest {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Fully resolved execution inputs; run never supplies a missing directory or timeout. */
|
||||
export interface CodeRunSpec extends CodeRunRequest {
|
||||
/** Absolute directory in the provider's execution world. */
|
||||
cwd: string
|
||||
/** Positive finite execution deadline in milliseconds, after provider capping. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** File confinement applied to a program, independently of its terminal outcome. */
|
||||
export interface CodeRunSandbox {
|
||||
/** File-effect mode used for this execution. */
|
||||
mode: SandboxMode
|
||||
/** Whether an observed failure matches the selected backend's denial diagnostics. */
|
||||
denied: boolean
|
||||
/** Completeness reported by the selected confining backend; absent for full access. */
|
||||
enforcement?: SandboxEnforcement
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run failed. The kinds are orthogonal outcomes reported independently
|
||||
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an
|
||||
@@ -102,7 +126,7 @@ export interface CodeRunRequest {
|
||||
*/
|
||||
export interface CodeRunFailure {
|
||||
/** The failure class (see the interface doc for each kind's meaning). */
|
||||
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'
|
||||
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit' | 'protocol' | 'sandbox-unavailable'
|
||||
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
|
||||
message: string
|
||||
}
|
||||
@@ -113,6 +137,8 @@ export interface CodeRunFailure {
|
||||
* an exception path.
|
||||
*/
|
||||
export interface CodeRunResult {
|
||||
/** Applied file policy and observed denial, when the provider enforces file policy. */
|
||||
sandbox?: CodeRunSandbox
|
||||
/**
|
||||
* The program's completion value (its top-level `return`), when it ran to
|
||||
* completion and the value crossed the runtime's lossless-JSON boundary.
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtim
|
||||
* the smallest subclass that honors it.
|
||||
*/
|
||||
class StubRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'in-process-stub'
|
||||
requests: CodeRunRequest[] = []
|
||||
@@ -43,10 +45,10 @@ describe('CodeRuntime service seam', () => {
|
||||
expect(runtime.isolation).toBe('in-process-stub')
|
||||
|
||||
const calls: unknown[] = []
|
||||
const result = await runtime.run({
|
||||
const result = await runtime.run(runtime.resolve({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: { probe: async (args) => { calls.push(args); return null } } }],
|
||||
})
|
||||
}))
|
||||
expect(result).toEqual({ logs: [] })
|
||||
expect(calls).toEqual([{ from: 'stub' }])
|
||||
expect(runtime.requests).toHaveLength(1)
|
||||
@@ -58,7 +60,7 @@ describe('CodeRuntime service seam', () => {
|
||||
logs: ['boom'],
|
||||
error: { kind: 'exception', message: 'boom' },
|
||||
}
|
||||
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'throw new Error("boom")', bindings: [] }))
|
||||
expect(result.error).toEqual({ kind: 'exception', message: 'boom' })
|
||||
expect(result.value).toBeUndefined()
|
||||
})
|
||||
@@ -67,7 +69,7 @@ describe('CodeRuntime service seam', () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort('cancelled')
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [], signal: controller.signal }))
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'cancelled' })
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -699,6 +699,8 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
describe('PTC mode native-tool denial through the agent loop', () => {
|
||||
/** A minimal in-process code runtime for test purposes — never actually runs. */
|
||||
class FakeCodeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake' as const
|
||||
async run(_request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
|
||||
@@ -19,6 +19,8 @@ import { apply, Config, inject, name } from '@deepseek-ai/dsh-agent-tool-present
|
||||
|
||||
/** A runtime that never runs anything: presentation never dispatches. */
|
||||
class StubRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'stub'
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^"
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
@@ -66,6 +68,8 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^"
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
@@ -915,6 +916,11 @@ export class ToolRuntime extends Service {
|
||||
private requireCodeTransport(): ToolDefinition {
|
||||
this.ptcTransport ??= createRunCodeTool(this, {
|
||||
requireRuntime: () => this.requireCodeRuntime(this.defaultMode),
|
||||
resolveSandboxPolicy: (exec) => {
|
||||
const policy = this.ctx.get('sandboxPolicy')
|
||||
if (policy === undefined) throw new Error('dsh-tools: confined code runtime requires sandboxPolicy')
|
||||
return policy.resolve({ session: exec.agent?.session })
|
||||
},
|
||||
// The language-aware description/parameters getters read the runtime
|
||||
// without demanding one, so a native-default process can still project
|
||||
// the transport for an agent that chose code.
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingFunction, CodeRunResult, CodeRunSandbox, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
|
||||
import { TOOL_RUNTIME_SCHEDULER } from './index.ts'
|
||||
@@ -256,7 +257,7 @@ function renderValue(value: JsonValue): string {
|
||||
}
|
||||
|
||||
/** Canonical value returned by the outer PTC mode transport. */
|
||||
type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
type RunCodeOutput = { logs: string[]; result?: JsonValue; sandbox?: CodeRunSandbox }
|
||||
|
||||
/**
|
||||
* Registry-private capabilities the bridge receives at construction — the
|
||||
@@ -264,6 +265,8 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
* off its public service API and flow here as closures instead.
|
||||
*/
|
||||
export interface RunCodeBridgeOptions {
|
||||
/** Resolves standing Session authority only for a runtime that enforces file policy. */
|
||||
resolveSandboxPolicy: (exec: ToolRunContext) => SandboxExecutionPolicy
|
||||
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
|
||||
requireRuntime: () => CodeRuntime
|
||||
/**
|
||||
@@ -316,11 +319,22 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
properties: {
|
||||
logs: { type: 'array', required: true, items: { type: 'string' } },
|
||||
result: { type: 'json' },
|
||||
sandbox: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: { type: 'string', required: true, enum: ['read-only', 'workspace-write', 'danger-full-access'] },
|
||||
denied: { type: 'boolean', required: true },
|
||||
enforcement: { type: 'string', enum: ['full', 'partial'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => {
|
||||
const rendered = value.result === undefined ? '' : renderValue(value.result)
|
||||
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||||
if (value.sandbox?.enforcement === 'partial') parts.push('File sandbox enforcement is partial on this host.')
|
||||
if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.`)
|
||||
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
|
||||
},
|
||||
},
|
||||
@@ -616,7 +630,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
try {
|
||||
let result: CodeRunResult
|
||||
try {
|
||||
result = await runtime.run({
|
||||
result = await runtime.run(runtime.resolve({
|
||||
program: args.code,
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
@@ -624,7 +638,9 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
signal: runController.signal,
|
||||
})
|
||||
...exec.agent?.session.header.cwd !== undefined ? { cwd: exec.agent.session.header.cwd } : {},
|
||||
...runtime.sandboxMode !== undefined ? { sandboxPolicy: options.resolveSandboxPolicy(exec) } : {},
|
||||
}))
|
||||
} finally {
|
||||
// Abort sub-dispatches and drain every in-flight dispatch before
|
||||
// closing the turn (queued-unstarted ones are abandoned unlogged).
|
||||
@@ -635,10 +651,13 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO
|
||||
|
||||
if (result.error) {
|
||||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
|
||||
const sandboxText = result.sandbox === undefined ? ''
|
||||
: `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.`
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}`)
|
||||
}
|
||||
return {
|
||||
logs: result.logs,
|
||||
...result.sandbox === undefined ? {} : { sandbox: result.sandbox },
|
||||
...result.value !== undefined ? { result: result.value } : {},
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -25,6 +25,8 @@ const testToolSignal = new AbortController().signal
|
||||
|
||||
/** A scriptable in-repo CodeRuntime: each test sets `behavior` to drive the bindings however it needs. */
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language: string
|
||||
readonly isolation = 'fake'
|
||||
behavior: (request: CodeRunRequest) => Promise<CodeRunResult> = () => Promise.resolve({ logs: [] })
|
||||
@@ -62,7 +64,7 @@ async function setup(options: SetupOptions = {}) {
|
||||
|
||||
/** Mint an agent scope configured like production that can register scoped tool policy. */
|
||||
async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: SessionId(name) } as Agent
|
||||
const agent = { id: SessionId(name), session: { header: {}, append: () => {} } } as unknown as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
|
||||
{ inject: ['tools', 'systemPrompt'] }))
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import type { Duplex } from 'node:stream'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingErrorClass, CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeBindingErrorClass, CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult, CodeRunSpec } from '@deepseek-ai/dsh-code-runtime'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
|
||||
@@ -1030,13 +1030,26 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one program in a fresh Python subprocess. Success resolves with
|
||||
* `result.value` (and no `result.error`); failure — parse failure, thrown
|
||||
* exception, invalid completion, output overflow, budget expiry, abort, or
|
||||
* substrate death — resolves with `result.error` set (classified by
|
||||
* `CodeRunFailure.kind`). The method rejects only for seam misuse.
|
||||
* Resolve directory and the experimental provider's configured wall deadline.
|
||||
* @param request - Program inputs; explicit sandbox or timeout overrides are unsupported.
|
||||
* @returns Complete inputs for the provider's run method.
|
||||
* @throws When a requested override is unsupported or cwd is relative.
|
||||
*/
|
||||
async run(request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
resolve(request: CodeRunRequest): CodeRunSpec {
|
||||
if (request.sandboxPolicy !== undefined) throw new Error('dsh-code-runtime-python: sandbox policy is unsupported')
|
||||
if (request.timeoutMs !== undefined) throw new Error('dsh-code-runtime-python: per-call timeout is unsupported')
|
||||
const cwd = request.cwd ?? process.cwd()
|
||||
if (!isAbsolute(cwd)) throw new Error('dsh-code-runtime-python: cwd must be absolute')
|
||||
return { ...request, cwd, timeoutMs: this.config.maxWallMs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a resolved Python program; this experimental provider has no file confinement.
|
||||
* @param request - Resolved cwd, provider deadline, program and bindings.
|
||||
* @returns Captured output and the program outcome.
|
||||
*/
|
||||
async run(request: CodeRunSpec): Promise<CodeRunResult> {
|
||||
if (request.sandboxPolicy !== undefined || request.timeoutMs !== this.config.maxWallMs) throw new Error('dsh-code-runtime-python: unsupported execution policy or timeout')
|
||||
if (this.disposed) throw new Error('dsh-code-runtime-python: run() after disposal')
|
||||
const bindings = this.validateBindings(request)
|
||||
if (request.signal?.aborted) {
|
||||
@@ -1155,7 +1168,7 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
|
||||
/** Spawn the child for one validated run and drive it to settlement. */
|
||||
private execute(
|
||||
request: CodeRunRequest,
|
||||
request: CodeRunSpec,
|
||||
bindings: Map<string, ValidatedNamespace>,
|
||||
bootstrapPath: string,
|
||||
): Promise<CodeRunResult> {
|
||||
@@ -1182,6 +1195,7 @@ export class PythonCodeRuntime extends CodeRuntime {
|
||||
// run. The `_LogStream` replacement of `sys.stdout`/`sys.stderr` is
|
||||
// unaffected (it is a Python object, not the C-level stdio buffer).
|
||||
child = spawn(this.pythonBin, ['-u', '-I', bootstrapPath], {
|
||||
cwd: request.cwd,
|
||||
// Preserve only the platform temp directory. macOS system Python emits a
|
||||
// startup warning when TMPDIR is absent; ambient credentials, PATH, HOME,
|
||||
// and other host state remain unavailable to model code.
|
||||
|
||||
@@ -146,10 +146,10 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
const run = runtime.run({
|
||||
const run = runtime.run(runtime.resolve({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: { echo: async (value: unknown) => value as number } }],
|
||||
})
|
||||
}))
|
||||
onTestFinished(async () => {
|
||||
await fiber.dispose()
|
||||
await run
|
||||
@@ -210,7 +210,7 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('failed to boot python subprocess')
|
||||
@@ -240,7 +240,7 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('python spawn error')
|
||||
@@ -258,7 +258,7 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('failed to boot python subprocess')
|
||||
@@ -275,7 +275,7 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime)
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [] })
|
||||
const result = await runtime.run(runtime.resolve({ program: 'return 1', bindings: [] }))
|
||||
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
expect(result.error?.message).toContain('python spawn error')
|
||||
@@ -302,10 +302,10 @@ describe('PythonCodeRuntime — controlled subprocess pipes', () => {
|
||||
const fiber = await ctx.plugin(PythonCodeRuntime, { maxWallMs: 3000 })
|
||||
const runtime = ctx.codeRuntime as InstanceType<typeof PythonCodeRuntime>
|
||||
|
||||
const result = await runtime.run({
|
||||
const result = await runtime.run(runtime.resolve({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: { f: async () => 'x'.repeat(4 * 1024 * 1024) } }],
|
||||
})
|
||||
}))
|
||||
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
// The drain wait settled on `close` and cleaned up after itself. The
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -39,10 +39,10 @@ it('seals stray fragments without recopying the sealed prefix', async () => {
|
||||
return realConcat(list, total)
|
||||
})
|
||||
try {
|
||||
const result = await ctx.codeRuntime.run({
|
||||
const result = await ctx.codeRuntime.run(ctx.codeRuntime.resolve({
|
||||
program: 'import os\nos.write(1, b"x" * 60000 + b"\\n")\nreturn "done"',
|
||||
bindings: [],
|
||||
})
|
||||
}))
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs).toEqual(['x'.repeat(60_000)])
|
||||
@@ -70,10 +70,10 @@ it.each([
|
||||
return merged
|
||||
})
|
||||
try {
|
||||
const result = await ctx.codeRuntime.run({
|
||||
const result = await ctx.codeRuntime.run(ctx.codeRuntime.resolve({
|
||||
program: `import os\nos.write(1, ${payload})\nreturn None`,
|
||||
bindings: [],
|
||||
})
|
||||
}))
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs.at(-1)).toBe(logTruncationMarker(3072))
|
||||
// Each raw byte decodes to U+FFFD (three UTF-8 bytes), so a 3072-byte
|
||||
|
||||
@@ -71,6 +71,8 @@ class CatalogAdapter extends LlmAdapter {
|
||||
|
||||
/** In-process PTC mode seam fake that invokes the real registry bindings. */
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
behavior: (request: CodeRunRequest) => Promise<CodeRunResult> = () => Promise.resolve({ logs: [] })
|
||||
|
||||
@@ -1049,6 +1049,8 @@ function withPersona(...sections: string[]): string {
|
||||
|
||||
/** Schema assembly only: these cases never execute user code. */
|
||||
class GuidanceCodeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run() { return Promise.resolve({ logs: [] }) }
|
||||
|
||||
@@ -509,6 +509,8 @@ describe('the soft layer', () => {
|
||||
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
|
||||
// assembly time (the ptc.spec fake's shape).
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
@@ -531,6 +533,8 @@ describe('the soft layer', () => {
|
||||
|
||||
it('keeps native wire schemas and the SDK in step under mode both', async () => {
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
@@ -553,6 +557,8 @@ describe('the soft layer', () => {
|
||||
|
||||
it('keeps the PTC mode SDK byte-identical across mode switches', async () => {
|
||||
class FakeRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
run(_request: CodeRunRequest): Promise<CodeRunResult> { return Promise.resolve({ logs: [] }) }
|
||||
@@ -926,6 +932,8 @@ describe('exit_plan_mode', () => {
|
||||
it('carries the exact plan through a PTC mode review and logs the nested dispatch', async () => {
|
||||
const plan = '# PTC mode plan\n\nUse the existing seam.'
|
||||
class ExitRuntime extends CodeRuntime {
|
||||
resolve(request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest): import('@deepseek-ai/dsh-code-runtime').CodeRunSpec { return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } }
|
||||
|
||||
readonly language = 'typescript'
|
||||
readonly isolation = 'fake'
|
||||
async run(request: CodeRunRequest): Promise<CodeRunResult> {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Classify observed sandbox runner failures and file-effect denials. */
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { RunnerFailureRule } from './index.ts'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
@@ -176,3 +176,5 @@ export abstract class SandboxProvider extends Service {
|
||||
}
|
||||
|
||||
export default SandboxProvider
|
||||
|
||||
export { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './diagnostics.ts'
|
||||
@@ -1,62 +1,7 @@
|
||||
/**
|
||||
* Internal sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
/** Shell-result projection over shared sandbox diagnostics. */
|
||||
import type { ShellRunResult } from '@deepseek-ai/dsh-shell'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures whose error path equals argv[0]
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
import { matchesSignature } from '@deepseek-ai/dsh-sandbox'
|
||||
export { isRunnerSpawnFailure, classifyRunnerFailure, matchesSignature } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
@@ -67,50 +12,3 @@ interface RunnerFailureMatch {
|
||||
export function classifyDenial(result: ShellRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import type { PostToolDecision, ToolExecution, ToolExecutionToken } from '@deeps
|
||||
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
import { NodeCodeRuntime } from '@deepseek-ai/dsh-code-runtime-node'
|
||||
import { mountRuntime } from '../../../code-runtime/code-runtime-node/tests/setup.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('outer PTC mode failure capture', () => {
|
||||
await ctx.plugin(ToolRuntime, { mode: 'ptc' })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: 200 })
|
||||
await ctx.plugin(NodeCodeRuntime, { maxOutputBytes: 500 })
|
||||
await mountRuntime(ctx, { maxOutputBytes: 500 })
|
||||
const events: unknown[] = []
|
||||
const agent = {
|
||||
session: {
|
||||
@@ -242,7 +242,7 @@ describe('the durable dispatch-log arm', () => {
|
||||
await ctx.plugin(ToolRuntime, { mode: 'ptc' })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(ctx, {})
|
||||
const events: { type: string; data: unknown }[] = []
|
||||
const agent = {
|
||||
session: {
|
||||
@@ -316,7 +316,7 @@ describe('the durable dispatch-log arm', () => {
|
||||
await ctx.plugin(ToolRuntime, { mode: 'ptc' })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(ctx, {})
|
||||
// A spill backend that hangs until released.
|
||||
let releaseSave!: () => void
|
||||
const gate = new Promise<void>((resolve) => { releaseSave = resolve })
|
||||
@@ -380,7 +380,7 @@ describe('the durable dispatch-log arm', () => {
|
||||
await ctx.plugin(ToolRuntime, { mode: 'ptc', maxParallelSubCalls: 1 })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(ctx, {})
|
||||
const store = ctx.spillStore as StubStore
|
||||
const releases: (() => void)[] = []
|
||||
store.gate = () => new Promise<void>((resolve) => { releases.push(resolve) })
|
||||
@@ -433,7 +433,7 @@ describe('the durable dispatch-log arm', () => {
|
||||
await ctx.plugin(ToolRuntime, { mode: 'ptc' })
|
||||
await ctx.plugin(StubStore)
|
||||
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
||||
await ctx.plugin(NodeCodeRuntime, {})
|
||||
await mountRuntime(ctx, {})
|
||||
;(ctx.spillStore as StubStore).fail = true
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const events: { type: string; data: unknown }[] = []
|
||||
|
||||
@@ -63,6 +63,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
|
||||
ctx.provide('codeRuntime', {
|
||||
language: 'typescript',
|
||||
isolation: 'test',
|
||||
resolve: (request: import('@deepseek-ai/dsh-code-runtime').CodeRunRequest) => ({ ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: 120_000 }),
|
||||
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
|
||||
} as never)
|
||||
}
|
||||
|
||||
Generated
+33
@@ -4584,6 +4584,9 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
|
||||
packages/code-runtime/code-runtime-node:
|
||||
dependencies:
|
||||
@@ -4600,9 +4603,33 @@ importers:
|
||||
'@deepseek-ai/dsh-code-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../code-runtime
|
||||
'@deepseek-ai/dsh-fs':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs
|
||||
'@deepseek-ai/dsh-fs-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs-local
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-local
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-policy
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess-local
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
@@ -5225,6 +5252,12 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-policy
|
||||
'@deepseek-ai/dsh-scope':
|
||||
specifier: workspace:^
|
||||
version: link:../scope
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
const selectorName = 'DSH_SUBPROCESS_RUNNER'
|
||||
const selection = process.env[selectorName]
|
||||
|
||||
if (selection === undefined) {
|
||||
if (process.env.DSH_CODE_RUNTIME_NODE === '1') {
|
||||
Reflect.deleteProperty(process.env, 'DSH_CODE_RUNTIME_NODE')
|
||||
await import('@deepseek-ai/dsh-code-runtime-node/process')
|
||||
} else if (selection === undefined) {
|
||||
const { runCli } = await import('@deepseek-ai/dsh/lib/bin.js')
|
||||
await runCli()
|
||||
} else {
|
||||
|
||||
@@ -210,6 +210,8 @@ export default defineConfig({
|
||||
'packages/*/*/src/types.ts',
|
||||
'packages/*/*/src/bin.ts',
|
||||
'packages/*/*/src/worker.ts',
|
||||
// The built Node entry invokes the independently covered process bootstrap through fd 7.
|
||||
'packages/code-runtime/code-runtime-node/src/process-entry.ts',
|
||||
// Dynamic Host/Client composition is covered by its focused lifecycle
|
||||
// tests and assembled application checks rather than per-file coverage.
|
||||
'packages/self-modification/*/src/**/*.{ts,tsx}',
|
||||
|
||||
Reference in new issue
Block a user