diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index ad0da6ce79..714859d916 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -100,6 +100,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ // never enter or exit the user's worktree state independently. ToolNames.ENTER_WORKTREE, ToolNames.EXIT_WORKTREE, + // FIX-8 (SEC-I1): WORKFLOW is excluded to prevent unbounded recursive + // fan-out: a subagent spawned by Workflow that calls Workflow would create + // O(k^n) subagents. + ToolNames.WORKFLOW, ]); /** diff --git a/packages/core/src/agents/runtime/workflow-orchestrator.test.ts b/packages/core/src/agents/runtime/workflow-orchestrator.test.ts new file mode 100644 index 0000000000..f94df40d77 --- /dev/null +++ b/packages/core/src/agents/runtime/workflow-orchestrator.test.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// T7 (PR #4732 R1): the `vi as vitest` alias diverges from every other +// test file in the repo. Use `vi` directly. +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + WorkflowOrchestrator, + WorkflowExecutionError, + createProductionDispatch, +} from './workflow-orchestrator.js'; +import type { Config } from '../../config/config.js'; + +// FIX-C3 (TST-2-C1): use vi.hoisted so `created` is initialised before the +// vi.mock factory runs AND remains accessible inside tests for assertion + +// reset between cases. Without this, the module-level `created` array +// accumulated across tests, so a later test could pass by coincidence. +// +// FIX-C8 (TST-2-I2): record the full 9-arg signature of AgentHeadless.create +// and the (ctx, signal?) shape of execute so any drift between the production +// call site and the real AgentHeadless surface becomes a test failure. +const { created, nextTerminateMode } = vi.hoisted(() => ({ + created: [] as Array<{ + name: string; + prompt: string; + signal?: AbortSignal; + promptConfigSystemPrompt?: string; + runConfig?: { max_turns?: number; max_time_minutes?: number }; + toolConfig?: { tools?: string[]; disallowedTools?: string[] }; + }>, + // T10 (PR #4732 R1): the production dispatch checks getTerminateMode() and + // throws on non-GOAL. Tests set `nextTerminateMode.value` to simulate + // CANCELLED / MAX_TURNS / TIMEOUT outcomes. + nextTerminateMode: { value: 'GOAL' as string }, +})); + +vi.mock('./agent-headless.js', () => ({ + AgentHeadless: { + create: async ( + name: string, + _runtimeContext: unknown, + promptConfig: { systemPrompt?: string }, + _modelConfig: unknown, + runConfig: { max_turns?: number; max_time_minutes?: number }, + toolConfig?: { tools?: string[]; disallowedTools?: string[] }, + // The next three optional params reflect the real AgentHeadless.create + // signature (eventEmitter?, hooks?, runtimeView?). Accepting them as + // `unknown` lets the mock detect if the production call site ever adds + // a positional argument that the mock would silently drop. + _eventEmitter?: unknown, + _hooks?: unknown, + _runtimeView?: unknown, + ) => ({ + execute: async ( + ctx: { get: (k: string) => unknown }, + signal?: AbortSignal, + ) => { + created.push({ + name, + prompt: ctx.get('task_prompt') as string, + signal, + promptConfigSystemPrompt: promptConfig.systemPrompt, + runConfig, + toolConfig, + }); + if ( + !promptConfig.systemPrompt?.includes('subagent spawned by a workflow') + ) { + throw new Error( + 'orchestrator did not pass workflow subagent system prompt', + ); + } + }, + getFinalText: () => + `headless-said:${created[created.length - 1]!.prompt}`, + getTerminateMode: () => nextTerminateMode.value, + }), + }, + ContextState: class ContextState { + private state: Record = {}; + get(key: string): unknown { + return this.state[key]; + } + set(key: string, value: unknown): void { + this.state[key] = value; + } + }, +})); + +function fakeConfig(): Config { + // createProductionDispatch uses Config only when constructing a real subagent. + // In tests we either inject a mock dispatch or test createProductionDispatch + // directly against the vi.mock above. An empty object cast is safe. + return {} as unknown as Config; +} + +describe('WorkflowOrchestrator', () => { + it('runs a script with injected mock dispatch and returns the script value', async () => { + const orchestrator = new WorkflowOrchestrator( + async (prompt) => `mock:${prompt}`, + ); + const outcome = await orchestrator.run({ + script: `phase("plan"); + const x = await agent("hi", { label: "a" }); + return x;`, + args: undefined, + }); + expect(outcome.result).toBe('mock:hi'); + expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/); + expect(outcome.phases).toEqual(['plan']); + }); + + it('passes args through to the script', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'unused'); + const outcome = await orchestrator.run({ + script: `return args.who`, + args: { who: 'world' }, + }); + expect(outcome.result).toBe('world'); + }); + + it('surfaces a thrown error from the script', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'unused'); + await expect( + orchestrator.run({ + script: `throw new Error("boom")`, + args: undefined, + }), + ).rejects.toThrow(/boom/); + }); + + it('runId is stable for the lifetime of a single run call', async () => { + const captured: string[] = []; + const orchestrator = new WorkflowOrchestrator(async (prompt) => { + captured.push(prompt); + return 'ok'; + }); + const outcome = await orchestrator.run({ + script: `await agent("first"); await agent("second"); return 0;`, + args: undefined, + }); + expect(captured).toEqual(['first', 'second']); + expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/); + }); + + // TST-C1: concurrent runs must produce distinct runIds. + it('runId is unique across concurrent runs', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'ok'); + const [a, b, c] = await Promise.all([ + orchestrator.run({ script: 'return 1', args: undefined }), + orchestrator.run({ script: 'return 2', args: undefined }), + orchestrator.run({ script: 'return 3', args: undefined }), + ]); + expect(a.runId).not.toBe(b.runId); + expect(b.runId).not.toBe(c.runId); + expect(a.runId).not.toBe(c.runId); + }); + + // TST-C2: a dispatch rejection must propagate out through the sandbox. + it('propagates dispatch rejection through the script', async () => { + const orchestrator = new WorkflowOrchestrator(async () => { + throw new Error('agent-crashed'); + }); + await expect( + orchestrator.run({ + script: 'await agent("x"); return 0;', + args: undefined, + }), + ).rejects.toThrow(/agent-crashed/); + }); +}); + +describe('createProductionDispatch', () => { + // FIX-C3: reset the shared mock-state array between tests so each case + // observes its own subagent.execute call only. Also reset the simulated + // terminate mode back to 'goal' (success). + beforeEach(() => { + created.length = 0; + nextTerminateMode.value = 'GOAL'; + }); + + it('routes calls through AgentHeadless and returns getFinalText', async () => { + const dispatch = createProductionDispatch(fakeConfig()); + const result = await dispatch('hello', { label: 'h1' }); + expect(result).toBe('headless-said:hello'); + expect(created.length).toBe(1); + expect(created[0]!.name).toBe('h1'); + expect(created[0]!.prompt).toBe('hello'); + }); + + // FIX-C4 (TST-2-C2): the previous test only asserted no-crash. This one + // actually captures the signal in the mock and asserts identity, so a + // regression that drops the second arg of subagent.execute() would fail. + it('threads abort signal through to subagent.execute', async () => { + const controller = new AbortController(); + const dispatch = createProductionDispatch(fakeConfig(), controller.signal); + await dispatch('hello', { label: 'h1' }); + expect(created.length).toBe(1); + expect(created[0]!.signal).toBe(controller.signal); + }); + + it('passes undefined signal when none provided', async () => { + const dispatch = createProductionDispatch(fakeConfig()); + await dispatch('hello', { label: 'h1' }); + expect(created.length).toBe(1); + expect(created[0]!.signal).toBeUndefined(); + }); + + // FIX-C2 (UP-2-C1): the subagent system prompt must include the binary's + // §XmO bullets. We assert the JSON-format instruction is present because + // its absence causes JSON-returning subagents to wrap output in code fences. + it('passes the binary §XmO verbatim system prompt to subagent', async () => { + const dispatch = createProductionDispatch(fakeConfig()); + await dispatch('hello', { label: 'h1' }); + const sp = created[0]!.promptConfigSystemPrompt ?? ''; + expect(sp).toContain('subagent spawned by a workflow'); + expect(sp).toContain('return ONLY the raw JSON'); + expect(sp).toContain('no code fences'); + expect(sp).toContain('SendUserMessage'); + }); + + // T11 (PR #4732 R1): subagents must be bounded so a single agent() call + // cannot loop the model indefinitely. + it('passes bounded runConfig (max_turns + max_time_minutes)', async () => { + const dispatch = createProductionDispatch(fakeConfig()); + await dispatch('hello', { label: 'h1' }); + expect(created[0]!.runConfig).toEqual({ + max_turns: 50, + max_time_minutes: 10, + }); + }); + + // T11: disallow SendMessage / ExitPlanMode to mirror upstream Tg8. + it('disallows SendMessage and ExitPlanMode for workflow subagents', async () => { + const dispatch = createProductionDispatch(fakeConfig()); + await dispatch('hello', { label: 'h1' }); + expect(created[0]!.toolConfig?.tools).toEqual(['*']); + expect(created[0]!.toolConfig?.disallowedTools).toEqual([ + 'send_message', + 'exit_plan_mode', + ]); + }); + + // T10 (PR #4732 R1): the production dispatch must throw when the + // subagent terminates with a non-GOAL mode. Without this, `await agent(...)` + // would resolve to '' on user cancel and the script would keep running. + it.each([ + ['CANCELLED', /terminate mode: CANCELLED/], + ['MAX_TURNS', /terminate mode: MAX_TURNS/], + ['TIMEOUT', /terminate mode: TIMEOUT/], + ['ERROR', /terminate mode: ERROR/], + ])( + 'throws when subagent terminate mode is %s', + async (mode, expectedMessage) => { + nextTerminateMode.value = mode; + const dispatch = createProductionDispatch(fakeConfig()); + await expect(dispatch('hello', { label: 'h1' })).rejects.toThrow( + expectedMessage, + ); + }, + ); +}); + +describe('WorkflowOrchestrator failure-context preservation', () => { + // T19 (PR #4732 R1): phases / logs accumulated before a script failure + // must be preserved on the thrown error so the tool layer can display + // them. Previously the sandbox instance was discarded with the error. + it('throws WorkflowExecutionError carrying phases and logs on script failure', async () => { + const orchestrator = new WorkflowOrchestrator(async () => 'ok'); + let caught: unknown; + try { + await orchestrator.run({ + script: ` + phase("plan"); + log("starting"); + phase("execute"); + log("about to fail"); + throw new Error("scripted failure"); + `, + args: undefined, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(WorkflowExecutionError); + const wfErr = caught as WorkflowExecutionError; + expect(wfErr.message).toContain('scripted failure'); + expect(wfErr.phases).toEqual(['plan', 'execute']); + expect(wfErr.logs).toEqual(['starting', 'about to fail']); + }); +}); diff --git a/packages/core/src/agents/runtime/workflow-orchestrator.ts b/packages/core/src/agents/runtime/workflow-orchestrator.ts new file mode 100644 index 0000000000..5a3fcdfe45 --- /dev/null +++ b/packages/core/src/agents/runtime/workflow-orchestrator.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomBytes } from 'node:crypto'; +import type { Config } from '../../config/config.js'; +import { createWorkflowSandbox } from './workflow-sandbox.js'; +import type { + WorkflowAgentOpts, + WorkflowAgentResult, +} from './workflow-sandbox.js'; +import { WORKFLOW_SUBAGENT_SYSTEM_PROMPT } from './workflow-prompts.js'; +import { AgentTerminateMode } from './agent-types.js'; +import { ToolNames } from '../../tools/tool-names.js'; + +/** + * Bound the resource ceiling for workflow subagents so a single `agent()` + * call cannot loop the model indefinitely. Values mirror conservative + * upstream defaults; P5 will refine via `budget` once it exists. + */ +const WORKFLOW_SUBAGENT_MAX_TURNS = 50; +const WORKFLOW_SUBAGENT_MAX_TIME_MINUTES = 10; + +/** + * disallowedTools mirror the upstream `Tg8` workflow-subagent config — both + * tools would let a subagent break the "final text IS the return value" + * contract. SendMessage would deliver the answer to the user instead of + * the calling script; ExitPlanMode would interrupt the workflow's plan-mode + * intent. Defense-in-depth alongside the §XmO system prompt that already + * documents both restrictions. + */ +const WORKFLOW_SUBAGENT_DISALLOWED_TOOLS: string[] = [ + ToolNames.SEND_MESSAGE, + ToolNames.EXIT_PLAN_MODE, +]; + +/** + * `WorkflowExecutionError` preserves the phases and logs the script + * accumulated before failing — without it, all diagnostic context is lost + * when the orchestrator's catch block surfaces only `err.message`. + * + * `cause` carries the underlying error message but no host-realm Error + * object: we only ever store strings to avoid re-introducing the T1 + * thrown-Error realm-escape vector. + */ +export class WorkflowExecutionError extends Error { + override readonly name = 'WorkflowExecutionError'; + constructor( + message: string, + readonly phases: string[], + readonly logs: string[], + ) { + super(message); + } +} + +// FIX-E (Round 4 ARCH-I1): single source of truth for the dispatch return +// type is `workflow-sandbox.ts`. Re-exported here so external consumers +// (WorkflowTool) can import the alias from the orchestrator module. +export type { WorkflowAgentResult }; + +export interface WorkflowRunRequest { + script: string; + args: unknown; + // FIX-D (Round 3 ARCH-I1): `signal` was previously declared here but never + // read by `run()` — cancellation flows through `createProductionDispatch`'s + // closure-captured signal, not via per-run state. Removed to prevent + // P2 authors from extending the wrong field. + /** + * T40 (PR #4732 R4): caller-owned AbortController linked to the wall-clock + * timeout. When the sandbox times out, this controller is aborted BEFORE + * the rejection propagates — letting in-flight subagent dispatches see + * the cancellation and stop burning tokens. The caller (`WorkflowTool`) + * also threads this same controller's signal into `createProductionDispatch` + * and aborts it in its own `finally` block to clean up on normal completion. + * If omitted, the wall-clock still rejects but in-flight subagents continue + * until their internal `max_time_minutes` limit. + */ + abortOnTimeout?: AbortController; +} + +export interface WorkflowRunOutcome { + runId: string; + result: unknown; + phases: string[]; + logs: string[]; +} + +export type WorkflowAgentDispatch = ( + prompt: string, + opts: WorkflowAgentOpts, +) => Promise; + +function generateRunId(): string { + return `wf_${randomBytes(8).toString('hex')}`; +} + +/** + * Build the production agent-dispatch function. + * + * Wraps AgentHeadless.create + execute + getFinalText into the + * `(prompt, opts) => Promise` shape required by the sandbox. + * + * Dynamic import lets test mocks swap agent-headless without static-import + * hoisting interference. + * + * FIX-6 (ARCH-C1): accepts an optional AbortSignal and threads it into + * subagent.execute() so cancellation from the caller propagates correctly. + * When signal is undefined, subagent.execute() runs without external abort. + */ +export function createProductionDispatch( + config: Config, + signal?: AbortSignal, +): WorkflowAgentDispatch { + return async (prompt, opts) => { + const { AgentHeadless, ContextState } = await import('./agent-headless.js'); + const ctx = new ContextState(); + ctx.set('task_prompt', prompt); + + const subagent = await AgentHeadless.create( + opts.label ?? 'workflow-agent', + config, + { + systemPrompt: WORKFLOW_SUBAGENT_SYSTEM_PROMPT, + initialMessages: [], + }, + {}, + // T11 (PR #4732 R1): bound resource ceiling so a single agent() call + // cannot loop the model indefinitely. Without this, runConfig was {} + // and the loop guards never tripped — combined with the cancellation + // bug below, workflows were effectively unkillable. + { + max_turns: WORKFLOW_SUBAGENT_MAX_TURNS, + max_time_minutes: WORKFLOW_SUBAGENT_MAX_TIME_MINUTES, + }, + // T11 (PR #4732 R1): disallow SendMessage / ExitPlanMode to align with + // upstream Tg8 — closes the back-channel that would let a subagent + // deliver its answer via user message instead of the script's read. + { tools: ['*'], disallowedTools: WORKFLOW_SUBAGENT_DISALLOWED_TOOLS }, + ); + await subagent.execute(ctx, signal); + // T10 (PR #4732 R1): runReasoningLoop does NOT throw on abort / turn / + // time limit — it returns with terminateMode = CANCELLED|MAX_TURNS| + // TIMEOUT|ERROR and getFinalText() = '' or partial. Without this check, + // `await agent(...)` would resolve to '' on user cancel and the script + // would happily loop on empty results. + const mode = subagent.getTerminateMode(); + if (mode !== AgentTerminateMode.GOAL) { + throw new Error( + `Workflow subagent did not complete (terminate mode: ${mode}).`, + ); + } + return subagent.getFinalText(); + }; +} + +export class WorkflowOrchestrator { + constructor(private readonly dispatch: WorkflowAgentDispatch) {} + + async run(req: WorkflowRunRequest): Promise { + // Signal threading lives in createProductionDispatch (closure-captured) + // rather than per-run state. Sandbox-level signal is intentionally not + // exposed in P1 — sync-loop protection is provided by the 30s vm + // timeout in workflow-sandbox.ts; async-loop cancellation flows + // through dispatch's subagent.execute path. + const runId = generateRunId(); + const sandbox = createWorkflowSandbox({ + args: req.args, + dispatch: this.dispatch, + abortOnTimeout: req.abortOnTimeout, + }); + try { + const result = await sandbox.run(req.script); + return { + runId, + result, + phases: sandbox.getPhases(), + logs: sandbox.getLogs(), + }; + } catch (err) { + // T19 (PR #4732 R1): preserve phases and logs accumulated before the + // script failed so the caller can surface them in the error display. + // We only carry primitive strings across the boundary — no host-realm + // Error instance — to avoid reintroducing the T1 escape vector. + // + // Cross-realm `instanceof Error` is false for vm-realm Error objects, + // so duck-type on `.message` instead. `String(vmError)` would coerce + // to "Error: " which is the wrong shape for a clean message. + throw new WorkflowExecutionError( + extractErrorMessage(err), + sandbox.getPhases(), + sandbox.getLogs(), + ); + } + } +} + +/** + * Duck-typed message extraction. `instanceof Error` is realm-local; vm-realm + * Errors raised inside the sandbox are NOT instances of host Error from the + * orchestrator's perspective, so the standard `err instanceof Error ? + * err.message : String(err)` pattern produces "Error: msg" via toString(). + * This helper falls back to the .message property regardless of realm. + */ +function extractErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'message' in err) { + const m = (err as { message: unknown }).message; + if (typeof m === 'string') return m; + return String(m); + } + return String(err); +} diff --git a/packages/core/src/agents/runtime/workflow-prompts.ts b/packages/core/src/agents/runtime/workflow-prompts.ts new file mode 100644 index 0000000000..989aa72299 --- /dev/null +++ b/packages/core/src/agents/runtime/workflow-prompts.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * System prompts for workflow subagents. + * + * Verbatim from claude-code 2.1.160 binary's §XmO constant (confirmed via + * `strings -a -n 6 | rg "You are a subagent spawned by a workflow"`). + * Kept in its own module so future phases (P3 schema mode via §ZmO, P5 budget + * guidance) can introduce variant prompts without touching the orchestrator. + */ + +/** + * Base subagent prompt — used when no schema is set on agent() opts. + * + * VERBATIM from claude-code 2.1.160 binary §XmO. The five bullet points are + * load-bearing for subagent behavior alignment: + * - "Output the literal result" — discourages explanatory text + * - "raw JSON ... no code fences" — critical for schema-returning agents in P3 + * - "Do NOT use SendUserMessage" — closes the back-channel escape hatch + * - "Be concise" — bounds token cost + * + * P1 omits the §ZmO variant (schema-mode) because P1 throws on agent({schema}). + * When P3 adds StructuredOutput, add WORKFLOW_SUBAGENT_SYSTEM_PROMPT_WITH_SCHEMA + * here as a separate const. + */ +export const WORKFLOW_SUBAGENT_SYSTEM_PROMPT = + 'You are a subagent spawned by a workflow orchestration script. ' + + 'Use the tools available to complete the task.\n' + + 'CRITICAL: Your final text response is returned **verbatim** as a string to the calling script — it is your return value, not a message to a human.\n' + + '- Output the literal result (data, JSON, text). Do NOT output confirmations like "Done." or "Sent."\n' + + '- If asked for JSON, return ONLY the raw JSON — no code fences, no prose, no markdown.\n' + + '- Do NOT use SendUserMessage to deliver your answer. Put your answer in your final text response.\n' + + '- Be concise. The script will parse your output.'; diff --git a/packages/core/src/agents/runtime/workflow-sandbox.test.ts b/packages/core/src/agents/runtime/workflow-sandbox.test.ts new file mode 100644 index 0000000000..b6a59e06a6 --- /dev/null +++ b/packages/core/src/agents/runtime/workflow-sandbox.test.ts @@ -0,0 +1,1073 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { stripExportMeta, createWorkflowSandbox } from './workflow-sandbox.js'; + +describe('stripExportMeta', () => { + it('returns input unchanged when no export meta present', () => { + const src = `phase("plan")\nreturn 1`; + expect(stripExportMeta(src)).toBe(src); + }); + + it('strips a simple export const meta declaration', () => { + const src = `export const meta = { name: 'x', description: 'y' }\nphase("plan")\nreturn 1`; + expect(stripExportMeta(src)).toBe(`phase("plan")\nreturn 1`); + }); + + it('strips a multi-line export const meta with nested braces', () => { + const src = `export const meta = { + name: 'x', + phases: [{ title: 'a' }, { title: 'b' }], +} +phase("plan") +return 1`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")\nreturn 1`); + }); + + it('strips an export meta followed by a trailing semicolon', () => { + const src = `export const meta = { name: 'x' };\nphase("plan")`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")`); + }); + + it('does not strip a const meta without export keyword', () => { + const src = `const meta = { name: 'x' }\nreturn meta`; + expect(stripExportMeta(src)).toBe(src); + }); + + it('handles string literals containing closing brace characters', () => { + const src = `export const meta = { name: 'x', description: 'hello }' } +phase("plan") +return 1`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")\nreturn 1`); + }); + + it('handles string literals containing opening brace characters', () => { + const src = `export const meta = { name: 'x', description: 'hello { world' } +phase("plan") +return 1`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")\nreturn 1`); + }); + + it('handles escaped quote characters inside string literals', () => { + const src = `export const meta = { name: 'x', description: 'it\\'s fine }' } +phase("plan")`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")`); + }); + + // T16 (Round 1 review Suggestion): line comments must skip their contents + // including stray quotes and braces, otherwise an `it's a plan` comment + // opens a phantom string literal that walks to EOF. + it('handles single-line comments inside meta object', () => { + const src = `export const meta = { + // it's the plan + name: 'x', +} +phase("plan") +return 1`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")\nreturn 1`); + }); + + it('handles braces inside single-line comments', () => { + const src = `export const meta = { + name: 'x', // closes brace } here +} +return 1`; + expect(stripExportMeta(src).trim()).toBe(`return 1`); + }); + + it('handles block comments inside meta object', () => { + const src = `export const meta = { + /* a multi-line comment with } and ' inside */ + name: 'x', +} +return 42`; + expect(stripExportMeta(src).trim()).toBe(`return 42`); + }); + + // T16: regex literals shouldn't be parsed as division on `/`. + it('handles regex literals in meta values', () => { + const src = `export const meta = { name: 'x', pattern: /\\{[a-z]+\\}/g } +return 1`; + expect(stripExportMeta(src).trim()).toBe(`return 1`); + }); + + // T9 / T17 (Round 1 review Critical): refuse to silently delete the script + // when the meta block has unmatched braces — previously returned `""` + // which made the workflow appear to succeed while returning nothing. + it('throws on unbalanced meta braces (does not silently delete script)', () => { + expect(() => stripExportMeta(`export const meta = { name: 'x'`)).toThrow( + /unbalanced/i, + ); + }); + + it('throws on meta with unterminated string', () => { + expect(() => + stripExportMeta(`export const meta = { name: 'foo }\nreturn 1`), + ).toThrow(/unbalanced/i); + }); + + // T33 (PR #4732 R4): the regex must anchor at file start, not at every + // line start. Without that, a template literal containing + // `\nexport const meta = {\n` triggers a false match and the brace-walker + // strips content out of the string body, corrupting the script. + it('does not strip an export const meta declaration inside a template literal (T33)', () => { + const src = `const banner = \` +export const meta = { name: 'fake' } +\`; +return banner;`; + expect(stripExportMeta(src)).toBe(src); + }); + + it('does not strip an export const meta declaration after leading code (T33)', () => { + const src = `const x = 1; +export const meta = { name: 'fake' } +return x;`; + expect(stripExportMeta(src)).toBe(src); + }); + + // Sanity: leading whitespace at file start is still tolerated. + it('strips export const meta even with leading whitespace/newlines (T33)', () => { + const src = `\n\n export const meta = { name: 'x' }\nphase("plan")\nreturn 1`; + expect(stripExportMeta(src).trim()).toBe(`phase("plan")\nreturn 1`); + }); +}); + +describe('createWorkflowSandbox', () => { + it('exposes args verbatim', async () => { + const sandbox = createWorkflowSandbox({ + args: { question: 'why?' }, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return args.question`); + expect(result).toBe('why?'); + }); + + // FIX-C6 (UP-2-I1): Date.now() throws (matches binary's static-reject + // intent + matches Math.random treatment). Previously it returned a + // sentinel which let scripts compute wrong durations silently. + it('Date.now() throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return Date.now()`)).rejects.toThrow(/Date\.now/); + }); + + it('Math.random() throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return Math.random()`)).rejects.toThrow( + /Math\.random/, + ); + }); + + it('return statement at top level captures the script result', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return 1 + 2`); + expect(result).toBe(3); + }); +}); + +// Security PoC tests — verify that every known realm-escape vector returns +// 'undefined' / safe values rather than the host `process` object. +describe('createWorkflowSandbox security', () => { + // Round 1 (PR #4732) approach: all globals are built in the vm-realm via + // an init script. `args.constructor` therefore points at vm-realm Object, + // `.constructor.constructor` at vm-realm Function, which when invoked + // runs in the vm realm where `process` is not defined → returns + // 'undefined' rather than the host process object. + it('args.constructor.constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: { x: 1 }, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = args.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 60); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + expect(String(result)).toMatch(/^undefined|^threw/); + }); + + // T1 (Round 1 review Critical): `try { throw } catch(e) { e.constructor }` + // previously reached the host Function constructor because injected + // closures threw host-realm Error objects. The vm-realm wrapper now + // converts every rejection into `new Error(msg)` inside the vm context, + // so `e.constructor` stays in the vm realm. + it('thrown Error from agent() options validation cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + await agent("x", { schema: { type: "object" } }); + return 'no-throw'; + } catch (e) { + try { + const v = e.constructor.constructor("return typeof process")(); + return String(v); + } catch (err) { return 'inner-threw:' + String(err.message).slice(0, 40); } + } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + expect(String(result)).toMatch(/^undefined|^inner-threw/); + }); + + // T8 / T14 (Round 1 review Critical): the Promise returned by agent() used + // to be a host-realm Promise; its constructor chain reached host Function. + // The vm-realm wrapper now returns a vm-realm Promise built via + // `new Promise(...)` inside the init script. + it('agent() success-path Promise constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ok', + }); + const result = await sandbox.run(` + const p = agent("x"); + try { + const v = p.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + expect(String(result)).toMatch(/^undefined|^threw/); + }); + + // Same vector via parallel / pipeline / workflow stubs — they all return + // vm-realm Promises now. + it('parallel() Promise constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ok', + }); + const result = await sandbox.run(` + const p = parallel([async () => 1]).catch(() => 0); + try { + const v = p.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + // T13 (Round 1 review Suggestion): the `[key: string]: unknown` index + // signature lets typos like `scema` past TypeScript. The runtime + // allowlist throws on any opt name not in the known set. + it('agent() rejects unknown opts (typo guard)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return agent("hi", { scema: { type: 'object' } });`), + ).rejects.toThrow(/scema.*unknown option/); + }); + + // T2 (Round 1 review Critical): `Object.setPrototypeOf(out, null)` used to + // remove Array.prototype, so `for...of`, `.map`, `.forEach`, spread, and + // destructuring all threw `TypeError: args is not iterable`. The vm-realm + // approach builds args from vm-realm `JSON.parse`, so they retain + // vm-realm Array.prototype. + it('array args support for...of iteration', async () => { + const sandbox = createWorkflowSandbox({ + args: [1, 2, 3], + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + let sum = 0; + for (const x of args) sum += x; + return sum; + `); + expect(result).toBe(6); + }); + + it('array args support .map / .filter / spread / destructuring', async () => { + const sandbox = createWorkflowSandbox({ + args: [1, 2, 3, 4], + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + const doubled = args.map(x => x * 2); + const evens = args.filter(x => x % 2 === 0); + const spread = [0, ...args, 5]; + const [first, ...rest] = args; + return { doubled, evens, spread, first, rest }; + `); + expect(result).toEqual({ + doubled: [2, 4, 6, 8], + evens: [2, 4], + spread: [0, 1, 2, 3, 4, 5], + first: 1, + rest: [2, 3, 4], + }); + }); + + // Nested-object args also iterate correctly via Object.entries / keys. + it('object args support Object.keys / entries / spread', async () => { + const sandbox = createWorkflowSandbox({ + args: { a: 1, b: 2, c: 3 }, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + const keys = Object.keys(args); + const vals = Object.values(args); + const ents = Object.entries(args); + const spread = { ...args, d: 4 }; + return { keys, vals, ents, spread }; + `); + expect(result).toEqual({ + keys: ['a', 'b', 'c'], + vals: [1, 2, 3], + ents: [ + ['a', 1], + ['b', 2], + ['c', 3], + ], + spread: { a: 1, b: 2, c: 3, d: 4 }, + }); + }); + + // Sanity: vm-realm phase.constructor exists but cannot reach host. + it('phase global is a vm-realm function (constructor cannot reach host)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = phase.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + // SEC-C2: vm timeout kills a synchronous infinite loop within 30s. + it('synchronous infinite loop is aborted by vm timeout', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`while(true){}`)).rejects.toThrow( + /Script execution timed out/i, + ); + }, 35_000); // wall clock for the test itself + + // UP-C1: agent({schema}) must throw a clear error, not silently drop the opt. + it('agent() rejects unsupported schema opt with a clear error', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(` + return agent("hi", { schema: { type: "object" } }); + `), + ).rejects.toThrow(/schema.*P3/); + }); + + // UP-C1: agent({phase}) is honored — pushed to the phases array. + it('agent() honors opts.phase by appending to phases', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async (_p, opts) => `done:${opts.phase ?? 'no-phase'}`, + }); + const result = await sandbox.run(` + return await agent("x", { phase: "Search" }); + `); + expect(result).toBe('done:Search'); + expect(sandbox.getPhases()).toEqual(['Search']); + }); + + // SEC-I2: log() must cap at MAX_LOG_LINES and add a truncation marker. + it('log() caps at MAX_LOG_LINES with a truncation marker', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await sandbox.run(`for (let i = 0; i < 10100; i++) log(i); return 0;`); + const logs = sandbox.getLogs(); + expect(logs.length).toBe(10_001); // 10_000 entries + 1 truncation marker + expect(logs[10_000]).toMatch(/truncated/); + }); + + // FIX-C5 (SEC-2-I1): same cap pattern for phases array — protects host + // from `for(let i=0;i<1e6;i++) phase("p"+i)` style memory bombs. + it('phase() caps at MAX_PHASE_ENTRIES with a truncation marker', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await sandbox.run( + `for (let i = 0; i < 10100; i++) phase("p"+i); return 0;`, + ); + const phases = sandbox.getPhases(); + expect(phases.length).toBe(10_001); + expect(phases[10_000]).toMatch(/truncated/); + }); + + // FIX-C1 (SEC-2-C1): Round 2 PoC — `Math.constructor.constructor("return process")()` + // reaches host realm because Math is the host realm's Math object. The Proxy + // `get` trap on `constructor` blocks the chain. + it('blocks Math.constructor realm escape', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + const ctor = Math.constructor; + return ctor === undefined ? 'blocked' : 'leaked:' + typeof ctor; + `); + expect(result).toBe('blocked'); + }); + + // FIX-D (Round 3 SEC C1): Math is now constructed in vm realm as a + // null-proto object. getOwnPropertyDescriptor returns a real descriptor, + // but invoking `.value()` still throws the "Math.random unavailable" + // error — the original goal (preventing real-random leakage) is preserved. + it('Math.random descriptor.value() still throws the unavailable error', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(` + const d = Object.getOwnPropertyDescriptor(Math, 'random'); + return d.value(); + `), + ).rejects.toThrow(/Math\.random/); + }); + + // FIX-D (Round 3 SEC-C1 PoC): Round 3 adversarial reviewer confirmed PoC + // that `Math.__proto__.constructor.constructor("return process")()` reached + // the host `process` object (returned darwin:pid). After Fix D, Math is + // a null-proto vm-realm object, so __proto__ is undefined. + it('Math.__proto__ is undefined (blocks proto-chain escape)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return Math.__proto__`); + expect([null, undefined]).toContain(result); + }); + + // FIX-D (Round 3 SEC-C2): Math.toString used to reach host + // Function.prototype.toString, whose .constructor is host Function. + // After Fix D, Math has no inherited toString (null-proto). + it('Math.toString is undefined (blocks inherited-method escape)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return typeof Math.toString`); + expect(result).toBe('undefined'); + }); + + // FIX-D (Round 3 TST-C1): Math.abs.constructor used to reach host Function. + // After Fix D, Math.abs is a vm-realm function. Its constructor is vm-realm + // Function — invoking it cannot access host process (process is undefined + // in vm globals). + it('Math.abs.constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = Math.abs.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw'; } + `); + // process is not in vm globals → typeof process === 'undefined'. + // Either way (caught or undefined), no host info leaks. + expect(result).not.toMatch(/object|darwin|linux|win32/i); + expect(['undefined', 'threw']).toContain(result); + }); + + // FIX-D (Round 3 SEC-C3): Date.constructor used to reach host Object, + // whose .constructor is host Function. After Fix D, Date is a vm-realm + // function with null prototype; .constructor is undefined. + it('Date.constructor is undefined (blocks Date-object escape)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return Date.constructor`); + expect(result).toBeUndefined(); + }); + + // FIX-D (Round 3 UP-C1): new Date() previously fell through to the host + // Date constructor and leaked real wall-clock time. After Fix D, the Date + // stub is itself a throwing function; `new Date()` triggers [[Construct]] + // which invokes [[Call]] → throws. + it('new Date() throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return new Date()`)).rejects.toThrow( + /unavailable in workflow scripts/i, + ); + }); + + it('Date() (bare call) throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return Date()`)).rejects.toThrow( + /unavailable in workflow scripts/i, + ); + }); + + it('Date.UTC() throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return Date.UTC(2026, 0, 1)`)).rejects.toThrow( + /unavailable in workflow scripts/i, + ); + }); + + // FIX-D: console object itself is hardened (null proto + .constructor + // undefined), blocking `console.constructor.constructor` escape. + it('console.constructor is undefined (blocks container-object escape)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return console.constructor`); + expect(result).toBeUndefined(); + }); + + // T22 (PR #4732 R2): `globalThis` itself used to be a host-realm plain + // object literal whose `.constructor` reached the host Object → host + // Function → host process. PoC: `globalThis.constructor.constructor( + // "return process")()` returned host process with .env/.platform/.pid + // readable. Fix severs sandboxGlobals's prototype before createContext. + it('blocks globalThis.constructor host-realm escape', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const Obj = globalThis.constructor; + if (!Obj) return 'no-ctor'; + const Fn = Obj.constructor; + if (!Fn) return 'no-inner-ctor'; + const v = Fn("return typeof process")(); + return String(v); + } catch (e) { return 'threw'; } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + expect(['no-ctor', 'no-inner-ctor', 'undefined', 'threw']).toContain( + result, + ); + }); + + // T22: same root via implicit `this` (== globalThis at top level). + it('blocks implicit globalThis (this) host-realm escape', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const t = (function(){ return this; })(); + if (!t || !t.constructor || !t.constructor.constructor) return 'blocked'; + const v = t.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw'; } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + // T23 (PR #4732 R2): the vm `timeout` option only covers synchronous + // execution. `return new Promise(() => {})` hits the first `await`, + // disarms the watchdog, and hangs forever. Wall-clock timeout via + // Promise.race rejects after maxWallClockMs. + it('rejects an async never-resolving Promise via wall-clock timeout', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + maxWallClockMs: 100, // tiny override for fast test + }); + await expect(sandbox.run(`return new Promise(() => {});`)).rejects.toThrow( + /timed out after 100 ms wall clock/, + ); + }); + + // NOTE: we explicitly do NOT test an in-script async microtask loop + // (`(async () => { while(true) await Promise.resolve(); })()`). Once such + // a loop starts inside the vm context, Node provides no way to halt it — + // our wall-clock timeout rejects the outer Promise.race but the loop + // keeps consuming microtasks, hanging the test runner. In production + // this is still acceptable: the workflow surface returns the timeout + // error and the vm context becomes unreferenced (GC eventually reclaims + // it). Documented as a limitation of node:vm. + + // T40 (PR #4732 R4): completing R2's wall-clock defense. When the timer + // fires the sandbox rejects, but in-flight subagents (closed over the + // dispatch signal) keep running until their internal max_time_minutes + // limit. Threading an AbortController through `abortOnTimeout` lets the + // caller link wall-clock fires to dispatch-signal aborts. + it('aborts the abortOnTimeout controller when wall-clock timeout fires (T40)', async () => { + const abortOnTimeout = new AbortController(); + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + maxWallClockMs: 100, + abortOnTimeout, + }); + expect(abortOnTimeout.signal.aborted).toBe(false); + await expect(sandbox.run(`return new Promise(() => {});`)).rejects.toThrow( + /timed out after 100 ms wall clock/, + ); + expect(abortOnTimeout.signal.aborted).toBe(true); + }); + + // T40 sibling: a normal completion must NOT abort the controller — the + // caller is responsible for cleanup in its finally block. + it('does not abort the abortOnTimeout controller on normal completion (T40)', async () => { + const abortOnTimeout = new AbortController(); + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + maxWallClockMs: 5000, + abortOnTimeout, + }); + const result = await sandbox.run(`return 42`); + expect(result).toBe(42); + expect(abortOnTimeout.signal.aborted).toBe(false); + }); + + // T23: env var override is honored when no explicit opt is passed. + it('QWEN_CODE_MAX_WORKFLOW_SECONDS env var sets the wall-clock cap', async () => { + process.env['QWEN_CODE_MAX_WORKFLOW_SECONDS'] = '0.1'; + try { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return new Promise(() => {});`), + ).rejects.toThrow(/timed out after 100 ms wall clock/); + } finally { + delete process.env['QWEN_CODE_MAX_WORKFLOW_SECONDS']; + } + }); + + // FIX-E (Round 4 Critical): Array args used to leak host process because + // `deepNullProto` recursed into elements but left Array.prototype intact + // on the array body. PoC: `args.constructor.constructor("return process")()` + // returned host process with .env.HOME readable. + it('blocks args.constructor escape when args is an array', async () => { + const sandbox = createWorkflowSandbox({ + args: [1, 2, 3], + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = args.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw'; } + `); + expect(result).not.toMatch(/object|darwin|linux/i); + expect(['undefined', 'threw']).toContain(result); + }); + + // FIX-E: Symbol.iterator path via array's prototype. + it('blocks args[Symbol.iterator] realm escape when args is an array', async () => { + const sandbox = createWorkflowSandbox({ + args: [1, 2, 3], + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const it = args[Symbol.iterator] && args[Symbol.iterator](); + if (!it) return 'no-iterator'; + const ctor = it.next.constructor; + const v = ctor("return typeof process")(); + return String(v); + } catch (e) { return 'threw'; } + `); + expect(result).not.toMatch(/object|darwin|linux/i); + expect(['undefined', 'threw', 'no-iterator']).toContain(result); + }); + + // FIX-F (Round 4 UP Critical): P2/P5 primitives are stub-throwing globals + // so model-authored scripts get a clear "scheduled for PN" error rather + // than `ReferenceError: parallel is not defined` (which the model would + // misdiagnose as a bug in its own script). + it('parallel() throws a P1-unsupported error rather than ReferenceError', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return parallel([() => agent("a")]);`), + ).rejects.toThrow(/parallel.*not supported in P1/); + }); + + it('pipeline() throws a P1-unsupported error rather than ReferenceError', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return pipeline([1, 2], x => x, x => x);`), + ).rejects.toThrow(/pipeline.*not supported in P1/); + }); + + it('workflow() throws a P1-unsupported error rather than ReferenceError', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return workflow('child', { foo: 1 });`), + ).rejects.toThrow(/workflow\(\).*not supported in P1/); + }); + + it('budget.spent() / .remaining() throw with clear P1-unsupported errors', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect(sandbox.run(`return budget.spent();`)).rejects.toThrow( + /budget\.spent.*not supported in P1/, + ); + await expect(sandbox.run(`return budget.remaining();`)).rejects.toThrow( + /budget\.remaining.*not supported in P1/, + ); + }); + + // FIX-H (Round 5 ARCH I1/I2/I3): injection seams for parallel/pipeline/ + // budget. These regression tests guard the contract: P2/P5 will provide + // real implementations via SandboxOptions without modifying sandbox source. + it('opts.parallel overrides the throwing stub when provided', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + parallel: async (thunks) => Promise.all(thunks.map((t) => t())), + }); + const result = await sandbox.run(` + return await parallel([async () => 1, async () => 2, async () => 3]); + `); + expect(result).toEqual([1, 2, 3]); + }); + + it('opts.pipeline overrides the throwing stub when provided', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + pipeline: async (items, ...stages) => { + const out: unknown[] = []; + for (let i = 0; i < items.length; i++) { + let cur: unknown = items[i]; + for (const stage of stages) { + cur = await stage(cur, items[i], i); + } + out.push(cur); + } + return out; + }, + }); + const result = await sandbox.run(` + return await pipeline([1, 2, 3], async (x) => x * 10); + `); + expect(result).toEqual([10, 20, 30]); + }); + + it('opts.budget overrides the throwing stub when provided', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + budget: { + total: 500_000, + spent: () => 123, + remaining: () => 499_877, + }, + }); + const result = await sandbox.run(` + return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() }; + `); + expect(result).toEqual({ total: 500_000, spent: 123, remaining: 499_877 }); + }); + + // T15 (PR #4732 R1): when opts.budget IS provided, the wrapper functions + // must also block the budget.spent.constructor host-Function escape. The + // existing constructor-escape tests only run against the default stub. + it('opts.budget: spent/remaining constructors stay vm-realm-safe', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + budget: { total: 100, spent: () => 10, remaining: () => 90 }, + }); + const result = await sandbox.run(` + try { + const v = budget.spent.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + it('budget.total is null in P1 (matches upstream "no target" sentinel)', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(`return budget.total`); + expect(result).toBeNull(); + }); + + // budget.spent / remaining are vm-realm functions whose .constructor is + // vm-realm Function. The escape would only matter if that chain reached + // host Function — it doesn't, because the entire budget object is built + // inside the vm init script. + it('budget.spent.constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = budget.spent.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + it('budget.remaining.constructor cannot reach host process', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + const result = await sandbox.run(` + try { + const v = budget.remaining.constructor.constructor("return typeof process")(); + return String(v); + } catch (e) { return 'threw:' + String(e.message).slice(0, 40); } + `); + expect(result).not.toMatch(/object|darwin|linux|win32/i); + }); + + // FIX-G (Round 4 test Important): Date.parse is implemented but was + // previously untested. Refactors that drop .parse would silently leave a + // gap where parse() works (legacy host Date) and leaks real time math. + it('Date.parse() throws inside sandbox', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return Date.parse("2026-01-01")`), + ).rejects.toThrow(/unavailable in workflow scripts/i); + }); + + // T6 (Round 1 review Suggestion): validateArgs must reject functions, + // BigInts, and circular references — without it, JSON.stringify silently + // drops function-valued keys, and circular refs throw a generic message. + it('rejects args with function-valued properties', () => { + expect(() => + createWorkflowSandbox({ + args: { fn: () => 1 }, + dispatch: async () => 'ignored', + }), + ).toThrow(/JSON-serializable.*functions/i); + }); + + it('rejects args with BigInt values', () => { + expect(() => + createWorkflowSandbox({ + args: { n: BigInt(1) }, + dispatch: async () => 'ignored', + }), + ).toThrow(/JSON-serializable.*BigInt/i); + }); + + it('rejects args with circular references', () => { + const a: Record = {}; + a['self'] = a; + expect(() => + createWorkflowSandbox({ + args: a, + dispatch: async () => 'ignored', + }), + ).toThrow(/JSON-serializable.*circular/i); + }); + + // Explicit max-depth cap on args nesting. + it('rejects args with nesting beyond max depth with a clear error', () => { + const deep: Record = {}; + let cur = deep; + for (let i = 0; i < 200; i++) { + const next: Record = {}; + cur['nested'] = next; + cur = next; + } + expect(() => + createWorkflowSandbox({ + args: deep, + dispatch: async () => 'ignored', + }), + ).toThrow(/max nesting depth/); + }); + + // FIX-C7 (TST-2-I1): each of the four unsupported-opts throw branches must + // have its own test. A refactor that deletes any branch passes the others. + it('agent() rejects isolation opt with clear error', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return agent("hi", { isolation: "worktree" });`), + ).rejects.toThrow(/isolation.*not supported in P1/); + }); + + it('agent() rejects model opt with clear error', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return agent("hi", { model: "gpt-4" });`), + ).rejects.toThrow(/model.*not supported in P1/); + }); + + it('agent() rejects agentType opt with clear error', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await expect( + sandbox.run(`return agent("hi", { agentType: "Explore" });`), + ).rejects.toThrow(/agentType.*not supported in P1/); + }); + + // FIX-C7 (TST-2-I3): the dedup branch in agent({phase}) — consecutive + // identical opts.phase values must not produce duplicate entries. + it('agent() opts.phase dedups consecutive identical entries', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'done', + }); + await sandbox.run(` + await agent("a", { phase: "Search" }); + await agent("b", { phase: "Search" }); + await agent("c", { phase: "Verify" }); + await agent("d", { phase: "Verify" }); + await agent("e", { phase: "Search" }); + return 0; + `); + // The implementation only dedups against the most recent entry, so a + // phase repeating after a different one is appended again. + expect(sandbox.getPhases()).toEqual(['Search', 'Verify', 'Search']); + }); +}); + +describe('createWorkflowSandbox primitives', () => { + it('phase() pushes titles in script order', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await sandbox.run(`phase("plan"); phase("build"); return 0`); + expect(sandbox.getPhases()).toEqual(['plan', 'build']); + }); + + it('log() accumulates string and non-string arguments', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await sandbox.run(`log("hi"); log(42); return 0`); + expect(sandbox.getLogs()).toEqual(['hi', '42']); + }); + + it('agent() invokes dispatch and resolves with its return value', async () => { + const seen: Array<{ prompt: string; label?: string }> = []; + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async (prompt, opts) => { + seen.push({ prompt, label: opts.label }); + return `echo: ${prompt}`; + }, + }); + const result = await sandbox.run( + `const a = await agent("write hello", { label: "h1" }); + return a;`, + ); + expect(result).toBe('echo: write hello'); + expect(seen).toEqual([{ prompt: 'write hello', label: 'h1' }]); + }); + + it('agent() runs sequentially when called multiple times', async () => { + const order: number[] = []; + let counter = 0; + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => { + const myOrder = ++counter; + await new Promise((r) => setTimeout(r, 5)); + order.push(myOrder); + return String(myOrder); + }, + }); + const result = await sandbox.run(` + const a = await agent("first"); + const b = await agent("second"); + return [a, b]; + `); + expect(result).toEqual(['1', '2']); + expect(order).toEqual([1, 2]); + }); + + // T5 (Round 1 review Suggestion): console.log/warn/error must route to + // getLogs() — a refactor removing the routing would silently break + // model scripts that use console for diagnostics. + it('console.log / warn / error route to getLogs()', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async () => 'ignored', + }); + await sandbox.run(` + console.log("info"); + console.warn("warn"); + console.error("err"); + return 0; + `); + expect(sandbox.getLogs()).toEqual(['info', 'warn', 'err']); + }); + + it('full P1 acceptance script: phase + agent returns expected value', async () => { + const sandbox = createWorkflowSandbox({ + args: undefined, + dispatch: async (prompt) => `agent-response:${prompt}`, + }); + const result = await sandbox.run(` + phase("plan"); + const out = await agent("write a hello", { label: "h1" }); + return out; + `); + expect(result).toBe('agent-response:write a hello'); + expect(sandbox.getPhases()).toEqual(['plan']); + }); +}); diff --git a/packages/core/src/agents/runtime/workflow-sandbox.ts b/packages/core/src/agents/runtime/workflow-sandbox.ts new file mode 100644 index 0000000000..c2b08851cc --- /dev/null +++ b/packages/core/src/agents/runtime/workflow-sandbox.ts @@ -0,0 +1,643 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Strip a leading `export const meta = { ... }` declaration from a workflow + * script. Required because Node's vm script mode rejects ES module syntax. + * + * P1 does not use meta semantically; it is removed so that Claude-Code-trained + * models whose first line is `export const meta = {...}` do not produce a + * SyntaxError at sandbox parse time. + * + * Recognises `//` / `/* *\/` comments and regex literals in addition to + * string literals (single, double, template). Throws on unbalanced braces + * instead of returning a truncated string — silently deleting the script + * body produced the worst-case failure mode (workflow runs, returns + * undefined, no diagnostic). + * + * Template-literal `${...}` substitutions that contain `{` or `}` are not + * supported — model-authored `meta` should avoid them. + */ +export function stripExportMeta(source: string): string { + // T33 (PR #4732 R4): anchor at file start (no `/m` flag). Per the design + // doc, `export const meta = {...}` must be the script's FIRST statement. + // With `/m`, the regex matched every line-start occurrence — including + // inside template literals — and the brace-walker then ripped content + // out of the string body, silently corrupting the script. + const re = /^\s*export\s+const\s+meta\s*=\s*\{/; + const match = re.exec(source); + if (!match) return source; + const exportIdx = match.index; + const startBrace = source.indexOf('{', exportIdx); + let depth = 1; + let i = startBrace + 1; + while (i < source.length && depth > 0) { + const ch = source[i]; + const next = source[i + 1]; + // Single-line comment: skip to newline (T16). + if (ch === '/' && next === '/') { + i += 2; + while (i < source.length && source[i] !== '\n') i++; + continue; + } + // Block comment: skip to closing `*/` (T16). + if (ch === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) + i++; + i += 2; + continue; + } + // Regex literal: skip to matching `/`. We accept the heuristic that a `/` + // appearing as a value in `{` context is a regex literal, not division + // — meta objects don't perform arithmetic on properties. + if (ch === '/' && isRegexContext(source, i)) { + i++; + let inClass = false; + while ( + i < source.length && + (inClass || source[i] !== '/') && + source[i] !== '\n' + ) { + if (source[i] === '\\') i += 2; + else if (source[i] === '[') { + inClass = true; + i++; + } else if (source[i] === ']') { + inClass = false; + i++; + } else { + i++; + } + } + i++; // skip closing / + // Skip flags + while (i < source.length && /[gimsuy]/.test(source[i]!)) i++; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + const q = ch; + i++; + while (i < source.length && source[i] !== q) { + if (source[i] === '\\') i++; // skip escaped char + i++; + } + i++; // skip closing quote + continue; + } + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + // T9/T17: refuse to truncate the script when the meta block is unbalanced. + // Returning `""` previously caused the entire workflow body to vanish + // silently — the worst possible failure mode. + if (depth !== 0) { + throw new Error( + 'stripExportMeta: unbalanced braces in export const meta declaration — ' + + 'the workflow script cannot be safely stripped. Check the meta block syntax.', + ); + } + // Skip trailing whitespace and an optional semicolon. + while (i < source.length && /[\s;]/.test(source[i]!)) i++; + return source.slice(0, exportIdx) + source.slice(i); +} + +/** + * Heuristic: a `/` at offset `i` is a regex literal (not division) if the + * previous non-whitespace character is an operator, opening brace/bracket, + * comma, colon, or `=` — i.e. positions where a value is expected. + */ +function isRegexContext(source: string, i: number): boolean { + let j = i - 1; + while (j >= 0 && /\s/.test(source[j]!)) j--; + if (j < 0) return true; + const prev = source[j]!; + return /[{[(,;:=!&|?+\-*/%^~<>]/.test(prev); +} + +import * as vm from 'node:vm'; + +// Cap log + phase lines to prevent unbounded memory growth from runaway +// model-authored loops. +const MAX_LOG_LINES = 10_000; +const MAX_PHASE_ENTRIES = 10_000; +// Max nesting depth for args; defends against stack-overflow on deeply +// nested model-authored input. +const ARGS_MAX_DEPTH = 64; + +/** + * WorkflowAgentOpts — structured options for the `agent()` global. + * + * The named fields below are explicitly recognised. P1 throws for unsupported + * fields (`schema`, `model`, `isolation`, `agentType`) rather than silently + * dropping them. The runtime allowlist enforced in the vm-realm init script + * additionally throws on ANY field not in the known set — catching typos + * like `scema` before they reach dispatch. + */ +export interface WorkflowAgentOpts { + label?: string; + phase?: string; + schema?: object; + model?: string; + isolation?: 'worktree' | 'remote'; + agentType?: string; + // The index signature exists so TypeScript accepts forward-compat opt names + // at compile time; the runtime allowlist still rejects unknown names. + [key: string]: unknown; +} + +/** + * Forward-compatibility alias for the agent dispatch return type. P1: always + * `string`. P3 will widen this to support StructuredOutput. + */ +export type WorkflowAgentResult = string; + +/** + * P5: budget global API surface. P1 default is throwing stubs (total = null, + * spent()/remaining() throw). P5 will inject a real tracker. + */ +export interface WorkflowBudget { + total: number | null; + spent(): number; + remaining(): number; +} + +export interface SandboxOptions { + /** Value bound to the `args` global inside the script. */ + args: unknown; + /** + * Function called by the script's `agent(prompt, opts)` global. Returns the + * agent's final text. Injected so tests can mock without spawning an LLM. + */ + dispatch: ( + prompt: string, + opts: WorkflowAgentOpts, + ) => Promise; + /** + * Forward-compatibility injection seams for P2 (parallel / pipeline) and + * P5 (budget). When omitted the sandbox falls back to throwing stubs. + */ + parallel?: (thunks: Array<() => Promise>) => Promise; + pipeline?: ( + items: unknown[], + ...stages: Array< + (prev: unknown, item: unknown, idx: number) => Promise + > + ) => Promise; + budget?: WorkflowBudget; + /** + * T23 (PR #4732 R2): async wall-clock cap (ms) covering the entire script + * including awaits. The vm `timeout` option only covers the synchronous + * portion; once the IIFE yields its first `await`, the watchdog is + * disarmed and `return new Promise(() => {})` would hang forever. + * + * Defaults to 30 minutes, override via `QWEN_CODE_MAX_WORKFLOW_SECONDS` + * env var, or pass an explicit value here (tests use small values for + * fast verification). + * + * This stays a permanent defense even after P5's `budget` ships: + * budget caps tokens, but a 0-token hang (`new Promise(() => {})`) only + * a wall-clock can catch. + */ + maxWallClockMs?: number; + /** + * T40 (PR #4732 R4): completes the R2 wall-clock defense. When the timer + * fires, the sandbox `abort()`s this controller BEFORE rejecting. The + * caller threads the same controller's `signal` into the dispatch + * function (via `createProductionDispatch`) so in-flight subagents see + * the abort and stop. Without this, the workflow user-side rejects but + * the subagent keeps burning tokens until its own `max_time_minutes` + * limit (10 min default). + * + * The caller is responsible for cleanup on natural completion (call + * `abort()` in a `finally` block to cancel any straggler dispatch). + */ + abortOnTimeout?: AbortController; +} + +/** + * T23 (PR #4732 R2): default async wall-clock cap. 30 minutes is generous + * for any realistic P1 sequential workflow (single agent capped at + * 10 min × ~10 agents max practical → ~100 min upper bound, but typical + * workflows finish in seconds). 30 min stops 0-token hang patterns + * before they waste operator hours. + */ +const DEFAULT_MAX_WALL_CLOCK_MS = 30 * 60 * 1000; + +function resolveMaxWallClockMs(opts: SandboxOptions): number { + if (typeof opts.maxWallClockMs === 'number' && opts.maxWallClockMs > 0) { + return opts.maxWallClockMs; + } + const envSec = Number(process.env['QWEN_CODE_MAX_WORKFLOW_SECONDS']); + if (Number.isFinite(envSec) && envSec > 0) return envSec * 1000; + return DEFAULT_MAX_WALL_CLOCK_MS; +} + +export interface WorkflowSandbox { + /** + * Execute the user-authored script source. The script is wrapped as an async + * IIFE so it may use top-level `await` and `return`. Returns the script's + * top-level return value. + */ + run(scriptSource: string): Promise; + /** Phase titles announced by the script in order. */ + getPhases(): string[]; + /** Log lines emitted by the script in order. */ + getLogs(): string[]; +} + +/** + * Validate `args` without mutating it. Throws on functions, BigInts, circular + * references, and nesting beyond `ARGS_MAX_DEPTH`. The actual sandbox `args` + * global is built INSIDE the vm context via `JSON.parse` so it inherits + * vm-realm prototypes — this validation just gates what we hand to JSON + * stringification. + */ +function validateArgs( + val: unknown, + depth = 0, + seen: WeakSet = new WeakSet(), +): void { + if (depth > ARGS_MAX_DEPTH) { + throw new Error( + `WorkflowSandbox: args exceeded max nesting depth of ${ARGS_MAX_DEPTH}`, + ); + } + if (val === null) return; + const t = typeof val; + if (t === 'function') { + throw new Error( + 'WorkflowSandbox: args must be JSON-serializable (functions are not allowed).', + ); + } + if (t === 'bigint') { + throw new Error( + 'WorkflowSandbox: args must be JSON-serializable (BigInt is not allowed — pass as string).', + ); + } + if (t !== 'object') return; + const obj = val as object; + if (seen.has(obj)) { + throw new Error( + 'WorkflowSandbox: args must be JSON-serializable (circular reference detected).', + ); + } + seen.add(obj); + if (Array.isArray(val)) { + for (const item of val) validateArgs(item, depth + 1, seen); + } else { + for (const v of Object.values(val as Record)) { + validateArgs(v, depth + 1, seen); + } + } +} + +export function createWorkflowSandbox(opts: SandboxOptions): WorkflowSandbox { + const phases: string[] = []; + const logs: string[] = []; + + const safeLog = (msg: unknown): void => { + if (logs.length < MAX_LOG_LINES) { + logs.push(String(msg)); + } else if (logs.length === MAX_LOG_LINES) { + logs.push(`[workflow log truncated at ${MAX_LOG_LINES} lines]`); + } + }; + + const safePhase = (title: string): void => { + if (phases.length < MAX_PHASE_ENTRIES) { + phases.push(String(title)); + } else if (phases.length === MAX_PHASE_ENTRIES) { + phases.push( + `[workflow phases truncated at ${MAX_PHASE_ENTRIES} entries]`, + ); + } + }; + + // FIX-Round1-T6: validate args structure (functions/BigInt/circular/depth) + // before serialising. Without this, `JSON.stringify({fn: () => {}})` silently + // drops the function key. + if (opts.args !== undefined) validateArgs(opts.args); + const argsJson = opts.args === undefined ? null : JSON.stringify(opts.args); + + // FIX-Round1-T1/T8/T14: build EVERY sandbox global inside the vm-realm + // via the init script below. Host-realm objects (Promises returned by host + // async functions, Error objects thrown by host code) leak the host Function + // constructor through their prototype chains: + // `agent("x").constructor.constructor("return process")()` (T8, success path) + // `try { throw new Error } catch(e) { e.constructor.constructor(...)() }` (T1) + // The fix is to NEVER expose a host object across the vm boundary. Instead + // we expose a primitive bridge (functions and strings) on globalThis, + // delete it as the first init action, and have the init script build vm-realm + // wrappers that internally call the bridge but only return / throw vm-realm + // values. + const bridge = { + argsJson, + pushPhase: safePhase, + pushLog: safeLog, + lastPhase: () => phases[phases.length - 1], + hostAgent: opts.dispatch, + // The truthy flags distinguish "injected" from "default stub" inside the + // init script without leaking the host function itself when not used. + hasParallel: !!opts.parallel, + hasPipeline: !!opts.pipeline, + hasBudget: !!opts.budget, + hostParallel: opts.parallel, + hostPipeline: opts.pipeline, + budgetTotal: opts.budget ? opts.budget.total : null, + hostBudgetSpent: opts.budget ? opts.budget.spent.bind(opts.budget) : null, + hostBudgetRemaining: opts.budget + ? opts.budget.remaining.bind(opts.budget) + : null, + }; + + // T22 (PR #4732 R2): sever the host Object.prototype on both the + // bridge AND the sandboxGlobals container. Without this, + // `globalThis.constructor.constructor("return process")()` inside the + // sandbox reaches the host Object → host Function → host process, + // bypassing every other vm-realm hardening measure in this file. + // PoC confirmed leak prior to fix; regression covered by + // "globalThis.constructor cannot reach host process". + Object.setPrototypeOf(bridge, null); + const sandboxGlobals: { __workflowBridge: typeof bridge } = Object.assign( + Object.create(null) as { __workflowBridge: typeof bridge }, + { __workflowBridge: bridge }, + ); + const ctx = vm.createContext(sandboxGlobals); + + // FIX-D + FIX-Round1: build Math, Date, args, all async/sync globals, + // and the console object entirely inside the vm-realm. After this init + // script completes, `globalThis.__workflowBridge` is deleted so the user + // script cannot reach it. + vm.runInContext( + `(() => { + const __b = globalThis.__workflowBridge; + delete globalThis.__workflowBridge; + + // --- Math (vm-realm, random throws) --- + const realMath = Math; + const safeMath = Object.create(null); + for (const k of Object.getOwnPropertyNames(realMath)) { + if (k === 'random' || k === 'constructor') continue; + safeMath[k] = realMath[k]; + } + safeMath.random = () => { + throw new Error( + 'Math.random() is unavailable in workflow scripts (breaks resume). ' + + 'For N independent samples, include the index in the agent label or prompt.' + ); + }; + globalThis.Math = safeMath; + + // --- Date (vm-realm function that throws on any access) --- + const dateMsg = 'Date.now() / new Date() are unavailable in workflow ' + + 'scripts (breaks resume). Stamp results after the workflow returns, ' + + 'or pass timestamps via args.'; + const safeDate = function Date() { throw new Error(dateMsg); }; + safeDate.now = () => { throw new Error(dateMsg); }; + safeDate.UTC = () => { throw new Error(dateMsg); }; + safeDate.parse = () => { throw new Error(dateMsg); }; + Object.setPrototypeOf(safeDate, null); + Object.defineProperty(safeDate, 'constructor', { + value: undefined, writable: false, configurable: false, + }); + globalThis.Date = safeDate; + + // --- args (parsed via vm-realm JSON → vm-realm objects/arrays) --- + // FIX-Round1-T2: vm-realm arrays keep their vm-realm Array.prototype, + // so for...of, .map, .forEach, spread, destructuring all work — and + // their inherited methods' constructors are vm-realm Function, which + // cannot reach host process. + globalThis.args = __b.argsJson === null ? undefined : JSON.parse(__b.argsJson); + + // --- Wrap a host async function so it returns a vm-realm Promise --- + // FIX-Round1-T1/T8/T14: success and failure both cross the boundary + // as vm-realm values: resolve with the host's value (a primitive + // string for dispatch; vm-realm arrays for parallel/pipeline because + // those wrappers will produce vm-realm results); reject with a + // freshly-constructed vm-realm Error so e.constructor.constructor + // stays in the vm realm. + function vmAsync(hostFn) { + return function (...vmArgs) { + return new Promise(function (resolve, reject) { + try { + const hostPromise = hostFn.apply(null, vmArgs); + hostPromise.then( + function (value) { resolve(value); }, + function (hostErr) { + const msg = (hostErr && hostErr.message != null) + ? String(hostErr.message) + : String(hostErr); + reject(new Error(msg)); + } + ); + } catch (hostErr) { + const msg = (hostErr && hostErr.message != null) + ? String(hostErr.message) + : String(hostErr); + reject(new Error(msg)); + } + }); + }; + } + + // --- phase / log --- + globalThis.phase = function phase(title) { + __b.pushPhase(String(title)); + }; + globalThis.log = function log(msg) { + __b.pushLog(msg); + }; + + // --- console (object with hardened methods, all in vm-realm) --- + const safeConsole = Object.create(null); + safeConsole.log = function () { + const parts = []; + for (let i = 0; i < arguments.length; i++) parts.push(String(arguments[i])); + __b.pushLog(parts.join(' ')); + }; + safeConsole.warn = safeConsole.log; + safeConsole.error = safeConsole.log; + globalThis.console = safeConsole; + + // --- agent (with runtime allowlist + named throws, all vm-realm) --- + // FIX-Round1-T13: throw on any opts key not in the allowlist — catches + // typos like { scema: ... } that previously slipped through the + // [key:string]: unknown index signature. + const KNOWN_AGENT_OPTS = ['label', 'phase', 'schema', 'model', 'isolation', 'agentType']; + globalThis.agent = vmAsync(function (prompt, agentOpts) { + agentOpts = agentOpts || {}; + const keys = Object.keys(agentOpts); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (KNOWN_AGENT_OPTS.indexOf(k) === -1) { + throw new Error( + "agent({" + k + "}): unknown option. " + + "Known options are: " + KNOWN_AGENT_OPTS.join(', ') + "." + ); + } + } + if (agentOpts.schema !== undefined) { + throw new Error( + 'agent({schema}) is not supported in P1. ' + + 'Schema enforcement / StructuredOutput contract is scheduled for P3.' + ); + } + if (agentOpts.isolation !== undefined) { + throw new Error( + "agent({isolation: '" + agentOpts.isolation + "'}) is not supported in P1. " + + 'Worktree / remote isolation is scheduled for a later phase.' + ); + } + if (agentOpts.model !== undefined) { + throw new Error( + 'agent({model}) is not supported in P1. Model override is scheduled for a later phase.' + ); + } + if (agentOpts.agentType !== undefined) { + throw new Error('agent({agentType}) is not supported in P1.'); + } + if (typeof agentOpts.phase === 'string' && agentOpts.phase.length > 0) { + if (__b.lastPhase() !== agentOpts.phase) { + __b.pushPhase(agentOpts.phase); + } + } + return __b.hostAgent(prompt, agentOpts); + }); + + // --- parallel / pipeline --- + if (__b.hasParallel) { + globalThis.parallel = vmAsync(function (thunks) { + return __b.hostParallel(thunks); + }); + } else { + globalThis.parallel = function parallel() { + return new Promise(function (_, reject) { + reject(new Error( + 'parallel() is not supported in P1. Sequential agent() is the only ' + + 'execution mode in P1. Concurrent fan-out is scheduled for P2.' + )); + }); + }; + } + if (__b.hasPipeline) { + globalThis.pipeline = vmAsync(function (items) { + const stages = []; + for (let i = 1; i < arguments.length; i++) stages.push(arguments[i]); + return __b.hostPipeline.apply(null, [items].concat(stages)); + }); + } else { + globalThis.pipeline = function pipeline() { + return new Promise(function (_, reject) { + reject(new Error( + 'pipeline() is not supported in P1. Staggered multi-stage execution ' + + 'is scheduled for P2.' + )); + }); + }; + } + // workflow() always throws in P1. + globalThis.workflow = function workflow() { + return new Promise(function (_, reject) { + reject(new Error( + 'workflow() (nested workflow invocation) is not supported in P1. ' + + 'Scheduled for a later phase.' + )); + }); + }; + + // --- budget --- + const safeBudget = Object.create(null); + Object.defineProperty(safeBudget, 'total', { + value: __b.budgetTotal, + writable: false, configurable: false, + }); + if (__b.hasBudget) { + Object.defineProperty(safeBudget, 'spent', { + value: function spent() { return __b.hostBudgetSpent(); }, + writable: false, configurable: false, + }); + Object.defineProperty(safeBudget, 'remaining', { + value: function remaining() { return __b.hostBudgetRemaining(); }, + writable: false, configurable: false, + }); + } else { + Object.defineProperty(safeBudget, 'spent', { + value: function spent() { + throw new Error( + 'budget.spent() is not supported in P1. Token tracking is scheduled for P5.' + ); + }, + writable: false, configurable: false, + }); + Object.defineProperty(safeBudget, 'remaining', { + value: function remaining() { + throw new Error( + 'budget.remaining() is not supported in P1. Token tracking is scheduled for P5.' + ); + }, + writable: false, configurable: false, + }); + } + globalThis.budget = safeBudget; + })();`, + ctx, + { filename: 'workflow-sandbox-init.js' }, + ); + + const maxWallClockMs = resolveMaxWallClockMs(opts); + + return { + async run(scriptSource: string): Promise { + const stripped = stripExportMeta(scriptSource); + const wrapped = `(async () => {\n${stripped}\n})()`; + const script = new vm.Script(wrapped, { + filename: 'workflow.js', + }); + // 30s sync wall-clock cap inside vm — covers `while(true){}` style + // synchronous loops only. Once the IIFE hits its first `await`, + // `runInContext` returns and this timer is disarmed. + const runOpts: vm.RunningScriptOptions = { + timeout: 30_000, + }; + const result = script.runInContext(ctx, runOpts) as Promise; + + // T23 (PR #4732 R2): async wall-clock cap covers everything past the + // first await — `return new Promise(() => {})`, async infinite loops, + // hung network calls — none of which the vm timeout or future P5 + // budget can stop (a 0-token hang spends no budget). Permanent + // defense-in-depth; default 30 min, env-tunable. + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + // T40 (PR #4732 R4): abort linked controller BEFORE rejecting so + // in-flight subagents see the cancellation and stop. Order + // matters: rejecting first then aborting would race the + // caller's finally block. + opts.abortOnTimeout?.abort(); + reject( + new Error( + `Workflow execution timed out after ${maxWallClockMs} ms wall clock. ` + + 'Override via SandboxOptions.maxWallClockMs or QWEN_CODE_MAX_WORKFLOW_SECONDS env var.', + ), + ); + }, maxWallClockMs); + // Don't keep the event loop alive on Node — if the run resolves + // quickly, the timer will be cleared in finally; this guards against + // edge cases where the caller drops the promise. + timer.unref?.(); + }); + try { + return await Promise.race([result, timeoutPromise]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }, + getPhases: () => [...phases], + getLogs: () => [...logs], + }; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fae6fed387..0b507054d3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -759,6 +759,7 @@ export interface ConfigParameters { experimentalZedIntegration?: boolean; cronEnabled?: boolean; forkSubagentEnabled?: boolean; + workflowsEnabled?: boolean; computerUseEnabled?: boolean; emitToolUseSummaries?: boolean; listExtensions?: boolean; @@ -1144,6 +1145,7 @@ export class Config { private readonly experimentalZedIntegration: boolean = false; private readonly cronEnabled: boolean = false; private readonly forkSubagentEnabled: boolean = false; + private workflowsEnabled = false; private readonly computerUseEnabled: boolean = true; private readonly emitToolUseSummaries: boolean = true; private readonly chatRecordingEnabled: boolean; @@ -1328,6 +1330,7 @@ export class Config { params.experimentalZedIntegration ?? false; this.cronEnabled = params.cronEnabled ?? false; this.forkSubagentEnabled = params.forkSubagentEnabled ?? false; + this.workflowsEnabled = params.workflowsEnabled ?? false; this.computerUseEnabled = params.computerUseEnabled ?? true; this.emitToolUseSummaries = params.emitToolUseSummaries ?? true; this.listExtensions = params.listExtensions ?? false; @@ -3318,6 +3321,19 @@ export class Config { if (process.env['QWEN_CODE_ENABLE_FORK_SUBAGENT'] === '1') return true; return this.forkSubagentEnabled; } + + isWorkflowsEnabled(): boolean { + // Workflows are experimental and opt-in: enabled via settings or env var + // P1 also honors a kill switch: QWEN_CODE_DISABLE_WORKFLOWS=1 forces off + if (process.env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false; + if (process.env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1') return true; + return this.workflowsEnabled; + } + + setWorkflowsEnabled(enabled: boolean): void { + this.workflowsEnabled = enabled; + } + isComputerUseEnabled(): boolean { return this.computerUseEnabled; } @@ -4240,6 +4256,14 @@ export class Config { }); } + // Register workflow tool when enabled + if (this.isWorkflowsEnabled()) { + await registerLazy(ToolNames.WORKFLOW, async () => { + const { WorkflowTool } = await import('../tools/workflow/workflow.js'); + return new WorkflowTool(this); + }); + } + // Register computer-use tools unless disabled. All 9 are deferred — // they surface only via ToolSearch keyword match // (see packages/core/src/tools/computer-use/). diff --git a/packages/core/src/config/config.workflow-registration.test.ts b/packages/core/src/config/config.workflow-registration.test.ts new file mode 100644 index 0000000000..155f68b8ff --- /dev/null +++ b/packages/core/src/config/config.workflow-registration.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// Shared mocks needed by Config constructor. +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('../telemetry/index.js', () => ({ + QwenLogger: vi.fn().mockImplementation(() => ({ + logStartSessionEvent: vi.fn().mockResolvedValue(undefined), + logEndSessionEvent: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + DEFAULT_TELEMETRY_TARGET: 'none', + DEFAULT_OTLP_ENDPOINT: '', + isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), + shutdownTelemetry: vi.fn().mockResolvedValue(undefined), + refreshSessionContext: vi.fn(), +})); +vi.mock('../core/contentGenerator.js', () => ({ + resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({ + config: { model: 'test-model', apiKey: 'test-key' }, + sources: {}, + }), + createContentGeneratorConfig: vi.fn().mockReturnValue({}), + createContentGenerator: vi.fn().mockReturnValue({}), + AuthType: { API_KEY: 'apiKey' }, +})); +vi.mock('../core/baseLlmClient.js'); +vi.mock('../core/toolHookTriggers.js', () => ({ + fireNotificationHook: vi.fn().mockResolvedValue({}), +})); +vi.mock('../services/skillManager.js', () => { + const SkillManagerMock = vi.fn(); + SkillManagerMock.prototype.startWatching = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.refreshCache = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.stopWatching = vi.fn(); + SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]); + SkillManagerMock.prototype.addChangeListener = vi.fn(); + SkillManagerMock.prototype.removeChangeListener = vi.fn(); + SkillManagerMock.prototype.matchAndActivateByPath = vi + .fn() + .mockResolvedValue([]); + SkillManagerMock.prototype.matchAndActivateByPaths = vi + .fn() + .mockResolvedValue([]); + return { SkillManager: SkillManagerMock }; +}); +vi.mock('../subagents/subagent-manager.js', () => { + const SubagentManagerMock = vi.fn(); + SubagentManagerMock.prototype.loadSessionSubagents = vi.fn(); + SubagentManagerMock.prototype.addChangeListener = vi + .fn() + .mockReturnValue(() => {}); + SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]); + return { SubagentManager: SubagentManagerMock }; +}); +vi.mock('../ide/ide-client.js', () => ({ + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, +})); +vi.mock('../memory/const.js', () => ({ + setGeminiMdFilename: vi.fn(), +})); + +import * as fs from 'node:fs'; +import type { ConfigParameters } from './config.js'; +import { Config } from './config.js'; +import { ToolNames } from '../tools/tool-names.js'; + +const baseParams: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp', + debugMode: false, + model: 'test-model', + telemetry: { enabled: false }, + usageStatisticsEnabled: false, + overrideExtensions: [], +}; + +describe('WorkflowTool registration', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readdirSync as Mock).mockReturnValue([]); + (fs.statSync as Mock).mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => String(p)); + (fs.mkdirSync as Mock).mockImplementation(() => undefined); + (fs.writeFileSync as Mock).mockImplementation(() => undefined); + (fs.renameSync as Mock).mockImplementation(() => undefined); + (fs.copyFileSync as Mock).mockImplementation(() => undefined); + (fs.unlinkSync as Mock).mockImplementation(() => undefined); + (fs.readFileSync as Mock).mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const key of [ + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', + ]) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + it('is NOT registered when isWorkflowsEnabled() is false', async () => { + delete process.env['QWEN_CODE_ENABLE_WORKFLOWS']; + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + const cfg = new Config({ ...baseParams }); + const registry = await cfg.createToolRegistry(undefined, { + skipDiscovery: true, + }); + expect(registry.getAllToolNames()).not.toContain(ToolNames.WORKFLOW); + }); + + it('is registered when isWorkflowsEnabled() is true', async () => { + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1'; + const cfg = new Config({ ...baseParams }); + const registry = await cfg.createToolRegistry(undefined, { + skipDiscovery: true, + }); + expect(registry.getAllToolNames()).toContain(ToolNames.WORKFLOW); + }); + + it('QWEN_CODE_DISABLE_WORKFLOWS=1 overrides enable-via-settings', async () => { + process.env['QWEN_CODE_DISABLE_WORKFLOWS'] = '1'; + delete process.env['QWEN_CODE_ENABLE_WORKFLOWS']; + const cfg = new Config({ ...baseParams }); + cfg.setWorkflowsEnabled(true); + const registry = await cfg.createToolRegistry(undefined, { + skipDiscovery: true, + }); + expect(registry.getAllToolNames()).not.toContain(ToolNames.WORKFLOW); + }); +}); + +// FIX-G (Round 4 test Important): regression test for the FIX-8 anti-recursion +// guard. A subagent spawned BY a workflow must not be able to call the +// Workflow tool again — that would create unbounded O(k^n) fan-out. Without +// this assertion, a rename of `ToolNames.WORKFLOW` or accidental deletion of +// the exclusion entry would silently open the recursion. +describe('Workflow anti-recursion guard', () => { + it('ToolNames.WORKFLOW is in EXCLUDED_TOOLS_FOR_SUBAGENTS', async () => { + const { EXCLUDED_TOOLS_FOR_SUBAGENTS } = await import( + '../agents/runtime/agent-core.js' + ); + expect(EXCLUDED_TOOLS_FOR_SUBAGENTS.has(ToolNames.WORKFLOW)).toBe(true); + }); +}); diff --git a/packages/core/src/config/config.workflows.test.ts b/packages/core/src/config/config.workflows.test.ts new file mode 100644 index 0000000000..36b4516190 --- /dev/null +++ b/packages/core/src/config/config.workflows.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// Shared mocks needed by Config constructor. +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('../telemetry/index.js', () => ({ + QwenLogger: vi.fn().mockImplementation(() => ({ + logStartSessionEvent: vi.fn().mockResolvedValue(undefined), + logEndSessionEvent: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + })), + DEFAULT_TELEMETRY_TARGET: 'none', + DEFAULT_OTLP_ENDPOINT: '', + isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), + shutdownTelemetry: vi.fn().mockResolvedValue(undefined), + refreshSessionContext: vi.fn(), +})); +vi.mock('../core/contentGenerator.js', () => ({ + resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({ + config: { model: 'test-model', apiKey: 'test-key' }, + sources: {}, + }), + createContentGeneratorConfig: vi.fn().mockReturnValue({}), + createContentGenerator: vi.fn().mockReturnValue({}), + AuthType: { API_KEY: 'apiKey' }, +})); +vi.mock('../core/baseLlmClient.js'); +vi.mock('../core/toolHookTriggers.js', () => ({ + fireNotificationHook: vi.fn().mockResolvedValue({}), +})); +vi.mock('../services/skillManager.js', () => { + const SkillManagerMock = vi.fn(); + SkillManagerMock.prototype.startWatching = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.refreshCache = vi + .fn() + .mockResolvedValue(undefined); + SkillManagerMock.prototype.stopWatching = vi.fn(); + SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]); + SkillManagerMock.prototype.addChangeListener = vi.fn(); + SkillManagerMock.prototype.removeChangeListener = vi.fn(); + SkillManagerMock.prototype.matchAndActivateByPath = vi + .fn() + .mockResolvedValue([]); + SkillManagerMock.prototype.matchAndActivateByPaths = vi + .fn() + .mockResolvedValue([]); + return { SkillManager: SkillManagerMock }; +}); +vi.mock('../subagents/subagent-manager.js', () => { + const SubagentManagerMock = vi.fn(); + SubagentManagerMock.prototype.loadSessionSubagents = vi.fn(); + SubagentManagerMock.prototype.addChangeListener = vi + .fn() + .mockReturnValue(() => {}); + SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]); + return { SubagentManager: SubagentManagerMock }; +}); +vi.mock('../ide/ide-client.js', () => ({ + IdeClient: { + getInstance: vi.fn().mockResolvedValue({ + getConnectionStatus: vi.fn(), + initialize: vi.fn(), + shutdown: vi.fn(), + }), + }, +})); +vi.mock('../memory/const.js', () => ({ + setGeminiMdFilename: vi.fn(), +})); + +import * as fs from 'node:fs'; +import type { ConfigParameters } from './config.js'; +import { Config } from './config.js'; + +const baseParams: ConfigParameters = { + cwd: '/tmp', + targetDir: '/tmp', + debugMode: false, + model: 'test-model', + telemetry: { enabled: false }, + usageStatisticsEnabled: false, + overrideExtensions: [], +}; + +describe('Config workflows feature gate', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + (fs.existsSync as Mock).mockReturnValue(true); + (fs.readdirSync as Mock).mockReturnValue([]); + (fs.statSync as Mock).mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => String(p)); + (fs.mkdirSync as Mock).mockImplementation(() => undefined); + (fs.writeFileSync as Mock).mockImplementation(() => undefined); + (fs.renameSync as Mock).mockImplementation(() => undefined); + (fs.copyFileSync as Mock).mockImplementation(() => undefined); + (fs.unlinkSync as Mock).mockImplementation(() => undefined); + (fs.readFileSync as Mock).mockImplementation(() => undefined); + }); + + afterEach(() => { + // Restore env vars touched by tests + for (const key of [ + 'QWEN_CODE_ENABLE_WORKFLOWS', + 'QWEN_CODE_DISABLE_WORKFLOWS', + ]) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + it('defaults to disabled', () => { + delete process.env['QWEN_CODE_ENABLE_WORKFLOWS']; + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + const cfg = new Config({ ...baseParams }); + expect(cfg.isWorkflowsEnabled()).toBe(false); + }); + + it('respects QWEN_CODE_ENABLE_WORKFLOWS=1', () => { + delete process.env['QWEN_CODE_DISABLE_WORKFLOWS']; + process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1'; + const cfg = new Config({ ...baseParams }); + expect(cfg.isWorkflowsEnabled()).toBe(true); + }); + + // TST-I1: "respects setWorkflowsEnabled(true)" was deleted — it is a + // getter/setter tautology that tests no logic. + + it('QWEN_CODE_DISABLE_WORKFLOWS=1 overrides everything', () => { + process.env['QWEN_CODE_DISABLE_WORKFLOWS'] = '1'; + process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1'; + const cfg = new Config({ ...baseParams }); + cfg.setWorkflowsEnabled(true); + expect(cfg.isWorkflowsEnabled()).toBe(false); + }); + + it('honors workflowsEnabled passed via ConfigParameters', () => { + const cfg = new Config({ ...baseParams, workflowsEnabled: true }); + expect(cfg.isWorkflowsEnabled()).toBe(true); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7a29469f6..717098a81d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,10 @@ export type { } from './tools/shell.js'; export type { SkillTool, SkillParams } from './tools/skill.js'; export type { AgentTool, AgentParams } from './tools/agent/agent.js'; +export type { + WorkflowTool, + WorkflowParams, +} from './tools/workflow/workflow.js'; export type { TodoWriteTool, TodoItem, diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index ce443631a5..d918c344ea 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -57,6 +57,7 @@ export const ToolNames = { COMPUTER_USE_TYPE_TEXT: 'computer_use__type_text', COMPUTER_USE_PRESS_KEY: 'computer_use__press_key', COMPUTER_USE_SET_VALUE: 'computer_use__set_value', + WORKFLOW: 'workflow', } as const; /** @@ -101,6 +102,7 @@ export const ToolDisplayNames = { COMPUTER_USE_TYPE_TEXT: 'computer_use__type_text', COMPUTER_USE_PRESS_KEY: 'computer_use__press_key', COMPUTER_USE_SET_VALUE: 'computer_use__set_value', + WORKFLOW: 'Workflow', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts new file mode 100644 index 0000000000..30667dc152 --- /dev/null +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { WorkflowTool } from './workflow.js'; +import type { Config } from '../../config/config.js'; +import { ToolNames, ToolDisplayNames } from '../tool-names.js'; + +function fakeConfig(): Config { + return {} as unknown as Config; +} + +describe('WorkflowTool', () => { + it('has the registered name and display name', () => { + const tool = new WorkflowTool(fakeConfig()); + expect(tool.name).toBe(ToolNames.WORKFLOW); + expect(tool.displayName).toBe(ToolDisplayNames.WORKFLOW); + }); + + it('rejects build() when script is missing', () => { + const tool = new WorkflowTool(fakeConfig()); + expect(() => tool.build({} as never)).toThrow(/script/); + }); + + it('rejects build() when script is empty string', () => { + const tool = new WorkflowTool(fakeConfig()); + expect(() => tool.build({ script: '' })).toThrow(/script/); + }); + + it('build() returns an invocation that exposes the script as description', () => { + const tool = new WorkflowTool(fakeConfig()); + const invocation = tool.build({ + script: 'return 1', + }); + expect(invocation.params.script).toBe('return 1'); + expect(invocation.getDescription()).toContain('workflow'); + }); + + it('getDefaultPermission returns "ask"', async () => { + const tool = new WorkflowTool(fakeConfig()); + const invocation = tool.build({ script: 'return 1' }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + }); + + it('execute() runs the script via WorkflowOrchestrator with injected dispatch and returns a ToolResult', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async (prompt) => `T:${prompt}`, + }); + const invocation = tool.build({ + script: `phase("plan"); + const r = await agent("write hello", { label: "h1" }); + return r;`, + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const text = JSON.stringify(result.llmContent); + expect(text).toContain('T:write hello'); + // FIX-7: llmContent now contains just the result, not the full JSON wrapper. + // The runId should NOT appear in llmContent when the result is a plain string. + // (It does appear in returnDisplay, which we don't test here.) + expect(JSON.stringify(result.returnDisplay)).toMatch(/wf_[0-9a-f]{16}/); + }); + + // TST-C3: execute() should return an error result (not throw) when the script throws. + it('execute() returns an error result when the script throws', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: 'throw new Error("scripted failure")', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('scripted failure'); + expect(JSON.stringify(result.llmContent)).toContain('Workflow failed'); + // T4 (PR #4732 R1): assert the machine-readable error type so a + // refactor removing the field doesn't go uncaught. + expect(result.error!.type).toBe('execution_failed'); + }); + + // T19 (PR #4732 R1): phases / logs accumulated before a script failure + // must be included in the user-visible display so debugging is possible. + it('execute() includes phases + logs in returnDisplay when script fails', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: ` + phase("plan"); + log("computing"); + phase("execute"); + log("about to fail"); + throw new Error("boom"); + `, + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeDefined(); + const display = String(result.returnDisplay); + expect(display).toContain('Workflow failed: boom'); + expect(display).toContain('plan'); + expect(display).toContain('execute'); + expect(display).toContain('computing'); + expect(display).toContain('about to fail'); + }); + + // T12 / T18 (PR #4732 R1): a script that returns a BigInt or a circular + // value must not be reported as a workflow failure — the script ran fine, + // only the post-processing JSON.stringify hit a limitation. + it('execute() degrades gracefully on BigInt return values (success, not failure)', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: 'return 1n + 2n;', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text; + expect(llmText).toMatch(/non-JSON-serializable value of type bigint/); + }); + + it('execute() degrades gracefully on circular return values', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: 'const a = {}; a.self = a; return a;', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text; + expect(llmText).toMatch(/non-JSON-serializable value of type object/); + }); + + // T30 (PR #4732 R3): sibling drift of the R1 T12/T18 fix. llmContent + // already degrades per-field on non-serializable result, but the + // returnDisplay payload (runId + phases + logs + result) used to be + // wrapped in a single JSON.stringify — one bad `result` collapsed the + // entire display to "(display payload not JSON-serializable)", losing + // the runId, the phases, AND the logs. safeStringifyDisplayPayload now + // degrades per-field on the failure path so always-serializable + // metadata survives regardless of which field went bad. + it('execute() preserves runId/phases/logs in returnDisplay when result is non-JSON-serializable', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: 'phase("compute"); const a = {}; a.self = a; return a;', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const display = String(result.returnDisplay); + // runId, the phase, and a result placeholder must all survive. + expect(display).toMatch(/wf_[0-9a-f]{16}/); + expect(display).toContain('compute'); + expect(display).toContain('non-JSON-serializable'); + // The atomic-failure fallback must NOT appear — that would mean the + // whole display payload had thrown. + expect(display).not.toContain('display payload not JSON-serializable'); + }); + + // TST-C3: llmContent must be the unwrapped script return value (FIX-7). + it('execute() strips the JSON wrapper from llmContent (script return is verbatim)', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'ignored', + }); + const invocation = tool.build({ + script: 'return { kind: "report", body: "hello" };', + }); + const result = await invocation.execute(new AbortController().signal); + const llmText = (result.llmContent as Array<{ text: string }>)[0].text; + // The llmText should be the JSON of just the script's return value, + // NOT a wrapper with {runId, result, phases, logs}. + expect(JSON.parse(llmText)).toEqual({ kind: 'report', body: 'hello' }); + }); + + // FIX-C9 (TST-M2): scripts without an explicit `return` resolve to + // undefined. WorkflowTool surfaces a clear placeholder rather than the + // literal string "undefined". + // FIX-G (Round 4 test Minor): args threading through WorkflowTool.build() + // → orchestrator.run() → sandbox. A regression where args is dropped + // (e.g. forgetting to pass `args: this.params.args` to orchestrator.run) + // would go uncaught. + it('execute() threads params.args through to the sandbox args global', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'unused', + }); + const invocation = tool.build({ + script: 'return args.who', + args: { who: 'world' }, + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text; + expect(llmText).toBe('world'); + }); + + it('execute() handles scripts that return undefined (no explicit return)', async () => { + const tool = new WorkflowTool(fakeConfig(), { + dispatch: async () => 'ignored', + }); + const invocation = tool.build({ + script: 'phase("noop"); /* no return */', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text; + expect(llmText).toBe('(workflow returned no value)'); + }); +}); diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts new file mode 100644 index 0000000000..a98b465040 --- /dev/null +++ b/packages/core/src/tools/workflow/workflow.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview WorkflowTool — user-facing tool that executes a workflow script + * via WorkflowOrchestrator (P1: sequential agent dispatch only). + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, + type ToolResultDisplay, + type ToolLocation, +} from '../tools.js'; +import type { ShellExecutionConfig } from '../../services/shellExecutionService.js'; +import { ToolNames, ToolDisplayNames } from '../tool-names.js'; +// FIX-10 (REUSE-I1): import ToolErrorType to use the standard machine-readable +// error code rather than an ad-hoc bare `{ message }` object. +import { ToolErrorType } from '../tool-error.js'; +import type { Config } from '../../config/config.js'; +import { + WorkflowOrchestrator, + WorkflowExecutionError, + createProductionDispatch, + type WorkflowAgentDispatch, +} from '../../agents/runtime/workflow-orchestrator.js'; +import { createChildAbortController } from '../../utils/abortController.js'; + +export interface WorkflowParams { + /** Inline JavaScript source for the workflow. Required in P1. */ + script: string; + /** Optional structured value bound to the `args` global inside the script. */ + args?: unknown; +} + +export interface WorkflowToolOptions { + /** + * Test-only dispatch injection. Production callers should leave this + * undefined so createProductionDispatch wires real AgentHeadless. + */ + dispatch?: WorkflowAgentDispatch; +} + +const WORKFLOW_PARAM_SCHEMA = { + type: 'object', + properties: { + script: { + type: 'string', + description: + 'JavaScript source of the workflow. Wrapped as an async IIFE. ' + + 'May call the injected globals `phase(title)`, `log(msg)`, ' + + '`agent(prompt, { label? })`, and read `args`. ' + + 'P1 ships sequential primitives only — `parallel()` and `pipeline()` ' + + 'arrive in P2. Writing `Promise.all([agent(), agent()])` spawns ' + + 'concurrent subagents that share Config and may race on file edits; ' + + 'prefer `await agent(...)` for ordered execution. ' + + '`Date.now()` and `Math.random()` both throw — workflow scripts ' + + 'must be deterministic for resume. ' + + '`export const meta = {...}` declarations are stripped before execution.', + }, + args: { + description: + 'Optional structured value bound to the `args` global. Pass actual JSON, not a stringified value.', + }, + }, + required: ['script'], +} as const; + +class WorkflowToolInvocation extends BaseToolInvocation< + WorkflowParams, + ToolResult +> { + constructor( + private readonly config: Config, + private readonly toolOptions: WorkflowToolOptions, + params: WorkflowParams, + ) { + super(params); + } + + getDescription(): string { + return `Run a workflow script (${this.params.script.length} chars)`; + } + + override toolLocations(): ToolLocation[] { + return []; + } + + override getDefaultPermission(): Promise<'ask'> { + return Promise.resolve('ask'); + } + + override async execute( + signal: AbortSignal, + _updateOutput?: (output: ToolResultDisplay) => void, + _shellExecutionConfig?: ShellExecutionConfig, + ): Promise { + // T40 (PR #4732 R4): child controller so dispatch sees caller aborts + // AND sandbox.ts wall-clock aborts (see setTimeout handler). + const dispatchController = createChildAbortController(signal); + const dispatch = + this.toolOptions.dispatch ?? + createProductionDispatch(this.config, dispatchController.signal); + const orchestrator = new WorkflowOrchestrator(dispatch); + try { + const outcome = await orchestrator.run({ + script: this.params.script, + args: this.params.args, + abortOnTimeout: dispatchController, + }); + + // FIX-7 (UP-C2): unwrap the script result so the LLM receives the + // script's return value verbatim. The full metadata (runId, phases, + // logs) is preserved in returnDisplay for the UI but does not pad + // the LLM context with bookkeeping noise. + // + // T12 / T18 (PR #4732 R1): defensive serialization. A successful + // workflow whose `return` value is a BigInt, a circular reference, + // or otherwise non-JSON used to be reported as `Workflow failed: + // Converting circular structure to JSON` — the script succeeded but + // the post-processing crashed. Wrap each JSON.stringify in its own + // try/catch with a clear placeholder so a serialization issue + // degrades gracefully instead of masquerading as a run failure. + const llmText = safeStringifyResult(outcome.result); + const displayJson = safeStringifyDisplayPayload({ + runId: outcome.runId, + phases: outcome.phases, + logs: outcome.logs, + result: outcome.result, + }); + + return { + llmContent: [{ text: llmText }], + returnDisplay: '```json\n' + displayJson + '\n```', + }; + } catch (err) { + // FIX-H (Round 5 SEC Minor): surface only the message — never the + // stack frame — to the LLM and the UI. Caller's stderr/debug log + // can still see the full stack via standard logging mechanisms. + // + // Cross-realm `instanceof Error` is false for vm-realm Errors; use + // duck-typed extraction so script-thrown errors aren't coerced to + // their "Error: " toString() form. + const message = extractErrorMessage(err); + // T19 (PR #4732 R1): if the orchestrator preserved phases / logs + // accumulated before the failure, include them in the display so + // the user can see what ran before the error. + const phases = + err instanceof WorkflowExecutionError ? err.phases : undefined; + const logs = err instanceof WorkflowExecutionError ? err.logs : undefined; + const display = + phases || logs + ? `Workflow failed: ${message}\n\n${safeStringifyDisplayPayload({ + phases: phases ?? [], + logs: logs ?? [], + })}` + : `Workflow failed: ${message}`; + return { + llmContent: [{ text: `Workflow failed: ${message}` }], + returnDisplay: display, + // FIX-10 (REUSE-I1): use the standard ToolErrorType.EXECUTION_FAILED + // code so error routing / dashboards can classify workflow failures + // the same way as other execution-time tool errors. + error: { message, type: ToolErrorType.EXECUTION_FAILED }, + }; + } finally { + // T40: cancel any straggler subagent on natural completion. + dispatchController.abort(); + } + } +} + +/** + * T12 / T18 (PR #4732 R1): serialize the script's return value, falling back + * to a clear placeholder on BigInt / circular / non-JSON values so a + * successful workflow is not reported as a failure. + */ +function safeStringifyResult(result: unknown): string { + if (result === undefined) return '(workflow returned no value)'; + if (typeof result === 'string') return result; + try { + return JSON.stringify(result, null, 2); + } catch { + return `(workflow returned a non-JSON-serializable value of type ${typeof result})`; + } +} + +/** + * T30 (PR #4732 R3): degrade per-field instead of all-or-nothing. The + * happy path is one stringify; on failure, walk the top-level keys and + * replace each non-serializable value with a placeholder, then + * re-stringify. This keeps always-serializable metadata (runId, phases, + * logs) visible to the user even when one field (typically `result`) + * carries a BigInt / circular value. Future-proof against new payload + * fields without requiring caller-side special cases. + */ +function safeStringifyDisplayPayload(payload: unknown): string { + try { + return JSON.stringify(payload, null, 2); + } catch { + if (payload && typeof payload === 'object') { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(payload)) { + try { + JSON.stringify(value); + sanitized[key] = value; + } catch { + sanitized[key] = + `(non-JSON-serializable value of type ${typeof value})`; + } + } + try { + return JSON.stringify(sanitized, null, 2); + } catch { + // Fall through to the generic fallback string below. + } + } + return '(display payload not JSON-serializable)'; + } +} + +/** + * Duck-typed extraction so vm-realm Errors (raised inside the sandbox) + * don't coerce to "Error: " via toString(). See workflow-orchestrator.ts + * for the matching helper on the orchestrator side. + */ +function extractErrorMessage(err: unknown): string { + if (err && typeof err === 'object' && 'message' in err) { + const m = (err as { message: unknown }).message; + if (typeof m === 'string') return m; + return String(m); + } + return String(err); +} + +export class WorkflowTool extends BaseDeclarativeTool< + WorkflowParams, + ToolResult +> { + constructor( + private readonly config: Config, + private readonly toolOptions: WorkflowToolOptions = {}, + ) { + super( + ToolNames.WORKFLOW, + ToolDisplayNames.WORKFLOW, + 'Execute a workflow script that orchestrates subagents sequentially. ' + + 'P1 supports `phase`, `log`, and sequential `agent` only. No parallel, ' + + 'no pipeline, no schema, no resume, no background execution. ' + + 'Scripts run in a node:vm sandbox without access to the filesystem or ' + + 'shell; all I/O happens through the spawned agents.', + Kind.Other, + WORKFLOW_PARAM_SCHEMA, + /* isOutputMarkdown */ true, + /* canUpdateOutput */ false, + ); + } + + protected override validateToolParamValues( + params: WorkflowParams, + ): string | null { + if (typeof params.script !== 'string' || params.script.length === 0) { + return 'WorkflowTool: `script` parameter is required and must be a non-empty string.'; + } + return null; + } + + protected createInvocation( + params: WorkflowParams, + ): ToolInvocation { + return new WorkflowToolInvocation(this.config, this.toolOptions, params); + } +}