workflow: meta rides the seam as data — the engine never evaluates it
P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.
Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.
The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
This commit is contained in:
18 files changed
+304
-462
No files matched your search
@@ -47,10 +47,10 @@ import z from 'schemastery'
|
||||
import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { WorkerRun } from './host.ts'
|
||||
import { extractMeta } from './meta.ts'
|
||||
import { validateMeta } from './meta.ts'
|
||||
import type { WorkerInit, WorkerLimits } from './types.ts'
|
||||
|
||||
export { extractMeta, type ExtractedScript } from './meta.ts'
|
||||
export { validateMeta } from './meta.ts'
|
||||
export { HostToWorkerType, WorkerToHostType } from './protocol.ts'
|
||||
export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts'
|
||||
export { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
@@ -75,7 +75,7 @@ export interface Config {
|
||||
maxTotalAgents?: number
|
||||
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
|
||||
maxItemsPerCall?: number
|
||||
/** vm timeout for the initial synchronous slice (inside the worker) AND the host-side meta evaluation (default 5000 ms). */
|
||||
/** vm timeout for the script's initial synchronous slice, inside the worker (default 5000 ms). */
|
||||
syncTimeoutMs?: number
|
||||
/**
|
||||
* How long after a cancellation an unsettled script may keep running before
|
||||
@@ -87,13 +87,21 @@ export interface Config {
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** A body that still carries the Claude Code-style meta header (meta rides the seam as data here). */
|
||||
const META_STATEMENT = /^\s*export\s+const\s+meta\b/
|
||||
|
||||
/**
|
||||
* Parse-check the body with the SAME wrapper the worker-side runtime
|
||||
* compiles, so `start()` keeps the seam's synchronous `SCRIPT_PARSE` throw
|
||||
* (the worker's own compile happens a thread away, after `start()` returned).
|
||||
* One redundant parse per run, bought deliberately for the contract.
|
||||
* One redundant parse per run, bought deliberately for the contract. A body
|
||||
* opening with `export const meta` gets a pointed message instead of the
|
||||
* wrapper's bare SyntaxError — the model's likeliest authoring slip.
|
||||
*/
|
||||
function assertBodyParses(body: string, name: string): void {
|
||||
if (META_STATEMENT.test(body)) {
|
||||
throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE')
|
||||
}
|
||||
try {
|
||||
// Parse only — the script object is discarded, nothing executes.
|
||||
void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 })
|
||||
@@ -130,17 +138,18 @@ export class WorkerWorkflowEngine extends WorkflowService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script in a fresh worker thread. 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.
|
||||
* Validate and execute a workflow script in a fresh worker thread. Throws
|
||||
* {@link WorkflowError} synchronously (`META_INVALID` for a malformed meta
|
||||
* block, `SCRIPT_PARSE` for a body that does not compile) for a request
|
||||
* that cannot begin; once a run is returned, every failure resolves through
|
||||
* `result.stopReason` instead.
|
||||
* @param request - the script body, its meta data and `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)
|
||||
assertBodyParses(body, meta.name)
|
||||
const meta = validateMeta(request.meta)
|
||||
assertBodyParses(request.script, meta.name)
|
||||
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.
|
||||
@@ -155,7 +164,7 @@ export class WorkerWorkflowEngine extends WorkflowService {
|
||||
}
|
||||
const init: WorkerInit = {
|
||||
meta,
|
||||
body,
|
||||
body: request.script,
|
||||
...request.args !== undefined ? { args: request.args } : {},
|
||||
limits,
|
||||
}
|
||||
|
||||
@@ -1,100 +1,24 @@
|
||||
/**
|
||||
* Meta-block extraction: turn a Claude Code-format workflow script —
|
||||
* `export const meta = {...}` followed by a plain-JS body — into a validated
|
||||
* {@link WorkflowMeta} plus the body with the meta statement blanked
|
||||
* line-preservingly (error stacks keep the script's own line numbers).
|
||||
*
|
||||
* The scanner is a small string/comment-aware brace matcher, not a JS parser:
|
||||
* it only has to find the END of the meta object literal, and the literal is
|
||||
* contractually PURE (no interpolation, no computed values). Template strings
|
||||
* are tolerated as plain quotes but `${` inside one is rejected up front —
|
||||
* interpolation is where "literal" stops being checkable by evaluation. The
|
||||
* extracted text is then evaluated ALONE in an empty, timed vm context (a
|
||||
* non-literal reference throws there; an expression can still RUN, so the
|
||||
* result — not the source — is the contract: it must materialize to plain
|
||||
* JSON data and pass the shape validation).
|
||||
* Meta validation: check the caller-provided {@link WorkflowMeta} DATA against
|
||||
* the shape contract and reject everything else loud, every violation named.
|
||||
* Meta arrives as plain JSON through the seam (the model-facing tool carries
|
||||
* it as a schema-validated object parameter) — the engine never evaluates
|
||||
* script text to obtain it, so no script-controlled code can run on the host
|
||||
* here (an evaluated meta literal could smuggle getters that spin the host
|
||||
* outside any vm timeout, the exact escape the worker thread exists to
|
||||
* prevent).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/meta
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError, renderThrown } from './realm.ts'
|
||||
|
||||
/** The result of {@link extractMeta}: the validated meta and the runnable body. */
|
||||
export interface ExtractedScript {
|
||||
meta: WorkflowMeta
|
||||
/** The script with the meta statement blanked (newlines preserved). */
|
||||
body: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
||||
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
||||
* index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
|
||||
* interpolation (`${` inside a backtick string) or an unterminated literal.
|
||||
*/
|
||||
function scanObjectLiteral(source: string, start: number): number {
|
||||
let depth = 0
|
||||
let index = start
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '/' && source[index + 1] === '/') {
|
||||
const end = source.indexOf('\n', index)
|
||||
index = end === -1 ? source.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (ch === '/' && source[index + 1] === '*') {
|
||||
const end = source.indexOf('*/', index + 2)
|
||||
if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
|
||||
index = end + 2
|
||||
continue
|
||||
}
|
||||
if (ch === '\'' || ch === '"' || ch === '`') {
|
||||
index = scanString(source, index, ch)
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[') depth += 1
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
|
||||
function scanString(source: string, start: number, quote: string): number {
|
||||
let index = start + 1
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (quote === '`' && ch === '$' && source[index + 1] === '{') {
|
||||
throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
if (ch === quote) return index + 1
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
|
||||
function blankSpan(source: string, from: number, to: number): string {
|
||||
const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
|
||||
return source.slice(0, from) + blanked + source.slice(to)
|
||||
}
|
||||
|
||||
/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
|
||||
/** Collect shape violations for a meta value (plain JSON data by the seam contract). */
|
||||
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
|
||||
const violations: string[] = []
|
||||
/* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
return { violations: ['meta must be an object literal'] }
|
||||
return { violations: ['meta must be an object'] }
|
||||
}
|
||||
const record = meta as Record<string, unknown>
|
||||
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
|
||||
@@ -143,97 +67,19 @@ function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: st
|
||||
}
|
||||
}
|
||||
|
||||
/** `export const meta =`, anchored AFTER {@link skipLeadingTrivia} — its quantifiers cannot backtrack ambiguously. */
|
||||
const META_HEAD = /^export\s+const\s+meta\s*=\s*/
|
||||
|
||||
/**
|
||||
* Index just past the leading trivia: whitespace and `//` / `/*`-style
|
||||
* comments. A hand-rolled character scan, NOT a prefix regex — an
|
||||
* all-alternation prefix (`\s*(?:comment|\s+)*`) partitions a whitespace run
|
||||
* ambiguously and backtracks EXPONENTIALLY when the match ultimately fails,
|
||||
* so a near-miss script (a comment header, then a forgotten `export`) would
|
||||
* spin the host synchronously inside `start()`, where no vm timeout applies.
|
||||
* The near-miss must fail fast into `SCRIPT_PARSE` instead — that error is
|
||||
* the model's retry signal.
|
||||
* Validate a caller-provided meta value against the {@link WorkflowMeta}
|
||||
* contract. Throws `META_INVALID` naming every violation (unknown fields,
|
||||
* missing/mistyped `name`/`description`, malformed `phases`); the returned
|
||||
* meta is a NORMALIZED copy built from the validated fields, so the engine
|
||||
* never aliases the caller's object.
|
||||
* @param value - the meta data from the start request (plain JSON by the seam contract).
|
||||
* @returns the validated, normalized meta block.
|
||||
*/
|
||||
function skipLeadingTrivia(source: string): number {
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (/\s/.test(ch)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (ch === '/' && source[index + 1] === '/') {
|
||||
const end = source.indexOf('\n', index)
|
||||
if (end === -1) return source.length
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
if (ch === '/' && source[index + 1] === '*') {
|
||||
const end = source.indexOf('*/', index + 2)
|
||||
if (end === -1) throw new WorkflowError('script has an unterminated comment before the meta block', 'SCRIPT_PARSE')
|
||||
index = end + 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate the leading `export const meta = {...}` statement.
|
||||
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
|
||||
* or unscannable, `META_INVALID` when the literal evaluates to something
|
||||
* outside the meta contract (non-JSON data, wrong shape, unknown fields).
|
||||
* @param script - the full script text.
|
||||
* @param evalTimeoutMs - the vm timeout for evaluating the extracted literal.
|
||||
* @returns the validated meta and the line-preservingly blanked body.
|
||||
*/
|
||||
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
|
||||
const triviaEnd = skipLeadingTrivia(script)
|
||||
const match = META_HEAD.exec(script.slice(triviaEnd))
|
||||
if (!match) {
|
||||
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalStart = triviaEnd + match[0].length
|
||||
if (script[literalStart] !== '{') {
|
||||
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalEnd = scanObjectLiteral(script, literalStart)
|
||||
const literal = script.slice(literalStart, literalEnd)
|
||||
|
||||
let evaluated: unknown
|
||||
try {
|
||||
// An EMPTY context: any non-literal reference (a variable, a call) throws
|
||||
// here. The result — data only — is what the contract checks; a getter or
|
||||
// IIFE can still run, which is why the timeout and the materialization
|
||||
// below are part of the same boundary.
|
||||
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(
|
||||
`meta block failed to evaluate as a pure literal: ${renderThrown(error)}`,
|
||||
'META_INVALID',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
let data: unknown
|
||||
try {
|
||||
data = materializeFromRealm(evaluated, 'meta')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
|
||||
}
|
||||
const { meta, violations } = validateMetaShape(data)
|
||||
export function validateMeta(value: unknown): WorkflowMeta {
|
||||
const { meta, violations } = validateMetaShape(value)
|
||||
if (meta === undefined) {
|
||||
throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID')
|
||||
throw new WorkflowError(`invalid meta: ${violations.join('; ')}`, 'META_INVALID')
|
||||
}
|
||||
|
||||
// Blank the whole statement (including a trailing semicolon, if any) so the
|
||||
// body compiles standalone with its original line numbers.
|
||||
let statementEnd = literalEnd
|
||||
while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1
|
||||
if (script[statementEnd] === ';') statementEnd += 1
|
||||
return { meta, body: blankSpan(script, 0, statementEnd) }
|
||||
return meta
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class WorkflowExecution {
|
||||
// wrapper, so under one Node version this throw is unreachable in
|
||||
// production — the session still maps it to an error result defensively.
|
||||
// lineOffset compensates for the wrapper line, so stack traces carry the
|
||||
// script's own line numbers (the meta statement was blanked, not removed).
|
||||
// script's own line numbers.
|
||||
try {
|
||||
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
||||
filename: `workflow:${meta.name}`,
|
||||
|
||||
@@ -30,9 +30,9 @@ export interface WorkerLimits {
|
||||
|
||||
/** The `workerData` payload one run is initialized with (host → worker, once, at spawn). */
|
||||
export interface WorkerInit {
|
||||
/** The validated meta block (extracted host-side). */
|
||||
/** The validated meta block (plain data off the start request, validated host-side). */
|
||||
meta: WorkflowMeta
|
||||
/** The script body with the meta statement blanked (host-side `extractMeta`). */
|
||||
/** The plain-JS script body, exactly as the start request carried it. */
|
||||
body: string
|
||||
/** The run's `args` value; the workerData structured clone is the copy that isolates the caller. */
|
||||
args?: unknown
|
||||
|
||||
Reference in New Issue
Block a user