返回 CodeWhale
vm_tests.rs
根目录 / crates / workflow-js / tests / vm_tests.rs
1 //! End-to-end tests for the Workflow JS runtime against a fake driver.
2
3 use std::sync::Arc;
4 use std::time::Duration;
5
6 use codewhale_workflow_js::testing::{FakeDriver, FakeReply};
7 use codewhale_workflow_js::{
8 ProgressEvent, WORKFLOW_LIFETIME_CAP, WorkflowJsError, WorkflowRunCancel, WorkflowVm,
9 };
10 use serde_json::json;
11
12 async fn run(
13 driver: &Arc<FakeDriver>,
14 source: &str,
15 args: serde_json::Value,
16 ) -> Result<serde_json::Value, WorkflowJsError> {
17 WorkflowVm::new()
18 .run_script(
19 source,
20 args,
21 driver.clone() as Arc<dyn codewhale_workflow_js::WorkflowDriver>,
22 )
23 .await
24 }
25
26 fn script_message(result: Result<serde_json::Value, WorkflowJsError>) -> String {
27 match result {
28 Err(WorkflowJsError::Script(message)) => message,
29 other => panic!("expected script error, got {other:?}"),
30 }
31 }
32
33 #[tokio::test]
34 async fn plain_return_value_round_trips() {
35 let driver = Arc::new(FakeDriver::new());
36 let value = run(&driver, "return 1 + 1;", json!(null)).await.unwrap();
37 assert_eq!(value, json!(2));
38 }
39
40 #[tokio::test]
41 async fn undefined_return_becomes_null() {
42 let driver = Arc::new(FakeDriver::new());
43 let value = run(&driver, "const x = 1;", json!(null)).await.unwrap();
44 assert_eq!(value, json!(null));
45 }
46
47 #[tokio::test]
48 async fn args_global_is_the_invocation_input() {
49 let driver = Arc::new(FakeDriver::new());
50 let value = run(
51 &driver,
52 "return { sum: args.x + 1, tag: args.tags[0] };",
53 json!({"x": 41, "tags": ["release"]}),
54 )
55 .await
56 .unwrap();
57 assert_eq!(value, json!({"sum": 42, "tag": "release"}));
58 }
59
60 #[tokio::test]
61 async fn checked_in_best_of_n_search_recipe_runs_with_structured_receipts() {
62 let driver = Arc::new(FakeDriver::new());
63 for index in 1..=2 {
64 driver.on(
65 // Rules match the driver-visible `TaskRequest.description`, which
66 // is the full instruction text (the VM's `prompt` alias wins over
67 // a short label). Match the unique per-candidate suffix line.
68 &format!("candidate_id=cand_{index:03} of 2."),
69 FakeReply::Complete(
70 json!({
71 "candidate_id": format!("cand_{index:03}"),
72 "hypothesis": "bounded fixture",
73 "modified_paths": ["src/lib.rs"],
74 "commands_run": ["cargo test --locked"],
75 "self_verdict": "pass",
76 "known_risks": [],
77 "artifact_refs": [format!("patch:cand_{index:03}")]
78 })
79 .to_string(),
80 ),
81 );
82 }
83 driver.on(
84 "read-only tournament judge",
85 FakeReply::Complete(
86 json!({
87 "winner_id": "cand_001",
88 "ranking": ["cand_001", "cand_002"],
89 "verification_required": true,
90 "reasons": ["fixture score"]
91 })
92 .to_string(),
93 ),
94 );
95
96 let value = run(
97 &driver,
98 include_str!("../../../workflows/operate_best_of_n.workflow.js"),
99 json!({
100 "brief": "Implement the fixture",
101 "strategy": "search",
102 "n": 2,
103 "writeRoots": ["src"],
104 "model": "deepseek-v4-flash",
105 "thinking": "max"
106 }),
107 )
108 .await
109 .expect("checked-in search recipe should execute");
110
111 assert_eq!(value["scenario"], "operate-search");
112 assert_eq!(value["review"]["winner_id"], "cand_001");
113 assert_eq!(driver.spawn_count(), 3);
114 let requests = driver.requests();
115 assert_eq!(requests[0].model.as_deref(), Some("deepseek-v4-flash"));
116 assert_eq!(requests[0].thinking.as_deref(), Some("max"));
117 assert_eq!(requests[0].write_roots, ["src"]);
118 assert_eq!(requests[2].write_authority.as_deref(), Some("read_only"));
119 // Regression: the driver-visible description is the full instruction text,
120 // so reply rules must target text that actually reaches the driver. If a
121 // future recipe reintroduces a separate short `description` next to a long
122 // `prompt`, these needles stop matching, the FakeDriver falls back to its
123 // non-JSON "done:..." reply, and the structured receipts fail loudly.
124 assert!(
125 requests[0]
126 .description
127 .starts_with("You are one independent candidate")
128 );
129 assert!(
130 requests[0]
131 .description
132 .contains("CANDIDATE-SPECIFIC INSTRUCTION: candidate_id=cand_001 of 2.")
133 );
134 assert!(
135 requests[2]
136 .description
137 .starts_with("You are the read-only tournament judge")
138 );
139 }
140
141 #[tokio::test]
142 async fn task_prompt_wins_over_description_as_driver_visible_text() {
143 let driver = Arc::new(FakeDriver::new());
144 let value = run(
145 &driver,
146 r#"
147 return await task({
148 description: "short progress label",
149 prompt: "the real instruction",
150 });
151 "#,
152 json!(null),
153 )
154 .await
155 .unwrap();
156
157 // No rules were registered, so the FakeDriver fallback echoes the
158 // driver-visible description. The reply text proves the driver received
159 // the prompt, not the short label.
160 assert_eq!(value, json!("done:the real instruction"));
161 let requests = driver.requests();
162 assert_eq!(requests.len(), 1);
163 assert_eq!(requests[0].description, "the real instruction");
164 assert_ne!(requests[0].description, "short progress label");
165 }
166
167 #[tokio::test]
168 async fn task_round_trip_carries_all_options_and_normalizes_profile() {
169 let driver = Arc::new(FakeDriver::new());
170 let value = run(
171 &driver,
172 r#"
173 return await task({
174 description: "implement the bounded change",
175 subagentType: "implementer",
176 profile: " ALpha-1 ",
177 model: "deepseek-chat",
178 modelStrength: "faster",
179 thinking: "low",
180 cwd: "repo-a",
181 worktree: true,
182 writeAuthority: "worktree_write",
183 writeRoots: ["crates/tui/src"],
184 exactFiles: ["Cargo.toml"],
185 coordinationContracts: ["public-api"],
186 dependencies: ["issue-4619"],
187 acceptance: ["locked tests pass"],
188 allowedTools: ["read", "grep"],
189 maxDepth: 2,
190 tokenBudget: 5000,
191 maxSteps: 4,
192 wallTimeSecs: 90,
193 label: "L1",
194 phase: "P1",
195 });
196 "#,
197 json!(null),
198 )
199 .await
200 .unwrap();
201 assert_eq!(value, json!("done:implement the bounded change"));
202
203 let requests = driver.requests();
204 assert_eq!(requests.len(), 1);
205 let request = &requests[0];
206 assert_eq!(request.description, "implement the bounded change");
207 assert_eq!(request.subagent_type.as_deref(), Some("implementer"));
208 assert_eq!(request.profile.as_deref(), Some("alpha-1"));
209 assert_eq!(request.model.as_deref(), Some("deepseek-chat"));
210 assert_eq!(request.model_strength.as_deref(), Some("faster"));
211 assert_eq!(request.thinking.as_deref(), Some("low"));
212 assert_eq!(request.cwd.as_deref(), Some("repo-a"));
213 assert!(request.worktree);
214 assert_eq!(request.write_authority.as_deref(), Some("worktree_write"));
215 assert_eq!(request.write_roots, ["crates/tui/src"]);
216 assert_eq!(request.exact_files, ["Cargo.toml"]);
217 assert_eq!(request.coordination_contracts, ["public-api"]);
218 assert_eq!(request.dependencies, ["issue-4619"]);
219 assert_eq!(request.acceptance, ["locked tests pass"]);
220 assert_eq!(
221 request.allowed_tools.as_deref(),
222 Some(["read".to_string(), "grep".to_string()].as_slice())
223 );
224 assert_eq!(request.max_depth, Some(2));
225 assert_eq!(request.token_budget, Some(5000));
226 assert_eq!(request.max_steps, Some(4));
227 assert_eq!(request.wall_time_secs, Some(90));
228 assert_eq!(request.response_schema, None);
229 assert_eq!(request.label.as_deref(), Some("L1"));
230 assert_eq!(request.phase.as_deref(), Some("P1"));
231 }
232
233 #[tokio::test]
234 async fn task_write_authority_requires_bounded_coordination_scope() {
235 let driver = Arc::new(FakeDriver::new());
236 let error = run(
237 &driver,
238 r#"
239 return await task({
240 prompt: "edit without a claim",
241 type: "implementer",
242 writeAuthority: "workspace_write",
243 });
244 "#,
245 json!(null),
246 )
247 .await
248 .expect_err("unscoped Workflow writer must fail before driver dispatch")
249 .to_string();
250 assert!(error.contains("requires writeRoots"), "{error}");
251 assert!(driver.requests().is_empty());
252 }
253
254 #[tokio::test]
255 async fn task_coordination_lists_deduplicate_with_hard_count_bounds() {
256 let driver = Arc::new(FakeDriver::new());
257 run(
258 &driver,
259 r#"
260 return await task({
261 prompt: "bounded edit",
262 type: "implementer",
263 writeAuthority: "workspace_write",
264 exactFiles: ["src/a.rs", "src/a.rs"],
265 dependencies: ["A", "A"],
266 acceptance: ["tests pass", "tests pass"],
267 });
268 "#,
269 json!(null),
270 )
271 .await
272 .expect("bounded unique coordination values");
273 let request = driver.requests().pop().expect("request");
274 assert_eq!(request.exact_files, ["src/a.rs"]);
275 assert_eq!(request.dependencies, ["A"]);
276 assert_eq!(request.acceptance, ["tests pass"]);
277 }
278
279 #[tokio::test]
280 async fn task_write_paths_normalize_and_reject_escape_spellings() {
281 let driver = Arc::new(FakeDriver::new());
282 run(
283 &driver,
284 r#"return await task({
285 prompt: "bounded edit",
286 type: "implementer",
287 writeRoots: ["./src//", "src"],
288 exactFiles: ["src\\lib.rs"]
289 });"#,
290 json!(null),
291 )
292 .await
293 .expect("normalized repo-relative paths");
294 let request = driver.requests().pop().expect("request");
295 assert_eq!(request.write_roots, ["src"]);
296 assert_eq!(request.exact_files, ["src/lib.rs"]);
297
298 for path in [
299 "../outside",
300 "/tmp/outside",
301 "C:\\outside",
302 "src/../../outside",
303 ] {
304 let driver = Arc::new(FakeDriver::new());
305 let source = format!(
306 "return await task({{ prompt: 'escape', type: 'implementer', writeRoots: [{}] }});",
307 serde_json::to_string(path).expect("path json")
308 );
309 let message = script_message(run(&driver, &source, json!(null)).await);
310 assert!(
311 message.contains("repo-relative") || message.contains("traversal"),
312 "{path}: {message}"
313 );
314 assert!(driver.requests().is_empty());
315 }
316 }
317
318 #[tokio::test]
319 async fn task_explicit_write_roles_fail_closed_without_scope_and_reject_write_escalation() {
320 for source in [
321 r#"return await task({prompt: "no scope", type: "implementer"});"#,
322 r#"return await task({prompt: "no scope", type: "builder"});"#,
323 r#"return await task({prompt: "no scope", type: "general"});"#,
324 r#"return await task({prompt: "no scope", profile: "release-lead"});"#,
325 r#"return await task({prompt: "wrong authority", type: "reviewer", writeAuthority: "workspace_write", writeRoots: ["src"]});"#,
326 r#"return await task({prompt: "wrong authority", type: "scout", writeAuthority: "workspace_write", writeRoots: ["src"]});"#,
327 r#"return await task({prompt: "role conflict", type: "implementer", role: "reviewer", writeRoots: ["src"]});"#,
328 ] {
329 let driver = Arc::new(FakeDriver::new());
330 let message = script_message(run(&driver, source, json!(null)).await);
331 assert!(
332 message.contains("require")
333 || message.contains("cannot")
334 || message.contains("contradictory"),
335 "{message}"
336 );
337 assert!(driver.requests().is_empty());
338 }
339 }
340
341 #[tokio::test]
342 async fn task_implementer_identity_can_be_narrowed_to_read_only_authority() {
343 let driver = Arc::new(FakeDriver::new());
344 let value = run(
345 &driver,
346 r#"return await task({prompt: "verification-only plan", type: "implementer", writeAuthority: "read_only"});"#,
347 json!(null),
348 )
349 .await
350 .expect("read-only authority must safely narrow an implementer identity");
351 assert_eq!(value, json!("done:verification-only plan"));
352 let request = driver.requests().pop().expect("request");
353 assert_eq!(request.subagent_type.as_deref(), Some("implementer"));
354 assert_eq!(request.write_authority.as_deref(), Some("read_only"));
355 assert!(request.write_roots.is_empty());
356 }
357
358 #[tokio::test]
359 async fn task_accepts_prompt_and_type_aliases() {
360 let driver = Arc::new(FakeDriver::new());
361 run(
362 &driver,
363 r#"return await task({ prompt: "aliased", type: "verifier" });"#,
364 json!(null),
365 )
366 .await
367 .unwrap();
368 let request = &driver.requests()[0];
369 assert_eq!(request.description, "aliased");
370 assert_eq!(request.subagent_type.as_deref(), Some("verifier"));
371 }
372
373 #[tokio::test]
374 async fn task_title_alias_routes_to_description() {
375 let driver = Arc::new(FakeDriver::new());
376 run(
377 &driver,
378 r#"return await task({ title: "inspect the release candidate", type: "verifier" });"#,
379 json!(null),
380 )
381 .await
382 .expect("title is accepted as the task description");
383
384 let request = &driver.requests()[0];
385 assert_eq!(request.description, "inspect the release candidate");
386 assert_eq!(request.subagent_type.as_deref(), Some("verifier"));
387 }
388
389 #[tokio::test]
390 async fn task_prompt_takes_precedence_over_short_description() {
391 let driver = Arc::new(FakeDriver::new());
392 run(
393 &driver,
394 r#"return await task({
395 description: "Short progress summary",
396 prompt: "Detailed child instructions",
397 label: "fixture-compatible"
398 });"#,
399 json!(null),
400 )
401 .await
402 .unwrap();
403 let request = &driver.requests()[0];
404 assert_eq!(request.description, "Detailed child instructions");
405 assert_eq!(request.label.as_deref(), Some("fixture-compatible"));
406 }
407
408 #[tokio::test]
409 async fn task_rejects_invalid_profile_tokens() {
410 for bad in ["two words", "a=b", "a\"b", "a`b", " "] {
411 let driver = Arc::new(FakeDriver::new());
412 let source = format!(
413 "return await task({{ description: \"x\", profile: {} }});",
414 serde_json::Value::String(bad.to_string())
415 );
416 let message = script_message(run(&driver, &source, json!(null)).await);
417 assert!(message.contains("profile"), "profile {bad:?}: {message}");
418 assert_eq!(driver.spawn_count(), 0, "invalid profile must not spawn");
419 }
420 }
421
422 #[tokio::test]
423 async fn task_requires_a_description() {
424 let driver = Arc::new(FakeDriver::new());
425 let message = script_message(run(&driver, "return await task({});", json!(null)).await);
426 assert!(message.contains("description"), "{message}");
427 assert_eq!(driver.spawn_count(), 0);
428 }
429
430 #[tokio::test]
431 async fn task_rejects_unknown_option_names() {
432 let driver = Arc::new(FakeDriver::new());
433 let message = script_message(
434 run(
435 &driver,
436 r#"return await task({ description: "x", responseschema: {} });"#,
437 json!(null),
438 )
439 .await,
440 );
441 assert!(message.contains("invalid options"), "{message}");
442 assert_eq!(driver.spawn_count(), 0);
443 }
444
445 #[tokio::test]
446 async fn driver_rejection_is_catchable_in_script() {
447 let driver = Arc::new(FakeDriver::new());
448 driver.on("bad", FakeReply::Reject("admission cap".to_string()));
449 let value = run(
450 &driver,
451 r#"
452 try {
453 await task({ description: "bad idea" });
454 return "no-throw";
455 } catch (err) {
456 return String(err);
457 }
458 "#,
459 json!(null),
460 )
461 .await
462 .unwrap();
463 let text = value.as_str().unwrap();
464 assert!(text.contains("admission cap"), "{text}");
465 }
466
467 #[tokio::test]
468 async fn parallel_fan_out_maps_one_failure_to_null_slot() {
469 let driver = Arc::new(FakeDriver::new());
470 driver.on("beta", FakeReply::Fail("boom".to_string()));
471 let value = run(
472 &driver,
473 r#"
474 return await parallel([
475 () => task({ description: "alpha" }),
476 () => task({ description: "beta" }),
477 () => task({ description: "gamma" }),
478 ]);
479 "#,
480 json!(null),
481 )
482 .await
483 .unwrap();
484 assert_eq!(value, json!(["done:alpha", null, "done:gamma"]));
485 assert_eq!(driver.spawn_count(), 3);
486 }
487
488 #[tokio::test]
489 async fn parallel_logs_a_breadcrumb_when_a_slot_is_dropped_to_null() {
490 // #dogfood 0.8.67: a fan-out slot that fails for a non-schema reason still
491 // resolves to null (documented resilience), but must leave a breadcrumb in
492 // the run log so an operator can see why a slot came back null / nothing
493 // spawned — instead of a silent "completed" with no explanation.
494 let driver = Arc::new(FakeDriver::new());
495 driver.on("beta", FakeReply::Fail("boom".to_string()));
496 let value = run(
497 &driver,
498 r#"
499 return await parallel([
500 () => task({ description: "alpha" }),
501 () => task({ description: "beta" }),
502 ]);
503 "#,
504 json!(null),
505 )
506 .await
507 .unwrap();
508 assert_eq!(value, json!(["done:alpha", null]));
509 assert!(
510 driver.events().iter().any(|event| matches!(
511 event,
512 ProgressEvent::Log { message } if message.contains("dropped a failed slot")
513 )),
514 "a dropped parallel slot should leave a breadcrumb in the run log"
515 );
516 }
517
518 #[tokio::test]
519 async fn parallel_surfaces_response_schema_errors_instead_of_null() {
520 let driver = Arc::new(FakeDriver::new());
521 driver.on(
522 "bad schema",
523 FakeReply::Complete(r#"{"refuted":"yes"}"#.to_string()),
524 );
525
526 let message = script_message(
527 run(
528 &driver,
529 r#"
530 return await parallel([
531 () => task({
532 description: "bad schema",
533 responseSchema: {
534 type: "object",
535 properties: { refuted: { type: "boolean" } },
536 required: ["refuted"],
537 },
538 }),
539 ]);
540 "#,
541 json!(null),
542 )
543 .await,
544 );
545
546 assert!(message.contains("responseSchema validation"), "{message}");
547 assert!(
548 driver.events().iter().any(|event| matches!(
549 event,
550 ProgressEvent::TaskSchemaValidationFailed { message, .. }
551 if message.contains("responseSchema validation")
552 )),
553 "schema validation error should be emitted as workflow progress"
554 );
555 }
556
557 #[tokio::test]
558 async fn pipeline_surfaces_response_schema_errors_instead_of_null() {
559 let driver = Arc::new(FakeDriver::new());
560 driver.on(
561 "bad schema",
562 FakeReply::Complete(r#"{"refuted":"yes"}"#.to_string()),
563 );
564
565 let message = script_message(
566 run(
567 &driver,
568 r#"
569 return await pipeline(
570 ["bad schema"],
571 (description) => task({
572 description,
573 responseSchema: {
574 type: "object",
575 properties: { refuted: { type: "boolean" } },
576 required: ["refuted"],
577 },
578 }),
579 );
580 "#,
581 json!(null),
582 )
583 .await,
584 );
585
586 assert!(message.contains("responseSchema validation"), "{message}");
587 }
588
589 #[tokio::test]
590 async fn parallel_enforces_the_1000_item_cap_without_spawning() {
591 let driver = Arc::new(FakeDriver::new());
592 let value = run(
593 &driver,
594 r#"
595 const thunks = new Array(1001).fill(() => task({ description: "x" }));
596 try {
597 await parallel(thunks);
598 return "no-throw";
599 } catch (err) {
600 return String(err);
601 }
602 "#,
603 json!(null),
604 )
605 .await
606 .unwrap();
607 let text = value.as_str().unwrap();
608 assert!(text.contains("max 1000"), "{text}");
609 assert_eq!(driver.spawn_count(), 0, "cap must reject before any spawn");
610 }
611
612 #[tokio::test]
613 async fn parallel_accepts_exactly_1000_items() {
614 let driver = Arc::new(FakeDriver::new());
615 let value = run(
616 &driver,
617 r#"
618 const thunks = new Array(1000).fill(() => Promise.resolve(1));
619 const results = await parallel(thunks);
620 return results.length;
621 "#,
622 json!(null),
623 )
624 .await
625 .unwrap();
626 assert_eq!(value, json!(1000));
627 }
628
629 #[tokio::test]
630 async fn pipeline_has_no_barrier_between_stages() {
631 let driver = Arc::new(FakeDriver::new());
632 // Item A crawls through stage 1; item B sprints through both stages.
633 driver.on_with_delay(
634 "s1:A",
635 FakeReply::Complete("A1".to_string()),
636 Duration::from_millis(300),
637 );
638 driver.on_with_delay(
639 "s1:B",
640 FakeReply::Complete("B1".to_string()),
641 Duration::from_millis(20),
642 );
643 driver.on_with_delay(
644 "s2:B1",
645 FakeReply::Complete("B2".to_string()),
646 Duration::from_millis(20),
647 );
648 driver.on("s2:A1", FakeReply::Complete("A2".to_string()));
649
650 let value = run(
651 &driver,
652 r#"
653 return await pipeline(
654 ["A", "B"],
655 (v) => task({ description: "s1:" + v }),
656 (v) => task({ description: "s2:" + v }),
657 );
658 "#,
659 json!(null),
660 )
661 .await
662 .unwrap();
663 assert_eq!(value, json!(["A2", "B2"]));
664
665 // B's stage 2 must have been requested while A was still in stage 1 —
666 // per-item chains, no stage barrier.
667 let descriptions = driver.request_descriptions();
668 assert_eq!(descriptions[..2], ["s1:A".to_string(), "s1:B".to_string()]);
669 assert_eq!(
670 descriptions[2], "s2:B1",
671 "expected B to reach stage 2 while A was still in stage 1: {descriptions:?}"
672 );
673 assert_eq!(descriptions[3], "s2:A1");
674 }
675
676 #[tokio::test]
677 async fn pipeline_stage_error_drops_only_that_item() {
678 let driver = Arc::new(FakeDriver::new());
679 driver.on("s1:B", FakeReply::Fail("boom".to_string()));
680 let value = run(
681 &driver,
682 r#"
683 return await pipeline(
684 ["A", "B"],
685 (v) => task({ description: "s1:" + v }),
686 (v) => v + "+2",
687 );
688 "#,
689 json!(null),
690 )
691 .await
692 .unwrap();
693 assert_eq!(value, json!(["done:s1:A+2", null]));
694 }
695
696 #[tokio::test]
697 async fn task_throws_once_budget_spent_reaches_total() {
698 let driver = Arc::new(FakeDriver::new());
699 driver.set_budget(Some(100), 60);
700 let value = run(
701 &driver,
702 r#"
703 let completed = 0;
704 try {
705 while (true) {
706 await task({ description: "chunk " + completed });
707 completed++;
708 }
709 } catch (err) {
710 return { completed, message: String(err) };
711 }
712 "#,
713 json!(null),
714 )
715 .await
716 .unwrap();
717 assert_eq!(value["completed"], json!(2));
718 let message = value["message"].as_str().unwrap();
719 assert!(message.contains("budget exhausted"), "{message}");
720 assert_eq!(driver.spawn_count(), 2);
721 }
722
723 #[tokio::test]
724 async fn budget_globals_reflect_live_driver_snapshots() {
725 let driver = Arc::new(FakeDriver::new());
726 driver.set_budget(Some(1000), 100);
727 let value = run(
728 &driver,
729 r#"
730 const before = budget.remaining();
731 await task({ description: "one" });
732 return {
733 total: budget.total,
734 before,
735 spent: budget.spent(),
736 after: budget.remaining(),
737 };
738 "#,
739 json!(null),
740 )
741 .await
742 .unwrap();
743 assert_eq!(
744 value,
745 json!({"total": 1000, "before": 1000, "spent": 100, "after": 900})
746 );
747 }
748
749 #[tokio::test]
750 async fn unbounded_budget_reads_as_null_total_and_infinite_remaining() {
751 let driver = Arc::new(FakeDriver::new());
752 let value = run(
753 &driver,
754 "return budget.total === null && budget.remaining() === Infinity;",
755 json!(null),
756 )
757 .await
758 .unwrap();
759 assert_eq!(value, json!(true));
760 }
761
762 #[tokio::test]
763 async fn lifetime_cap_throws_on_spawn_attempt_1001() {
764 let driver = Arc::new(FakeDriver::new());
765 let value = run(
766 &driver,
767 r#"
768 let completed = 0;
769 try {
770 for (let i = 0; i < 1001; i++) {
771 await task({ description: "t" + i });
772 completed++;
773 }
774 return "no-throw";
775 } catch (err) {
776 return { completed, message: String(err) };
777 }
778 "#,
779 json!(null),
780 )
781 .await
782 .unwrap();
783 assert_eq!(value["completed"], json!(WORKFLOW_LIFETIME_CAP));
784 let message = value["message"].as_str().unwrap();
785 assert!(message.contains("lifetime agent cap (1000)"), "{message}");
786 assert_eq!(driver.spawn_count(), WORKFLOW_LIFETIME_CAP as usize);
787 }
788
789 #[tokio::test]
790 async fn response_schema_returns_the_parsed_validated_object() {
791 let driver = Arc::new(FakeDriver::new());
792 driver.on(
793 "check",
794 FakeReply::Complete(r#"{"refuted": true, "confidence": 0.9}"#.to_string()),
795 );
796 let value = run(
797 &driver,
798 r#"
799 const verdict = await task({
800 description: "check the claim",
801 responseSchema: {
802 type: "object",
803 properties: { refuted: { type: "boolean" } },
804 required: ["refuted"],
805 },
806 });
807 return verdict.refuted === true ? "refuted" : "upheld";
808 "#,
809 json!(null),
810 )
811 .await
812 .unwrap();
813 assert_eq!(value, json!("refuted"));
814 assert!(driver.requests()[0].response_schema.is_some());
815 }
816
817 #[tokio::test]
818 async fn response_schema_rejects_non_json_replies() {
819 let driver = Arc::new(FakeDriver::new());
820 driver.on(
821 "check",
822 FakeReply::Complete("definitely not json".to_string()),
823 );
824 let message = script_message(
825 run(
826 &driver,
827 r#"
828 return await task({
829 description: "check",
830 responseSchema: { type: "object" },
831 });
832 "#,
833 json!(null),
834 )
835 .await,
836 );
837 assert!(message.contains("not valid JSON"), "{message}");
838 }
839
840 #[tokio::test]
841 async fn response_schema_rejects_schema_violations() {
842 let driver = Arc::new(FakeDriver::new());
843 driver.on(
844 "check",
845 FakeReply::Complete(r#"{"refuted": "yes"}"#.to_string()),
846 );
847 let message = script_message(
848 run(
849 &driver,
850 r#"
851 return await task({
852 description: "check",
853 responseSchema: {
854 type: "object",
855 properties: { refuted: { type: "boolean" } },
856 required: ["refuted"],
857 },
858 });
859 "#,
860 json!(null),
861 )
862 .await,
863 );
864 assert!(message.contains("responseSchema validation"), "{message}");
865 }
866
867 #[tokio::test]
868 async fn determinism_ban_date_now() {
869 let driver = Arc::new(FakeDriver::new());
870 let message = script_message(run(&driver, "return Date.now();", json!(null)).await);
871 assert!(message.contains("Date.now()"), "{message}");
872 }
873
874 #[tokio::test]
875 async fn determinism_ban_math_random() {
876 let driver = Arc::new(FakeDriver::new());
877 let message = script_message(run(&driver, "return Math.random();", json!(null)).await);
878 assert!(message.contains("Math.random()"), "{message}");
879 }
880
881 #[tokio::test]
882 async fn determinism_ban_new_date() {
883 let driver = Arc::new(FakeDriver::new());
884 let message = script_message(run(&driver, "return new Date();", json!(null)).await);
885 assert!(message.contains("unavailable"), "{message}");
886 }
887
888 /// Explicit product surface for the sandboxed Workflow VM (#4129).
889 ///
890 /// Only these Workflow-owned calls may exist on `globalThis` beyond standard
891 /// ECMAScript intrinsics. If a new host global is intentionally added, update
892 /// this list in the same PR — the fail-closed inventory test below will break
893 /// until the allowlist is extended deliberately.
894 const WORKFLOW_ALLOWED_GLOBALS: &[&str] = &[
895 "task", "parallel", "pipeline", "phase", "log", "budget", "args",
896 ];
897
898 /// Host / Node / Deno / browser surfaces that must never leak into the VM.
899 ///
900 /// Standard ECMAScript intrinsics (`Object`, `Function`, `eval`, `Promise`, …)
901 /// remain available; this list is only host escape hatches.
902 const SANDBOX_BANNED_GLOBALS: &[&str] = &[
903 "process",
904 "require",
905 "module",
906 "exports",
907 "__dirname",
908 "__filename",
909 "Buffer",
910 "fs",
911 "child_process",
912 "os",
913 "path",
914 "net",
915 "http",
916 "https",
917 "fetch",
918 "XMLHttpRequest",
919 "WebSocket",
920 "Deno",
921 "Bun",
922 "Worker",
923 ];
924
925 #[tokio::test]
926 async fn sandbox_exposes_only_the_documented_workflow_calls() {
927 let driver = Arc::new(FakeDriver::new());
928 let value = run(
929 &driver,
930 r#"
931 return {
932 task: typeof task,
933 parallel: typeof parallel,
934 pipeline: typeof pipeline,
935 phase: typeof phase,
936 log: typeof log,
937 budget: typeof budget,
938 args: typeof args,
939 };
940 "#,
941 json!({"ok": true}),
942 )
943 .await
944 .unwrap();
945 assert_eq!(
946 value,
947 json!({
948 "task": "function",
949 "parallel": "function",
950 "pipeline": "function",
951 "phase": "function",
952 "log": "function",
953 "budget": "object",
954 "args": "object",
955 })
956 );
957 // Keep the constant and the live typeof probe in lockstep.
958 assert_eq!(
959 WORKFLOW_ALLOWED_GLOBALS,
960 &[
961 "task", "parallel", "pipeline", "phase", "log", "budget", "args"
962 ]
963 );
964 }
965
966 #[tokio::test]
967 async fn sandbox_blocks_host_filesystem_shell_network_and_env_surfaces() {
968 // Each probe must either throw / reject or resolve to a clearly absent
969 // binding. We never allow a successful host escape.
970 let probes: &[(&str, &str)] = &[
971 (
972 "process.env",
973 r#"
974 if (typeof process !== "undefined") {
975 return process.env;
976 }
977 throw new Error("process is unavailable");
978 "#,
979 ),
980 (
981 "require('fs')",
982 r#"
983 if (typeof require === "function") {
984 return require("fs");
985 }
986 throw new Error("require is unavailable");
987 "#,
988 ),
989 (
990 "import",
991 r#"
992 // Dynamic import is a module-loader surface; the VM has no loader.
993 return await import("fs");
994 "#,
995 ),
996 (
997 "fetch",
998 r#"
999 if (typeof fetch === "function") {
1000 return await fetch("https://example.invalid/");
1001 }
1002 throw new Error("fetch is unavailable");
1003 "#,
1004 ),
1005 (
1006 "child_process",
1007 r#"
1008 if (typeof require === "function") {
1009 return require("child_process");
1010 }
1011 if (typeof child_process !== "undefined") {
1012 return child_process;
1013 }
1014 throw new Error("child_process is unavailable");
1015 "#,
1016 ),
1017 (
1018 "Deno.env",
1019 r#"
1020 if (typeof Deno !== "undefined") {
1021 return Deno.env.toObject();
1022 }
1023 throw new Error("Deno is unavailable");
1024 "#,
1025 ),
1026 ];
1027
1028 for (label, source) in probes {
1029 let driver = Arc::new(FakeDriver::new());
1030 let result = run(&driver, source, json!(null)).await;
1031 assert!(
1032 result.is_err(),
1033 "sandbox probe `{label}` must fail closed, got {result:?}"
1034 );
1035 // No driver side-effect is expected from a sandbox probe.
1036 assert_eq!(
1037 driver.spawn_count(),
1038 0,
1039 "probe `{label}` must not spawn tasks"
1040 );
1041 }
1042 }
1043
1044 #[tokio::test]
1045 async fn sandbox_global_inventory_fails_closed_on_new_host_leaks() {
1046 let driver = Arc::new(FakeDriver::new());
1047 let value = run(
1048 &driver,
1049 r#"
1050 // Own enumerable + non-enumerable names on the global object.
1051 // Anything beyond standard ECMAScript + the Workflow allowlist is a
1052 // regression that must break this test so new leaks cannot land quietly.
1053 const names = Reflect.ownKeys(globalThis)
1054 .map((k) => String(k))
1055 .sort();
1056 return names;
1057 "#,
1058 json!(null),
1059 )
1060 .await
1061 .unwrap();
1062 let names: Vec<String> = serde_json::from_value(value).expect("name list is a JSON array");
1063
1064 // Fail closed: none of the banned host surfaces may appear.
1065 for banned in SANDBOX_BANNED_GLOBALS {
1066 assert!(
1067 !names.iter().any(|n| n == *banned),
1068 "banned global `{banned}` leaked into the Workflow VM: {names:?}"
1069 );
1070 }
1071
1072 // Every Workflow-owned call must still be present.
1073 for allowed in WORKFLOW_ALLOWED_GLOBALS {
1074 assert!(
1075 names.iter().any(|n| n == *allowed),
1076 "expected Workflow global `{allowed}` missing from inventory: {names:?}"
1077 );
1078 }
1079
1080 // Internal host helpers must not be script-visible.
1081 for internal in [
1082 "__workflow_task",
1083 "__workflow_log",
1084 "__workflow_phase",
1085 "__workflow_budget_total",
1086 "__workflow_budget_spent",
1087 "__workflow_budget_remaining",
1088 ] {
1089 assert!(
1090 !names.iter().any(|n| n == internal),
1091 "internal host binding `{internal}` must stay hidden: {names:?}"
1092 );
1093 }
1094 }
1095
1096 #[tokio::test]
1097 async fn sandbox_rejects_commonjs_module_loader_and_eval_style_constructors() {
1098 let driver = Arc::new(FakeDriver::new());
1099 // `eval` / `Function` are standard ES, but if they are present they must
1100 // still be unable to reach host modules. The banned-global inventory above
1101 // already fails closed if Node-style loaders appear; this probe documents
1102 // the intended product message for module load attempts.
1103 let message = script_message(
1104 run(
1105 &driver,
1106 r#"
1107 if (typeof require === "function") {
1108 return require("node:fs");
1109 }
1110 throw new Error("require is unavailable");
1111 "#,
1112 json!(null),
1113 )
1114 .await,
1115 );
1116 assert!(
1117 message.contains("unavailable") || message.contains("require"),
1118 "{message}"
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn dropping_the_run_future_cancels_outstanding_tasks() {
1124 let driver = Arc::new(FakeDriver::new());
1125 driver.on("hang", FakeReply::Never);
1126 let vm = WorkflowVm::new();
1127 {
1128 let fut = vm.run_script(
1129 "await task({ description: 'hang forever' }); return 'unreachable';",
1130 json!(null),
1131 driver.clone() as Arc<dyn codewhale_workflow_js::WorkflowDriver>,
1132 );
1133 let outcome = tokio::time::timeout(Duration::from_millis(400), fut).await;
1134 assert!(outcome.is_err(), "run should still be pending at timeout");
1135 // The timed-out future is dropped here.
1136 }
1137 assert!(
1138 driver.cancel_all_calls() >= 1,
1139 "dropping the run future must cancel outstanding driver tasks"
1140 );
1141 assert_eq!(driver.spawn_count(), 1);
1142 }
1143
1144 #[tokio::test]
1145 async fn parallel_does_not_continue_after_external_run_cancellation() {
1146 let driver = Arc::new(FakeDriver::new());
1147 driver.on("hang", FakeReply::Never);
1148 let cancel = WorkflowRunCancel::new();
1149 let run_cancel = cancel.clone();
1150 let run_driver = driver.clone();
1151 let handle = tokio::spawn(async move {
1152 WorkflowVm::new()
1153 .run_script_with_cancel(
1154 r#"
1155 await parallel([() => task({ description: "hang" })]);
1156 phase("unreachable after cancellation");
1157 return "wrong";
1158 "#,
1159 json!(null),
1160 run_driver as Arc<dyn codewhale_workflow_js::WorkflowDriver>,
1161 run_cancel,
1162 )
1163 .await
1164 });
1165
1166 tokio::time::timeout(Duration::from_secs(2), async {
1167 while driver.spawn_count() == 0 {
1168 tokio::task::yield_now().await;
1169 }
1170 })
1171 .await
1172 .expect("task should start");
1173 cancel.cancel();
1174
1175 let result = handle.await.expect("VM task should join");
1176 assert!(
1177 matches!(result, Err(WorkflowJsError::Cancelled)),
1178 "{result:?}"
1179 );
1180 assert!(
1181 !driver.events().iter().any(|event| matches!(
1182 event,
1183 ProgressEvent::Phase { title } if title == "unreachable after cancellation"
1184 )),
1185 "parallel() must not downgrade run cancellation into a null slot"
1186 );
1187 }
1188
1189 #[tokio::test]
1190 async fn script_error_rejects_cleanly_and_cancels_children() {
1191 let driver = Arc::new(FakeDriver::new());
1192 let result = run(
1193 &driver,
1194 r#"await task({ description: "quick" }); throw new Error("boom");"#,
1195 json!(null),
1196 )
1197 .await;
1198 let message = script_message(result);
1199 assert!(message.contains("boom"), "{message}");
1200 assert!(
1201 driver.cancel_all_calls() >= 1,
1202 "a failed run must cancel its cascade"
1203 );
1204 }
1205
1206 #[tokio::test]
1207 async fn log_and_phase_events_reach_the_driver_in_order() {
1208 let driver = Arc::new(FakeDriver::new());
1209 run(
1210 &driver,
1211 r#"
1212 phase("scan");
1213 log("a");
1214 log({ found: 2 });
1215 phase("verify");
1216 log("b");
1217 return null;
1218 "#,
1219 json!(null),
1220 )
1221 .await
1222 .unwrap();
1223 assert_eq!(
1224 driver.events(),
1225 vec![
1226 ProgressEvent::Phase {
1227 title: "scan".to_string()
1228 },
1229 ProgressEvent::Log {
1230 message: "a".to_string()
1231 },
1232 ProgressEvent::Log {
1233 message: r#"{"found":2}"#.to_string()
1234 },
1235 ProgressEvent::Phase {
1236 title: "verify".to_string()
1237 },
1238 ProgressEvent::Log {
1239 message: "b".to_string()
1240 },
1241 ]
1242 );
1243 }
1244
1245 #[tokio::test]
1246 async fn promise_all_of_tasks_resolves_concurrently() {
1247 let driver = Arc::new(FakeDriver::new());
1248 driver.on_with_delay(
1249 "left",
1250 FakeReply::Complete("L".to_string()),
1251 Duration::from_millis(50),
1252 );
1253 driver.on_with_delay(
1254 "right",
1255 FakeReply::Complete("R".to_string()),
1256 Duration::from_millis(50),
1257 );
1258 let started = std::time::Instant::now();
1259 let value = run(
1260 &driver,
1261 r#"
1262 const [a, b] = await Promise.all([
1263 task({ description: "left" }),
1264 task({ description: "right" }),
1265 ]);
1266 return a + "/" + b;
1267 "#,
1268 json!(null),
1269 )
1270 .await
1271 .unwrap();
1272 assert_eq!(value, json!("L/R"));
1273 // Two 50ms tasks awaited concurrently should not take ~100ms serially.
1274 // Generous bound to stay green on slow CI.
1275 assert!(
1276 started.elapsed() < Duration::from_millis(3000),
1277 "took {:?}",
1278 started.elapsed()
1279 );
1280 assert_eq!(driver.spawn_count(), 2);
1281 }
1282
1283 #[tokio::test]
1284 async fn export_default_async_function_runs_with_args() {
1285 let driver = Arc::new(FakeDriver::new());
1286 let source = r#"
1287 export default async function (args) {
1288 return { doubled: args.n * 2 };
1289 }
1290 "#;
1291 let value = run(&driver, source, json!({ "n": 21 })).await.unwrap();
1292 assert_eq!(value, json!({ "doubled": 42 }));
1293 }
1294
1295 #[tokio::test]
1296 async fn export_default_function_result_becomes_run_result() {
1297 let driver = Arc::new(FakeDriver::new());
1298 let source = r#"
1299 function helper() {
1300 return "from-helper";
1301 }
1302 export default function () {
1303 return helper();
1304 }
1305 "#;
1306 let value = run(&driver, source, json!(null)).await.unwrap();
1307 assert_eq!(value, json!("from-helper"));
1308 }
1309
1310 #[tokio::test]
1311 async fn export_default_non_function_value_is_returned() {
1312 let driver = Arc::new(FakeDriver::new());
1313 let value = run(&driver, "export default 7;", json!(null))
1314 .await
1315 .unwrap();
1316 assert_eq!(value, json!(7));
1317 }
1318
1319 #[tokio::test]
1320 async fn plain_scripts_are_untouched_by_export_desugaring() {
1321 let driver = Arc::new(FakeDriver::new());
1322 // A string literal mentioning `export default` must not trigger the
1323 // module desugaring path.
1324 let value = run(
1325 &driver,
1326 "const note = \"export default docs\";\nreturn note.length;",
1327 json!(null),
1328 )
1329 .await
1330 .unwrap();
1331 assert_eq!(value, json!(19));
1332 }
1333
1334 #[tokio::test]
1335 async fn export_default_examples_inside_multiline_text_are_not_desugared() {
1336 let driver = Arc::new(FakeDriver::new());
1337 let value = run(
1338 &driver,
1339 r#"
1340 const template = `
1341 export default async function (args) {
1342 return args;
1343 }
1344 `;
1345 /*
1346 export default function () {
1347 return "comment example";
1348 }
1349 */
1350 return template.includes("export default async function");
1351 "#,
1352 json!(null),
1353 )
1354 .await
1355 .unwrap();
1356 assert_eq!(value, json!(true));
1357 }
1358
1359 #[tokio::test]
1360 async fn task_accepts_agent_tool_spellings() {
1361 // The `agent` tool and `task()` are written by the same authors; a schema
1362 // that runs on one surface must not be an unknown-field error on the
1363 // other. snake_case spellings and `workspace_policy` are aliases.
1364 let driver = Arc::new(FakeDriver::new());
1365 let value = run(
1366 &driver,
1367 r#"
1368 return await task({
1369 prompt: "cross-surface schema",
1370 subagent_type: "implementer",
1371 workspace_policy: "worktree",
1372 write_authority: "worktree_write",
1373 write_roots: ["crates/tui/src"],
1374 token_budget: 5000,
1375 max_steps: 4,
1376 });
1377 "#,
1378 json!(null),
1379 )
1380 .await
1381 .unwrap();
1382 assert_eq!(value, json!("done:cross-surface schema"));
1383 let requests = driver.requests();
1384 assert_eq!(requests.len(), 1);
1385 assert!(
1386 requests[0].worktree,
1387 "workspace_policy worktree maps to worktree isolation"
1388 );
1389 assert_eq!(
1390 requests[0].write_authority.as_deref(),
1391 Some("worktree_write")
1392 );
1393 assert_eq!(requests[0].token_budget, Some(5000));
1394
1395 // "shared" is accepted and stays non-worktree; contradictions and unknown
1396 // values still fail loudly.
1397 let error = run(
1398 &driver,
1399 r#"return await task({ prompt: "x", workspacePolicy: "shared", worktree: true });"#,
1400 json!(null),
1401 )
1402 .await
1403 .unwrap_err();
1404 assert!(script_message(Err(error)).contains("conflicts with worktree"));
1405 let error = run(
1406 &driver,
1407 r#"return await task({ prompt: "x", workspacePolicy: "solo" });"#,
1408 json!(null),
1409 )
1410 .await
1411 .unwrap_err();
1412 assert!(script_message(Err(error)).contains("must be shared or worktree"));
1413 }
1414
1415 #[tokio::test]
1416 async fn vm_rejected_task_options_notify_the_driver() {
1417 // A task() whose options fail VM validation throws before spawn_task, and
1418 // inside parallel() that throw collapses to a null slot. The driver must
1419 // still receive a TaskRejected event so the run record can refuse to call
1420 // the run a plain success (morning-report issue #2).
1421 let driver = Arc::new(FakeDriver::new());
1422 let value = run(
1423 &driver,
1424 r#"
1425 return await parallel([
1426 () => task({ prompt: "bad slot", label: "L-bad", phase: "P1", cwd: "/absolute/path" }),
1427 ]);
1428 "#,
1429 json!(null),
1430 )
1431 .await
1432 .unwrap();
1433 assert_eq!(value, json!([null]));
1434 assert!(
1435 driver.requests().is_empty(),
1436 "no dispatch reached the driver"
1437 );
1438 let rejected: Vec<_> = driver
1439 .events()
1440 .into_iter()
1441 .filter_map(|event| match event {
1442 ProgressEvent::TaskRejected {
1443 label,
1444 phase,
1445 message,
1446 } => Some((label, phase, message)),
1447 _ => None,
1448 })
1449 .collect();
1450 assert_eq!(rejected.len(), 1, "one rejection event per refused slot");
1451 let (label, phase, message) = &rejected[0];
1452 assert_eq!(label.as_deref(), Some("L-bad"));
1453 assert_eq!(phase.as_deref(), Some("P1"));
1454 assert!(message.contains("bounded repo-relative paths"), "{message}");
1455 }
1456
1456 lines RUST