Files
deepseek-harness/packages/ptc-runtime/ptc-runtime-node/src/index.ts
T
Tianyi Cui b9863a9e28 fix(ptc): cancel pending runs when Session policy changes
Register calls before awaiting prior process cleanup. Carry policy cancellation into both Node execution modes and retain each admission until its call settles. Track persistent calls during provider disposal as well.

Cover unchanged-mode temporary escalation and policy changes that return to the original mode while cleanup is pending. Document the admission guarantee.

Validation: 308 focused tests passed, one skipped; both changed source files have 100% coverage. Two built-runtime smokes, two selected Session snapshots, all 43 doc-sync gates, and typed lint passed.
2026-10-09 16:43:56 +08:00

346 lines
19 KiB
TypeScript

/** Node PTC execution with configurable per-call processes or retained in-memory REPL state. */
import { AsyncLocalStorage } from 'node:async_hooks'
import { stripTypeScriptTypes } from 'node:module'
import { isAbsolute } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
import { clampTimeout } from '@deepseek-ai/dsh-timeout'
import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-fs'
import { replyToBinding, validateBindings } from './bindings.ts'
import { JsonChannel } from './channel.ts'
import { bootstrapArgs, resolveLaunch } from './launch.ts'
import { NodeConfig, validateConfig } from './config.ts'
import { PolicyProcesses } from './policy-processes.ts'
import { InMemoryExecution } from './persistent.ts'
import type { Config, ResolvedConfig } from './config.ts'
export type { Config } from './config.ts'
export type { LaunchConfig } from './launch.ts'
import { OutputLedger } from './output-ledger.ts'
import { terminateProcess } from './output-stream.ts'
import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts'
import { decodePtcJsonWire } from './json-wire.ts'
import type { ProgramBootData } from './protocol.ts'
interface LiveRun { controller: AbortController; finished: Promise<void> }
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
const STRIP_SUFFIX = '\n}'
function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
function record(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value) }
/** Node execution under file policy, with optional owner-scoped retained memory. */
export class NodePtcRuntime extends PtcRuntime {
static inject = ['fs', 'subprocess', 'sandbox', 'sandboxPolicy']
static Config = NodeConfig
readonly language = 'typescript'
readonly isolation = 'process'
private readonly inMemory?: InMemoryExecution
private readonly processes: PolicyProcesses
override get programFormat(): 'function-body' | 'repl' { return this.inMemory ? 'repl' : 'function-body' }
override get executionInstructions(): string {
if (this.inMemory) return this.inMemory.executionInstructions
return 'The program is the body of an async TypeScript function; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level await and return are supported. Emit output with console.log(...) or return a lossless JSON value. Only printed values and the returned value become program output. Each call runs in a fresh Node process. Node APIs are available through await import(...). Relative paths use the supplied working directory; process.env starts empty. Direct filesystem access follows this execution\'s sandbox policy.'
}
private readonly config: ResolvedConfig
private readonly live = new Set<LiveRun>()
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = { ...config, launch: resolveLaunch(config.launch) } as ResolvedConfig
validateConfig(this.config)
this.processes = new PolicyProcesses(ctx)
if (this.config.executionMode === 'persistent-in-memory') {
this.inMemory = new InMemoryExecution(ctx, this.config, this.processes)
}
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 ptc-runtime cleanup')
}
override get sandboxMode(): SandboxMode | undefined { return this.ctx.sandboxPolicy.defaultMode }
override release(owner: object): Promise<void> {
return this.inMemory ? this.inMemory.release(owner) : super.release(owner)
}
override get timeout(): { defaultMs: number; maxMs: number } {
return { defaultMs: Math.min(this.config.timeoutMs, this.config.maxTimeoutMs), maxMs: this.config.maxTimeoutMs }
}
/**
* Resolve directory, deadline and file policy for the selected execution mode.
* @param request - Program, bindings, optional cwd/deadline, and resolved authority.
* @returns Complete execution inputs with a capped numeric budget or an explicit null deadline.
*/
resolve(request: PtcRunRequest): PtcRunSpec {
if (this.inMemory) return this.inMemory.resolve(request)
if (this.disposed) throw new Error('ptc-runtime-node: resolve after disposal')
const sandboxPolicy = request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const cwd = request.cwd ?? sandboxPolicy.workspaceRoot
if (!isAbsolute(cwd)) throw new Error('ptc-runtime-node: cwd must be absolute')
return {
...request,
cwd,
timeoutMs: request.timeoutMs === null ? null : clampTimeout(request.timeoutMs, this.config.timeoutMs, this.config.maxTimeoutMs, 'ptc-runtime-node: timeoutMs'),
sandboxPolicy,
}
}
/**
* Run a resolved program under the configured execution mode.
* Session mode changes cancel admitted calls before process launch as well as active programs.
* @param spec - Inputs returned by resolve for the configured mode.
* @returns Logs, JSON completion or failure; retained processes remain until release or invalidation.
*/
async run(spec: PtcRunSpec): Promise<PtcRunResult> {
if (this.disposed) throw new Error('ptc-runtime-node: run after disposal')
const controller = new AbortController()
const completion = Promise.withResolvers<void>()
const unregister = spec.sandboxPolicy === undefined ? undefined : this.processes.register(spec.sandboxPolicy, async () => {
controller.abort(new Error('sandbox mode changed'))
await completion.promise
})
const live = { controller, finished: completion.promise }
this.live.add(live)
try {
await this.processes.wait(spec.sandboxPolicy?.sessionId)
if (this.inMemory) {
const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal])
return await this.inMemory.run({ ...spec, signal })
}
if (spec.sandboxPolicy === undefined) throw new Error('ptc-runtime-node: run requires a resolved sandbox policy')
if (!isAbsolute(spec.cwd) || (spec.timeoutMs !== null && (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0 || spec.timeoutMs > this.config.maxTimeoutMs))) throw new Error('ptc-runtime-node: run requires resolved cwd and timeout')
const bindings = validateBindings(spec)
return await this.execute(spec, spec.sandboxPolicy, bindings, controller)
} finally {
unregister?.()
this.live.delete(live)
completion.resolve()
}
}
private async execute(
spec: PtcRunSpec,
policy: SandboxExecutionPolicy,
bindings: Map<string, PtcBindingNamespace>,
controller: AbortController,
): Promise<PtcRunResult> {
const replyBinding = AsyncLocalStorage.bind(replyToBinding)
const output = new OutputLedger(this.config.maxOutputBytes)
const logs: string[] = []
const sandbox: PtcRunSandbox = { mode: policy.mode, denied: false }
const result = Promise.withResolvers<PtcRunResult>()
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 overflowResult: PtcRunResult | undefined
let stderr = ''
let parsing = true
const wallTimer = spec.timeoutMs === null ? undefined
: setTimeout(() => { timedOut = true; controller.abort('execution deadline reached') }, spec.timeoutMs)
let stopping: Promise<boolean> | undefined
const stop = (): Promise<boolean> => {
if (stopping !== undefined) return stopping
const cleanup = handle === undefined ? Promise.resolve(true) : terminateProcess(handle, this.config.graceMs)
stopping = cleanup.then((drained) => { unregister(); return drained }, (error: unknown) => {
stopping = undefined
throw error
})
return stopping
}
const finish = (failure?: PtcRunFailure, value?: PtcJsonValue): void => {
if (settled) return
settled = true
clearTimeout(wallTimer)
signal.removeEventListener('abort', onAbort)
channel?.close()
void (async () => {
try {
const drained = await stop()
if (!drained && failure === undefined) {
failure = { kind: 'worker-exit', message: 'Node process output did not close cleanly' }
}
} catch (error: unknown) {
failure = { kind: 'worker-exit', message: `managed process cleanup failed: ${messageOf(error)}` }
}
const outcome = outputOverflow ? overflowResult ?? 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) })
}
const unregister = this.processes.register(policy, async () => {
controller.abort(new Error('sandbox mode changed'))
await stop()
})
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
try {
// Abort callbacks can settle execution before or during an awaited operation.
// oxlint-disable-next-line typescript/no-unnecessary-condition
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,
}
const executable = await this.ctx.subprocess.resolveExecutable(this.config.launch.executable, undefined, signal)
// Abort callbacks can settle execution before or during an awaited operation.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (settled) return await result.promise
const packaged = this.config.launch.kind === 'embedded'
const heapFlag = `--max-old-space-size=${this.config.maxOldGenerationSizeMb}`
const argv = [executable, ...packaged ? [] : [heapFlag],
...bootstrapArgs(this.ctx.fs, this.config.launch, this.config.maxMessageBytes)]
confined = policy.mode === 'danger-full-access' ? undefined : await this.ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Cancellation can settle during awaited confinement.
if (settled) return await result.promise
if (confined !== undefined) sandbox.enforcement = confined.enforcement
// Electron needs its Node-mode selector until bootstrap; the child then removes it with other ambient values.
const env: NodeJS.ProcessEnv = Object.fromEntries(Object.keys(process.env)
.filter(key => !STARTUP_ENVIRONMENT_NAMES.has(key.toUpperCase()) && key.toUpperCase() !== 'ELECTRON_RUN_AS_NODE')
.map(key => [key, undefined]))
if (packaged) {
env.DSH_PTC_RUNTIME_NODE = '1'
env.NODE_OPTIONS = heapFlag
}
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 || launched.stdout === undefined || launched.stderr === undefined) {
throw new Error('subprocess provider did not supply the requested control and output pipes')
}
const admit = (text: string): void => {
if (outputOverflow) return
if (!output.admit(text, logs)) {
outputOverflow = true
overflowResult = output.limit([...logs, text])
finish({ kind: 'output-limit', message: `outer output exceeded ${this.config.maxOutputBytes} bytes` })
}
}
const stdoutDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
const stderrDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
handle.stdout?.on('data', (chunk: Buffer) => {
const text = stdoutDecoder.decode(chunk, { stream: true })
if (text.length > 0) admit(text)
})
handle.stdout?.on('end', () => {
const text = stdoutDecoder.decode()
if (text.length > 0) admit(text)
})
handle.stdout?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
handle.stderr?.on('data', (chunk: Buffer) => {
const text = stderrDecoder.decode(chunk, { stream: true })
stderr = (stderr + text).slice(-this.config.maxOutputBytes)
if (text.length > 0) admit(text)
})
handle.stderr?.on('end', () => {
const text = stderrDecoder.decode()
if (text.length > 0) admit(text)
})
handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) })
let ready = false
let nextId = 1
let pending = 0
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 (!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: unknown) => { protocolFailure(messageOf(error)) })
return
}
switch (raw.type) {
case 'log':
if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return }
admit(raw.text)
return
case 'output-limit': outputOverflow = true; finish(); return
case 'done': {
if (raw.error !== undefined) {
if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return }
const failure = { kind: raw.error.kind, message: raw.error.message } as PtcRunFailure
if (confined !== undefined) {
sandbox.denied = confined.denialSignatures.some(signature =>
failure.message.toLowerCase().includes(signature.toLowerCase()))
}
if (failure.kind === 'output-limit') outputOverflow = true
finish(failure)
return
}
const value = raw.value === undefined ? undefined : decodePtcJsonWire(raw.value)
if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
else finish(undefined, value)
return
}
case 'call': {
if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return }
nextId += 1
const functions = bindings.get(raw.global)?.functions
const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined
if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return }
const args = decodePtcJsonWire(raw.args)
if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return }
if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return }
const id = raw.id
void (async () => {
const reply = await replyBinding(id, fn, args).finally(() => {
pending -= 1
pendingBytes -= bytes
})
if (!settled) await transport.send(reply)
})().catch((error: unknown) => { protocolFailure(messageOf(error)) })
return
}
default: protocolFailure('unknown control message')
}
}, (error, kind) => {
if (kind === 'protocol') protocolFailure(messageOf(error))
else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) })
else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) })
})
channel = transport
void launched.done.then((outcome) => {
// Allow queued control-frame callbacks to run before classifying a command exit.
setImmediate(() => { processFinished(outcome) })
}, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
} catch (error: unknown) {
finish({ kind: error instanceof SandboxUnavailableError ? 'sandbox-unavailable' : parsing ? 'exception' : 'worker-exit', message: messageOf(error) })
}
return await result.promise
}
}
export default NodePtcRuntime