feat(core): Workflow tool P1 — minimal node:vm sandbox + sequential agent() (#4721) (#4732)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* feat(core): register Workflow tool name (P1)

* feat(core): isWorkflowsEnabled config gate with env-var override (P1)

* feat(core): stripExportMeta helper for workflow sandbox (P1)

* feat(core): createWorkflowSandbox with determinism stubs (P1)

* test(core): cover workflow sandbox phase/log/agent primitives (P1)

* feat(core): WorkflowOrchestrator with injectable dispatch (P1)

* feat(core): WorkflowOrchestrator production dispatch via AgentHeadless (P1)

* feat(core): WorkflowTool wraps WorkflowOrchestrator (P1)

* feat(core): register WorkflowTool behind isWorkflowsEnabled gate (P1)

* feat(core): export WorkflowTool from package index (P1)

* fix(core): harden workflow sandbox + tighten agent() opts surface (P1)

SEC-C1: deep-null-proto + hardenClosure blocks args/closure realm-escape PoC.
SEC-C2: vm 30s timeout kills sync infinite loops.
UP-C1: agent() throws on unsupported opts (schema/isolation/model/agentType).
UP-I1: keep verbatim subagent system prompt comment.
ARCH-C1: thread AbortSignal into buildProductionDispatch → subagent.execute().
UP-C2: llmContent carries script result verbatim; metadata moves to returnDisplay.
SEC-I1: add WORKFLOW to EXCLUDED_TOOLS_FOR_SUBAGENTS to prevent recursive fan-out.
SEC-I2: cap logs[] at 10 000 lines with a truncation marker.
REUSE-I1: use ToolErrorType.EXECUTION_FAILED in workflow error returns.
TST: add security PoC tests, unique-runId, dispatch-rejection, llmContent-unwrap.
TST-I1: remove setter/getter tautology test from config.workflows.test.ts.

* refactor(core): decouple WorkflowOrchestrator from Config (P1)

- Extract WORKFLOW_SUBAGENT_SYSTEM_PROMPT into workflow-prompts.ts
- Lift buildProductionDispatch() into exported createProductionDispatch(config, signal?)
- WorkflowOrchestrator constructor now takes dispatch directly: (dispatch: WorkflowAgentDispatch)
- Remove WorkflowOrchestratorOptions interface
- WorkflowToolOptions.orchestratorOverrides replaced by WorkflowToolOptions.dispatch
- WorkflowToolInvocation.execute() calls createProductionDispatch() when no override is set
- Tests updated: orchestrator tests inject dispatch directly; production-dispatch tests moved to createProductionDispatch describe block

* fix(core): Math proxy hardening, subagent prompt verbatim, Date.now throw, phases cap, test fidelity (P1)

* fix(core): construct Math+Date in vm realm, sever proto chains on injected closures (P1)

* fix(core): sever Array.prototype on args, cap deep-null-proto recursion, consolidate WorkflowAgentResult (P1)

* fix(core): stub parallel/pipeline/workflow/budget globals with P1-unsupported errors

* fix(core): harden budget inner functions, regression-test anti-recursion + args threading

* fix(core): P2/P5 forward-compat injection seams + document error.stack limitation (P1)

* fix(core): vm-realm wrap async sandbox globals + stripExportMeta hardening (PR #4732 R1)

Closes T1/T8/T14: thrown Errors and async-function Promises used to leak the
host Function constructor through their prototype chains. PoC:
  agent('x').constructor.constructor('return process')()
  try { throw } catch(e) { e.constructor.constructor('return process')() }
Build every async/sync global (agent, parallel, pipeline, workflow, budget,
console, phase, log, args) inside the vm-realm via the existing init
script. Host only exposes a primitive bridge that the init script reads
once and deletes from globalThis. Both rejection and resolution paths
cross the boundary as vm-realm values.

Closes T2: deepNullProto used to setPrototypeOf(null) on array args,
breaking for-of / .map / .filter / spread / destructuring. Replaced with
vm-realm JSON.parse of an args string — arrays retain vm-realm
Array.prototype methods.

Closes T13: runtime allowlist on agent() opts catches typos like 'scema'.

Closes T9/T16/T17: stripExportMeta now recognises //, /* */, and regex
literals; throws on unbalanced braces instead of silently returning ''.

Closes T6: validateArgs rejects functions, BigInt, circular refs, and
over-deep nesting (previously functions silently disappeared).

Closes T5: regression test for console.log/warn/error → getLogs routing.

* fix(core): change WorkflowTool export to type-only (PR #4732 R1 T3)

Production callers use Config.createToolRegistry's registerLazy path which
dynamic-imports './tools/workflow/workflow.js'. The barrel export at index.ts
previously forced eager evaluation of the workflow.js → workflow-orchestrator
→ workflow-sandbox → node:vm module chain for every consumer of
@qwen-code/core, even when workflows are disabled.

Sibling tool exports (AgentTool, SkillTool) are type-only; align WorkflowTool
with the same pattern. SDK consumers can still annotate types; instantiation
happens through the registry, not the barrel.

* fix(core): subagent terminateMode + bounded runConfig + disallowedTools + failure context + defensive serialization (PR #4732 R1)

Closes T10: runReasoningLoop returns terminateMode = CANCELLED|MAX_TURNS|
TIMEOUT|ERROR rather than throwing. Without checking it, await agent(...)
resolved to '' on user cancel and the workflow kept looping. Now check
getTerminateMode() after execute() and throw on non-GOAL — mirrors
AgentTool's existing handling.

Closes T11: workflow subagents previously ran with runConfig: {} (no
max_turns / max_time_minutes guard) and tools: ['*'] without
disallowedTools. A single agent() could loop the model indefinitely. Bound
to 50 turns / 10 minutes; add disallowedTools: [SEND_MESSAGE, EXIT_PLAN_MODE]
to mirror upstream Tg8 — defense in depth with the §XmO system prompt.

Closes T19: phases / logs accumulated before a script failure used to be
discarded with the sandbox instance. WorkflowExecutionError carries them
through the rejection so the user-visible display can show what ran.

Closes T12 / T18: defensive serialization. A successful workflow returning
a BigInt or circular value used to be reported as 'Workflow failed:
Converting circular structure to JSON' because JSON.stringify was inside
the try block. safeStringifyResult / safeStringifyDisplayPayload degrade
to a clear placeholder so a serialization issue doesn't masquerade as a
run failure.

Closes T4: regression test for ToolErrorType.EXECUTION_FAILED assertion.

Closes T7: vi as vitest alias removed (now matches every other test file).

* chore(core): add missing @license headers + remove stale config-session-env reference (PR #4732 R1)

Closes T20: 6 of 9 new workflow files were missing the standard @license
Apache-2.0 header. Add Qwen-style header (matches sibling tools/agent/agent.ts
and others) to: workflow-sandbox.ts, workflow-sandbox.test.ts,
workflow-orchestrator.ts, workflow-orchestrator.test.ts,
workflow-prompts.ts, workflow.test.ts.

Closes T21: config.workflows.test.ts and config.workflow-registration.test.ts
both contained 'mirrors config-session-env.test.ts' in a setup comment, but
that file does not exist in the repo. Drop the dangling reference.

* chore(core): clean up stray rebase conflict marker (PR #4732)

* fix(core): sever sandboxGlobals proto + add async wall-clock timeout (PR #4732 R2)

Closes T22: sandboxGlobals was a plain host-realm Object literal. Its
prototype chain reached host Object → host Function → host process,
bypassing every per-global hardening measure. PoC confirmed leak via
`globalThis.constructor.constructor('return process')()` returning
host process before fix. Fix: Object.setPrototypeOf(null) on both
sandboxGlobals and the bridge object before vm.createContext.
Regression tests cover both globalThis.constructor and implicit-this
escape paths.

Closes T23: vm.runInContext timeout only covers synchronous execution.
Once the async IIFE yields its first await, the watchdog disarms and
`return new Promise(() => {})` hangs forever. Fix: wrap in
Promise.race with a wall-clock timeout (default 30 minutes, configurable
via SandboxOptions.maxWallClockMs or QWEN_CODE_MAX_WORKFLOW_SECONDS env
var). This is a permanent defense-in-depth — not P1-only: P2/P3/P5
all add resource caps measured in agent-calls or tokens, but a
0-token / 0-agent hang requires a wall-clock cap.

Documented limitation: an in-script async microtask loop continues
consuming microtasks after the outer wall-clock rejects (node:vm
provides no way to halt async execution). In production the workflow
surface returns the timeout error and the vm context becomes
unreferenced; the leaked microtask loop is a host-process concern
that requires worker_threads-level isolation (out of P1 scope).

* fix(core): pre-sanitize non-serializable result before display payload (PR #4732 R3)

Sibling drift of the R1 T12/T18 fix. safeStringifyResult already degrades
per-field when the script's `result` is a BigInt / circular value, so
llmContent survives. But the success-path display payload wraps
{runId, phases, logs, result} in a single JSON.stringify — one bad
`result` collapsed the whole display to the generic
"(display payload not JSON-serializable)" string and the user lost the
runId (needed for log correlation), the accumulated phases, AND the
logs. Pre-sanitize `result` only; runId / phases / logs are always
serializable.

Add regression test that scripts a circular `result` with a `phase()`
in front: assertions check runId, the phase, and the non-JSON-serializable
placeholder all appear in returnDisplay, and that the atomic-failure
fallback string does NOT appear.

RED at 10:56:23 → fix → GREEN at 10:56:48. 14/14 workflow.test, 109/109
across the workflow test suite, typecheck silent.

* refactor(core): push display-payload per-field fallback into the helper (PR #4732)

Post-R3 /simplify pass. The R3 fix special-cased `result` at the call
site by pre-probing JSON.stringify and substituting a placeholder. Four
review angles (reuse / simplification / efficiency / altitude) all
converged on the same root cause: per-field degradation belongs in
`safeStringifyDisplayPayload`, not duplicated at every caller.

- Altitude: the bug ("all-or-nothing stringify is too coarse") names a
  property of the helper; the fix now lives in the helper. Any new
  payload field that becomes non-serializable in a future round
  (`metrics: bigint`, etc.) is handled without a fresh call-site patch.
- Reuse: the third try/JSON.stringify probe in this file is gone;
  the helper owns the probe.
- Simplification: call site reverts to the pre-R3 clean shape.
- Efficiency: success path is back to one stringify per payload.

Helper behavior:
  happy path  → 1 stringify (unchanged)
  one field fails → walk top-level keys, probe each, replace failing
                    value with `(non-JSON-serializable value of type X)`,
                    re-stringify; total 2 stringifies of payload + N
                    field probes. Fall through to the original generic
                    fallback if the sanitized re-stringify also fails.

R3 regression test (`execute() preserves runId/phases/logs in
returnDisplay when result is non-JSON-serializable`) passes unchanged
— it tests observable behavior, not the implementation site. 109/109
across the workflow suite, typecheck silent.

* fix(core): honest description + meta-strip anchor + wall-clock cancellation + stray gitignore (PR #4732 R4)

Four fixes from R4 review:

T32 (workflow.ts tool description) — P1 description claimed "sequential
only" but `Promise.all([agent(), agent()])` bypasses the claim because
the vm cannot intercept JS built-ins. Rewrite the description to be
honest: P1 ships sequential primitives only (no parallel/pipeline);
Promise.all spawns concurrent subagents that share Config and may race
on file edits. Matches upstream Claude Code behavior — they also expose
Promise.all without enforcement.

T33 (workflow-sandbox.ts stripExportMeta) — drop the `/m` flag on the
anchor regex. With `/m`, a template literal containing
`\nexport const meta = {\n` triggered a false match, and the brace
walker ripped content out of the string body, silently corrupting the
script. Per design intent ("required first statement of every script")
meta must be file-start; anchoring there closes the corruption surface.
Adds two RED-confirmed regression tests (template literal + leading
code) and a sanity test for leading whitespace.

T35 (packages/core/.gitignore) — removed. The `.qwen/computer-use/`
entry was stray scope pollution committed accidentally in R1's license-
header cleanup (`d118c55f8`) and unrelated to the Workflow P1 surface.

T40 (sandbox.ts + orchestrator.ts + workflow.ts) — completes the R2
wall-clock defense. When the timer fires the sandbox now `abort()`s a
caller-supplied AbortController BEFORE rejecting; the controller's
signal is also threaded into `createProductionDispatch`, so in-flight
subagent.execute() calls see the cancellation and stop burning tokens.
Without this, R2's "30 min wall-clock" still let subagents run for up
to their internal `max_time_minutes: 10` after the user-side timed out.

WorkflowTool.execute now derives `dispatchController`, forwards caller
signal abort to it, passes its signal to dispatch and the controller
itself to `orchestrator.run({abortOnTimeout})`. A `finally` block
aborts the controller on natural completion (cancel any straggler
subagent) and detaches the caller-signal listener to avoid leaks.

Adds two sandbox unit tests (RED-confirmed): controller IS aborted on
timeout, controller is NOT aborted on normal completion.

114/114 workflow suite tests pass, typecheck silent.

* refactor(core): use createChildAbortController for T40 dispatch-signal bridge (PR #4732)

Post-R4 /simplify pass. Four review angles converged on one finding:
the T40 manual AbortController-bridging at the call site re-implements
`createChildAbortController` from `packages/core/src/utils/abortController.ts:61`,
which is already the project-idiomatic helper for this exact pattern
(used at agent-headless.ts:231, agent-interactive.ts:157, agent-core.ts:603 & 952).

The replacement collapses 5 lines of imperative listener wiring at
`workflow.ts:execute()` head + 1 line of finally cleanup into a single
`createChildAbortController(signal)` call, plus inherits the helper's
hardenings:

- WeakRef on the parent (so a long-lived caller signal doesn't pin the
  child controller)
- Auto-removal of the parent listener when the child fires (covers
  both the wall-clock-fire path and the natural-completion path)
- Default 50-listener cap via `setMaxListeners`

No behavior change at the API boundary — the wall-clock `abortOnTimeout`
contract and the test assertions for T40's two cases (controller IS
aborted on wall-clock; controller is NOT aborted on normal completion)
all still hold. The /simplify "altitude" finding (push the bridging
into the orchestrator) is deferred — that would change WorkflowOrchestrator's
constructor/run signature and is outside this PR's scope.

Also trims a redundant inline comment at workflow-orchestrator.ts:172
(the `abortOnTimeout: req.abortOnTimeout` line; the field's type
comment at line 71-81 already explains the contract).

114/114 workflow suite tests pass, typecheck silent.

* chore(core): compress over-weight T40 comments after createChildAbortController refactor (PR #4732)

The previous commit moved the bridging logic into the helper, so the
inline comments restating the helper's contract (parent forwarding,
already-aborted fast path, WeakRef, auto-removal) became redundant —
that's the helper's job to document.

Compress to the load-bearing semantics: the child controller sees
both caller-driven and wall-clock-driven aborts, and `finally` cancels
stragglers on normal completion. No code change.

* chore(core): align copyright header to Qwen on 2 PR-new test files (PR #4732 R7 F4)

Both files were derived from a template (the stale `config-session-env.test.ts`
reference cleaned up in R1 T21) and retained the upstream `Copyright 2025
Google LLC` header. The other six new workflow source/test files in this
PR carry `Copyright 2025 Qwen`. Align for same-PR consistency.

Per DragonnZhang R7 F4. No behavior change; header text only.

---------

Co-authored-by: tanzhenxin <tanzhenxing1987@gmail.com>
This commit is contained in:
顾盼 2026-06-10 11:32:46 +08:00 committed by GitHub
parent cc141ffc32
commit 7757d87850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 3112 additions and 0 deletions

View file

@ -100,6 +100,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = 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,
]);
/**

View file

@ -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<string, unknown> = {};
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']);
});
});

View file

@ -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<WorkflowAgentResult>;
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<string>` 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<WorkflowRunOutcome> {
// 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: <msg>" 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);
}

View file

@ -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 <binary> | 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.';

File diff suppressed because it is too large Load diff

View file

@ -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<WorkflowAgentResult>;
/**
* Forward-compatibility injection seams for P2 (parallel / pipeline) and
* P5 (budget). When omitted the sandbox falls back to throwing stubs.
*/
parallel?: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
pipeline?: (
items: unknown[],
...stages: Array<
(prev: unknown, item: unknown, idx: number) => Promise<unknown>
>
) => Promise<unknown[]>;
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<unknown>;
/** 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<object> = 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<string, unknown>)) {
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<unknown> {
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<unknown>;
// 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<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, 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],
};
}

View file

@ -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/).

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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

View file

@ -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)');
});
});

View file

@ -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<ToolResult> {
// 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: <msg>" 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<string, unknown> = {};
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: <msg>" 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<WorkflowParams, ToolResult> {
return new WorkflowToolInvocation(this.config, this.toolOptions, params);
}
}