55 */
66
77import { describe , it , expect } from 'vitest' ;
8+ import * as vm from 'node:vm' ;
89import { REVIEW_BUILTIN_SUBAGENT_TYPE } from '@qwen-code/qwen-code-core' ;
910import {
1011 buildReviewWorkflowScript ,
@@ -17,13 +18,36 @@ import {
1718// Every case below runs the REAL output of `buildReviewWorkflowScript`, so a
1819// roster that serialized wrong would fail these as surely as a broken loop.
1920//
20- // The harness is an analogue of the sandbox, not the sandbox itself:
21- // `createWorkflowSandbox` is not exported from the core package, and importing
22- // it would make this a cross-package change for no gain in what is being
23- // checked — this script's own behaviour. The one assumption is the documented
24- // one: `export const meta = {...}` is stripped and the rest runs as an async
25- // function body. The real runtime executes it end-to-end when the skill is
26- // wired to it.
21+ // The harness mirrors the runtime's execution shape (workflow-sandbox.ts)
22+ // rather than importing it — `createWorkflowSandbox` is not exported from
23+ // the core package, and exporting it for this test would be a cross-package
24+ // change for no gain in what is being checked:
25+ // - the meta block is STRIPPED, never executed: the sandbox parses it as
26+ // a pure literal, so no live `meta` binding may reach the body;
27+ // - the body runs inside the runtime's strict-mode async IIFE wrapper, so
28+ // an undeclared assignment throws here like it throws at dispatch;
29+ // - the body runs in a vm context binding only the globals this harness
30+ // stands in for — a host-only global like `setTimeout` fails here like
31+ // it fails in the sandbox;
32+ // - the `agent` stub applies the runtime's option gates.
33+ // What it does not mirror: the sandbox's throwing Date/Math replacements
34+ // (a fresh vm context has a real Date, so the determinism case asserts on
35+ // the source instead) and the meta literal parser (not exported from the
36+ // core package; the meta block's purity is asserted on the source below).
37+ //
38+ // The runtime's option allowlist (workflow-sandbox.ts): a key outside it is
39+ // a typo the sandbox refuses at dispatch, so it must be refused here.
40+ const KNOWN_AGENT_OPTS = [
41+ 'label' ,
42+ 'phase' ,
43+ 'schema' ,
44+ 'model' ,
45+ 'isolation' ,
46+ 'agentType' ,
47+ 'stallMs' ,
48+ 'workingDir' ,
49+ ] ;
50+
2751async function runScript (
2852 script : string ,
2953 dispatch : ( prompt : string , opts : unknown ) => Promise < unknown > ,
@@ -37,24 +61,51 @@ async function runScript(
3761 const logs : string [ ] = [ ] ;
3862 const phases : string [ ] = [ ] ;
3963
40- const agent = async ( prompt : string , opts : unknown ) => {
41- dispatched . push ( { prompt, opts } ) ;
42- return dispatch ( prompt , opts ) ;
64+ // The runtime's gates: an unknown option is refused, an empty workingDir
65+ // is not "no pin", and workingDir together with isolation is a
66+ // contradiction about who owns the directory's lifetime.
67+ const agent = async ( prompt : string , opts : Record < string , unknown > ) => {
68+ const options = opts ?? { } ;
69+ for ( const key of Object . keys ( options ) ) {
70+ if ( ! KNOWN_AGENT_OPTS . includes ( key ) ) {
71+ throw new Error ( `agent({${ key } }): unknown option.` ) ;
72+ }
73+ }
74+ if ( options [ 'workingDir' ] !== undefined ) {
75+ if (
76+ typeof options [ 'workingDir' ] !== 'string' ||
77+ options [ 'workingDir' ] . trim ( ) . length === 0
78+ ) {
79+ throw new Error ( 'agent({workingDir}): must be a non-empty string.' ) ;
80+ }
81+ if ( options [ 'isolation' ] !== undefined ) {
82+ throw new Error (
83+ 'agent({workingDir, isolation}): incompatible options.' ,
84+ ) ;
85+ }
86+ }
87+ dispatched . push ( { prompt, opts : options } ) ;
88+ return dispatch ( prompt , options ) ;
4389 } ;
4490 // Mirrors the runtime's errors-as-data contract: a thunk that rejects
4591 // becomes a `null` element, and the call itself never rejects.
4692 const parallel = async ( thunks : Array < ( ) => Promise < unknown > > ) =>
4793 Promise . all ( thunks . map ( ( t ) => t ( ) . catch ( ( ) => null ) ) ) ;
94+ const phase = ( title : string ) : void => {
95+ phases . push ( title ) ;
96+ } ;
97+ const log = ( message : string ) : void => {
98+ logs . push ( message ) ;
99+ } ;
48100
49- const body = script . replace ( 'export const meta =' , 'const meta =' ) ;
50- const AsyncFunction = Object . getPrototypeOf ( async ( ) => { } ) . constructor ;
51- const fn = new AsyncFunction ( 'agent' , 'parallel' , 'phase' , 'log' , body ) ;
52- const result = await fn (
53- agent ,
54- parallel ,
55- ( t : string ) => phases . push ( t ) ,
56- ( m : string ) => logs . push ( m ) ,
57- ) ;
101+ // Strip the meta block exactly like the runtime does — it is parsed as a
102+ // pure literal there, never executed, so the body must run with no `meta`
103+ // binding either.
104+ const body = script . slice ( script . indexOf ( '\n};' ) + '\n};' . length ) ;
105+ // The runtime's wrapper: an async IIFE under 'use strict'.
106+ const wrapped = `(async () => {'use strict';\n${ body } \n})()` ;
107+ const context = vm . createContext ( { agent, parallel, phase, log } ) ;
108+ const result : unknown = await new vm . Script ( wrapped ) . runInContext ( context ) ;
58109 return { result, dispatched, logs, phases } ;
59110}
60111
@@ -79,6 +130,11 @@ describe('the generated Step 3A fan-out script', () => {
79130 expect ( script ) . not . toContain ( 'Date.now' ) ;
80131 expect ( script ) . not . toContain ( 'Math.random' ) ;
81132 expect ( script ) . not . toContain ( 'new Date' ) ;
133+ // The sandbox's safeDate throws on these forms too, and the vm harness
134+ // cannot stand in for it — a fresh context carries a working Date.
135+ expect ( script ) . not . toContain ( 'Date.parse' ) ;
136+ expect ( script ) . not . toContain ( 'Date.UTC' ) ;
137+ expect ( script ) . not . toMatch ( / \b D a t e \s * \( / ) ;
82138 } ) ;
83139
84140 it ( 'dispatches every agent in the roster, once each, in one phase' , async ( ) => {
0 commit comments