Files
deepseek-harness/packages/workflow/workflow-vm/src/index.ts
Tianyi Cui 9688870da3 docs(workflow): admit the node:vm escape concretely — absent globals are surface, not containment
The review's critical finding: the engine README and the tool
description read as if the missing filesystem/network/Node globals were
enforced, but a script can reach the host Function constructor via
globalThis.constructor.constructor and from it process and every Node
builtin. Per the trust premise this is ACCEPTED (model-written scripts,
bash-equivalent trust; genuine sandboxing is the deferred engine swap
already listed) — but the docs must say so instead of implying a wall.
The trust-premise sections (engine README, module doc, RFC) now name
the escape and its acceptance; the model-facing tool description says
the APIs are not PROVIDED rather than implying they are prevented.
2026-07-06 21:04:20 +08:00

201 lines
8.7 KiB
TypeScript

/**
* The `node:vm` workflow engine: the first {@link WorkflowService}
* implementation. Parses the Claude Code-format script (meta + body), runs the
* body in a fresh in-process vm context with the workflow hooks injected, and
* fans `agent()` calls out to `ctx.subagents`.
*
* TRUST PREMISE: scripts are MODEL-WRITTEN — the same trust level as the
* model's existing bash access — so this engine defends against BUGGY
* scripts, never hostile ones. vm is NOT a security boundary and no attempt
* is made to contain adversarial values (see ./realm.ts); the context is
* escapable by construction (the host `Function` constructor is reachable via
* `globalThis.constructor.constructor`, and `process` from there), so the
* absent globals are API surface, not containment. Genuine sandboxing is an
* engine swap behind the seam (worker-thread/isolated-vm), not incremental
* host-side defenses here.
*
* Engine limitations, documented as the accepted cost of the in-process
* mechanism:
*
* - `start()` runs the script's initial SYNCHRONOUS slice inline, so the
* CALLER blocks on the host event loop until the script's first await (or
* the vm `timeout` kills the slice); the meta-literal evaluation has its
* own timeout budget on the same call.
* - The vm `timeout` covers only that initial slice; realm code running past
* it — an await continuation, a thenable's `then` invoked by promise
* resolution (including one the script RETURNS: a returned thenable
* resolves per JavaScript semantics before materialization, which is what
* makes an un-awaited `return agent('x')` work) — is beyond the timeout, so
* a synchronous spin there cannot be killed in-process, and neither can
* script code the host invokes while rendering a failure (a getter on a
* thrown value). `dispose()` waits a bounded grace for the script to settle
* AND its children (stray `agent()` calls included) to finish disposing,
* then ABANDONS whatever is left: pending hook promises are already
* rejected and the script's settlement is contained (no unhandled
* rejection), but an abandoned synchronous spin would still occupy the
* event loop.
*
* Plugin export shape: a default-exported {@link WorkflowService} subclass
* (the class-based service form, like `dsh-bash-local`).
*
* @module @deepseek-ai/dsh-workflow-vm
*/
import { randomUUID } from 'node:crypto'
import { availableParallelism } from 'node:os'
import type { Context } from 'cordis'
import z from 'schemastery'
import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { extractMeta } from './meta.ts'
import { WorkflowExecution, type ExecutionLimits } from './runtime.ts'
export { extractMeta, type ExtractedScript } from './meta.ts'
export { materializeFromRealm, MaterializeError } from './realm.ts'
export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/** The `ctx.subagents` provider children run on (default `spawn`). */
provider?: string
/** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
maxConcurrentAgents?: number
/** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
maxTotalAgents?: number
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
maxItemsPerCall?: number
/** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */
syncTimeoutMs?: number
/**
* How long after a cancellation an unsettled script may keep running before
* it is abandoned and `result` force-settles `cancelled` (default 5000 ms);
* also bounds `dispose()`.
*/
disposeGraceMs?: number
}
type ResolvedConfig = Required<Config>
/**
* The vm engine service. `start()` validates the script up front (meta +
* body compile) and returns a {@link WorkflowRun} whose `result` never
* rejects; the `workflow/*` events fire around the run per the seam contract.
*/
export class VmWorkflowEngine extends WorkflowService {
static inject = ['subagents']
static Config: z<Config> = z.object({
provider: z.string().default('spawn'),
maxConcurrentAgents: z.natural().default(0),
maxTotalAgents: z.natural().min(1).default(1000),
maxItemsPerCall: z.natural().min(1).default(4096),
syncTimeoutMs: z.natural().min(1).default(5000),
disposeGraceMs: z.natural().default(5000),
})
private readonly config: ResolvedConfig
constructor(ctx: Context, config: Config) {
super(ctx)
// schemastery (static Config) has already filled the defaulted fields;
// the assertion records that resolution, not a hidden fallback.
this.config = config as ResolvedConfig
}
/**
* Parse and execute a workflow script. Throws {@link WorkflowError}
* synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot
* begin; once a run is returned, every failure resolves through
* `result.stopReason` instead.
* @param request - the script, its `args`, the parent agent, and an
* optional cancel signal.
* @returns the live run (its `result` resolves when the script settles).
*/
start(request: WorkflowStartRequest): WorkflowRun {
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs)
const id = WorkflowRunId(randomUUID())
// The event payloads and the run handle get SEPARATE meta clones: a
// listener mutating its snapshot must not corrupt the holder's view.
const info: WorkflowRunInfo = { id, meta: structuredClone(meta) }
const limits: ExecutionLimits = {
provider: this.config.provider,
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
? Math.min(16, Math.max(1, availableParallelism() - 2))
: this.config.maxConcurrentAgents,
maxTotalAgents: this.config.maxTotalAgents,
maxItemsPerCall: this.config.maxItemsPerCall,
syncTimeoutMs: this.config.syncTimeoutMs,
disposeGraceMs: this.config.disposeGraceMs,
}
const execution = new WorkflowExecution(
this.ctx,
meta,
body,
request.parent,
request.args,
request.signal,
limits,
{
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
},
)
this.emitWorkflowEvent('workflow/start', info)
const result: Promise<WorkflowResult> = execution.drive()
// `workflow/end` fires as the (never-rejecting) result settles, with the
// outcome DATA only — the value stays with the run's holder.
void result.then((settled) => {
this.emitWorkflowEvent('workflow/end', info, {
stopReason: settled.stopReason,
...settled.error !== undefined ? { error: settled.error } : {},
agentsStarted: settled.agentsStarted,
})
})
let disposed: Promise<void> | undefined
return {
id,
meta: structuredClone(meta),
result,
cancel(reason?: string): void {
execution.cancel(reason)
},
dispose: (): Promise<void> => {
// Idempotent: cancel, then wait min(settle + child quiescence, grace).
// The cancel itself bounds `result` (the execution abandons a script
// still unsettled `disposeGraceMs` later), so this outer race exists
// for CHILD quiescence: a slow-disposing child must not hold dispose
// past the grace. `result` and `quiesce()` never reject, so the race
// needs no rejection handling.
disposed ??= (async () => {
execution.cancel('workflow disposed')
await Promise.race([
(async () => {
await result
// The result settles with the SCRIPT; stray children a script
// fired without awaiting are still winding down — dispose must
// not return while they hold live resources.
await execution.quiesce()
})(),
sleep(this.config.disposeGraceMs),
])
})()
return disposed
},
}
}
}
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
timer.unref()
})
}
export default VmWorkflowEngine