返回 CodeWhale
verifier.rs
根目录 / crates / tui / src / tools / verifier.rs
1 //! Parallel verifier ensemble tool: `run_verifiers`.
2 //!
3 //! This is the agent-facing path for "parallelize the verifier, not the
4 //! generator": one tool call fans out to independent project checks across
5 //! common ecosystems and returns a single structured verdict.
6
7 use std::collections::{BTreeSet, HashMap};
8 use std::fs;
9 use std::path::{Path, PathBuf};
10 use std::process::Stdio;
11 use std::time::{Duration, Instant};
12
13 use async_trait::async_trait;
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Value, json};
16 use shlex::try_join;
17
18 use crate::dependencies::ExternalTool;
19
20 use super::spec::{
21 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
22 };
23
24 const DEFAULT_MAX_PYTHON_FILES: usize = 200;
25 const MAX_CUSTOM_GATES: usize = 12;
26 const BACKGROUND_GATE_TIMEOUT_MS: u64 = 600_000;
27 /// Wall-clock bound for a built-in (detected) foreground gate unless the call
28 /// sets `timeout_ms`.
29 const DEFAULT_GATE_TIMEOUT_MS: u64 = 600_000;
30 /// Wall-clock bound for a custom foreground gate when `timeout_ms` is absent.
31 /// Custom gates run arbitrary programs (headless browsers, servers) that can
32 /// finish their work and never exit, so the default is deliberately tighter.
33 const DEFAULT_CUSTOM_GATE_TIMEOUT_MS: u64 = 180_000;
34 const MAX_GATE_TIMEOUT_MS: u64 = 1_800_000;
35
36 /// Wall-clock bounds a foreground gate plan is built with. Background gates
37 /// are shell jobs, which the shell manager does not bound; they run until they
38 /// finish or are cancelled through task state, so `timeout_ms` is refused there.
39 #[derive(Debug, Clone, Copy)]
40 struct GateTimeouts {
41 /// Bound for every built-in (detected) gate.
42 builtin_ms: u64,
43 /// Bound for a custom gate that does not set its own `timeout_ms`.
44 custom_default_ms: u64,
45 }
46
47 impl GateTimeouts {
48 /// A custom gate that never exits (a headless browser that wrote its
49 /// output) holds the turn, so its default is tighter than a built-in's.
50 const DEFAULT: Self = Self {
51 builtin_ms: DEFAULT_GATE_TIMEOUT_MS,
52 custom_default_ms: DEFAULT_CUSTOM_GATE_TIMEOUT_MS,
53 };
54
55 /// `timeout_ms` on the call overrides the built-in gates' bound.
56 fn for_call(builtin_override_ms: Option<u64>) -> Result<Self, ToolError> {
57 let mut timeouts = Self::DEFAULT;
58 if let Some(ms) = builtin_override_ms {
59 check_gate_timeout("timeout_ms", ms)?;
60 timeouts.builtin_ms = ms;
61 }
62 Ok(timeouts)
63 }
64 }
65
66 fn check_gate_timeout(field: &str, ms: u64) -> Result<(), ToolError> {
67 if (1..=MAX_GATE_TIMEOUT_MS).contains(&ms) {
68 Ok(())
69 } else {
70 Err(ToolError::invalid_input(format!(
71 "{field} must be between 1 and {MAX_GATE_TIMEOUT_MS}"
72 )))
73 }
74 }
75 /// Bytes of a gate stream kept in memory from its start, and from its end,
76 /// once the stream is longer than both together. Every byte also goes to a
77 /// session artifact, so the middle stays readable (#6508). What the model
78 /// sees of the result is the engine's one recoverable budget.
79 const GATE_CAPTURE_HEAD_BYTES: usize = 512 * 1024;
80 const GATE_CAPTURE_TAIL_BYTES: usize = 512 * 1024;
81 /// After a gate's own process exits, how long a helper it started may keep
82 /// stdout/stderr open before the gate's process group is killed.
83 const GATE_PIPE_DRAIN_GRACE: Duration = Duration::from_secs(2);
84
85 /// Tool for running independent verifier gates concurrently.
86 pub struct RunVerifiersTool;
87
88 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89 #[serde(rename_all = "snake_case")]
90 enum VerifierProfile {
91 Auto,
92 Rust,
93 Node,
94 Python,
95 Go,
96 }
97
98 impl VerifierProfile {
99 fn parse(raw: &str) -> Result<Self, ToolError> {
100 match raw {
101 "auto" => Ok(Self::Auto),
102 "rust" => Ok(Self::Rust),
103 "node" => Ok(Self::Node),
104 "python" => Ok(Self::Python),
105 "go" => Ok(Self::Go),
106 other => Err(ToolError::invalid_input(format!(
107 "Unsupported profile '{other}'. Expected one of: auto, rust, node, python, go"
108 ))),
109 }
110 }
111
112 fn as_str(self) -> &'static str {
113 match self {
114 Self::Auto => "auto",
115 Self::Rust => "rust",
116 Self::Node => "node",
117 Self::Python => "python",
118 Self::Go => "go",
119 }
120 }
121 }
122
123 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
124 #[serde(rename_all = "snake_case")]
125 enum VerifierLevel {
126 Quick,
127 Full,
128 }
129
130 impl VerifierLevel {
131 fn parse(raw: &str) -> Result<Self, ToolError> {
132 match raw {
133 "quick" => Ok(Self::Quick),
134 "full" => Ok(Self::Full),
135 other => Err(ToolError::invalid_input(format!(
136 "Unsupported level '{other}'. Expected one of: quick, full"
137 ))),
138 }
139 }
140
141 fn as_str(self) -> &'static str {
142 match self {
143 Self::Quick => "quick",
144 Self::Full => "full",
145 }
146 }
147 }
148
149 #[derive(Debug, Clone, Deserialize)]
150 #[serde(default, deny_unknown_fields)]
151 struct RunVerifiersInput {
152 profile: String,
153 level: String,
154 max_python_files: usize,
155 commands: Vec<CustomVerifierInput>,
156 background: bool,
157 cwd: Option<String>,
158 timeout_ms: Option<u64>,
159 }
160
161 impl Default for RunVerifiersInput {
162 fn default() -> Self {
163 Self {
164 profile: "auto".to_string(),
165 level: "quick".to_string(),
166 max_python_files: DEFAULT_MAX_PYTHON_FILES,
167 commands: Vec::new(),
168 background: false,
169 cwd: None,
170 timeout_ms: None,
171 }
172 }
173 }
174
175 #[derive(Debug, Clone, Default, Deserialize)]
176 #[serde(default, deny_unknown_fields)]
177 struct CustomVerifierInput {
178 name: String,
179 program: String,
180 args: Vec<String>,
181 cwd: Option<String>,
182 timeout_ms: Option<u64>,
183 }
184
185 #[derive(Debug, Clone)]
186 struct VerifierGate {
187 name: String,
188 ecosystem: String,
189 cwd: PathBuf,
190 program: Option<String>,
191 args: Vec<String>,
192 env: Vec<(String, String)>,
193 skipped_reason: Option<String>,
194 timeout: Duration,
195 }
196
197 #[derive(Debug, Clone, Serialize, Deserialize)]
198 struct GateResult {
199 name: String,
200 ecosystem: String,
201 status: GateStatus,
202 command: String,
203 cwd: String,
204 exit_code: Option<i32>,
205 duration_ms: u64,
206 stdout: String,
207 stderr: String,
208 stdout_truncated: bool,
209 stderr_truncated: bool,
210 /// Session artifact (`art_<id>`) holding the whole stream when it was
211 /// longer than what the result keeps in memory (#6508).
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 stdout_log_ref: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 stderr_log_ref: Option<String>,
216 skipped_reason: Option<String>,
217 }
218
219 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220 #[serde(rename_all = "snake_case")]
221 enum GateStatus {
222 Passed,
223 Failed,
224 Skipped,
225 }
226
227 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228 #[serde(rename_all = "snake_case")]
229 enum VerifierVerdict {
230 Pass,
231 Partial,
232 Fail,
233 }
234
235 impl VerifierVerdict {
236 fn from_counts(gate_count: usize, failed: usize, skipped: usize) -> Self {
237 if failed > 0 {
238 Self::Fail
239 } else if skipped > 0 || gate_count == 0 {
240 Self::Partial
241 } else {
242 Self::Pass
243 }
244 }
245 }
246
247 #[derive(Debug, Clone, Serialize, Deserialize)]
248 struct RunVerifiersOutput {
249 success: bool,
250 profile: String,
251 level: String,
252 workspace: String,
253 gate_count: usize,
254 passed: usize,
255 failed: usize,
256 skipped: usize,
257 verifier_verdict: VerifierVerdict,
258 summary: String,
259 gates: Vec<GateResult>,
260 }
261
262 #[derive(Debug, Clone, Serialize, Deserialize)]
263 struct BackgroundGateJob {
264 name: String,
265 ecosystem: String,
266 status: String,
267 command: String,
268 cwd: String,
269 task_id: Option<String>,
270 skipped_reason: Option<String>,
271 error: Option<String>,
272 }
273
274 #[derive(Debug, Clone, Serialize, Deserialize)]
275 struct RunVerifiersBackgroundOutput {
276 success: bool,
277 profile: String,
278 level: String,
279 workspace: String,
280 background: bool,
281 gate_count: usize,
282 started: usize,
283 skipped: usize,
284 failed_to_start: usize,
285 summary: String,
286 jobs: Vec<BackgroundGateJob>,
287 }
288
289 #[async_trait]
290 impl ToolSpec for RunVerifiersTool {
291 fn name(&self) -> &'static str {
292 "run_verifiers"
293 }
294
295 fn model_visible(&self) -> bool {
296 false
297 }
298
299 fn description(&self) -> &'static str {
300 "Run independent verifier gates in parallel across detected Rust, Node, Python, and Go projects. Supports explicit custom verifier commands as program+args without requiring Bash."
301 }
302
303 fn input_schema(&self) -> Value {
304 json!({
305 "type": "object",
306 "properties": {
307 "profile": {
308 "type": "string",
309 "enum": ["auto", "rust", "node", "python", "go"],
310 "default": "auto",
311 "description": "Which ecosystem verifier set to run. 'auto' detects all supported project types in the workspace."
312 },
313 "level": {
314 "type": "string",
315 "enum": ["quick", "full"],
316 "default": "quick",
317 "description": "Quick runs fast syntax/drift/build checks. Full adds heavier test/lint gates where available."
318 },
319 "max_python_files": {
320 "type": "integer",
321 "minimum": 1,
322 "maximum": 1000,
323 "default": DEFAULT_MAX_PYTHON_FILES,
324 "description": "Maximum Python files to syntax-parse in the built-in python-syntax gate."
325 },
326 "commands": {
327 "type": "array",
328 "description": "Optional explicit verifier gates. Commands run directly as program+args, not through a shell. Use program='bash', args=['-lc', '...'] only when Bash is intentionally part of the verifier.",
329 "items": {
330 "type": "object",
331 "properties": {
332 "name": {
333 "type": "string",
334 "description": "Short unique gate name."
335 },
336 "program": {
337 "type": "string",
338 "description": "Executable to spawn, for example 'uv', 'pytest', 'npm', 'make', 'cmd', 'powershell', or 'bash'."
339 },
340 "args": {
341 "type": "array",
342 "items": { "type": "string" },
343 "description": "Arguments passed directly to the executable."
344 },
345 "cwd": {
346 "type": "string",
347 "description": "Optional working directory relative to the workspace."
348 },
349 "timeout_ms": {
350 "type": "integer",
351 "minimum": 1,
352 "maximum": MAX_GATE_TIMEOUT_MS,
353 "description": format!("Wall-clock limit for this gate (foreground only; refused with background). When it elapses the gate's whole process tree is killed and the gate fails as timed out. Defaults to {DEFAULT_CUSTOM_GATE_TIMEOUT_MS}. Raise it for long test suites.")
354 }
355 },
356 "required": ["name", "program"],
357 "additionalProperties": false
358 },
359 },
360 "background": {
361 "type": "boolean",
362 "default": false,
363 "description": "Start verifier gates as background shell jobs and return task_ids immediately. Use for long build/test/lint gates; completion is tracked in task/status state, and `Bash` with action 'wait' / task_shell_wait are only for early output, final output, or true dependency barriers."
364 },
365 "cwd": {
366 "type": "string",
367 "description": "Optional working directory, relative to the workspace, to detect projects and run gates in. Per-command cwd stays relative to it. Must exist inside the workspace."
368 },
369 "timeout_ms": {
370 "type": "integer",
371 "minimum": 1,
372 "maximum": MAX_GATE_TIMEOUT_MS,
373 "default": DEFAULT_GATE_TIMEOUT_MS,
374 "description": "Wall-clock limit for each built-in (detected) gate. When it elapses the gate's whole process tree is killed and the gate fails as timed out. Raise it for a `full` run on a large workspace. Foreground only; refused with background. Custom commands use their own timeout_ms."
375 }
376 },
377 "additionalProperties": false
378 })
379 }
380
381 fn capabilities(&self) -> Vec<ToolCapability> {
382 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
383 }
384
385 fn approval_requirement(&self) -> ApprovalRequirement {
386 ApprovalRequirement::Required
387 }
388
389 fn starts_detached_for(&self, input: &Value) -> bool {
390 input.get("background").and_then(Value::as_bool) == Some(true)
391 }
392
393 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
394 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
395 let input: RunVerifiersInput = serde_json::from_value(input)
396 .map_err(|err| ToolError::invalid_input(err.to_string()))?;
397 // `cwd` scopes the whole call — project detection, gate roots, and
398 // reported paths — to an existing in-workspace subdirectory.
399 let scoped;
400 let context = match input
401 .cwd
402 .as_deref()
403 .map(str::trim)
404 .filter(|cwd| !cwd.is_empty())
405 {
406 None => context,
407 Some(raw) => {
408 let root = context.resolve_existing_dir(raw, "cwd")?;
409 let mut narrowed = context.clone();
410 narrowed.workspace = root;
411 scoped = narrowed;
412 &scoped
413 }
414 };
415 let profile = VerifierProfile::parse(input.profile.as_str())?;
416 let level = VerifierLevel::parse(input.level.as_str())?;
417 if input.max_python_files == 0 || input.max_python_files > 1000 {
418 return Err(ToolError::invalid_input(
419 "max_python_files must be between 1 and 1000",
420 ));
421 }
422 if input.commands.len() > MAX_CUSTOM_GATES {
423 return Err(ToolError::invalid_input(format!(
424 "commands may contain at most {MAX_CUSTOM_GATES} custom gates"
425 )));
426 }
427
428 if input.background
429 && (input.timeout_ms.is_some()
430 || input
431 .commands
432 .iter()
433 .any(|custom| custom.timeout_ms.is_some()))
434 {
435 return Err(ToolError::invalid_input(
436 "timeout_ms applies only to foreground gates; background gates run as shell jobs until they finish or are cancelled through task state",
437 ));
438 }
439 let timeouts = GateTimeouts::for_call(input.timeout_ms)?;
440 let gates = build_gate_plan(
441 context,
442 profile,
443 level,
444 input.max_python_files,
445 &input.commands,
446 timeouts,
447 )?;
448 if gates.is_empty() {
449 let verifier_verdict = VerifierVerdict::from_counts(0, 0, 0);
450 let output = RunVerifiersOutput {
451 success: false,
452 profile: profile.as_str().to_string(),
453 level: level.as_str().to_string(),
454 workspace: context.workspace.display().to_string(),
455 gate_count: 0,
456 passed: 0,
457 failed: 0,
458 skipped: 0,
459 verifier_verdict,
460 summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(),
461 gates: Vec::new(),
462 };
463 return verifier_tool_result(&output);
464 }
465
466 if input.background {
467 return start_background_gates(context, profile, level, gates);
468 }
469
470 // Gates run as futures of this call, not detached blocking tasks: when
471 // Stop drops the tool future, every running gate's process group is
472 // killed with it instead of being orphaned.
473 let mut results = futures_util::future::join_all(
474 gates
475 .into_iter()
476 .map(|gate| run_gate(gate, &context.state_namespace)),
477 )
478 .await;
479 results.sort_by(|a, b| a.name.cmp(&b.name));
480
481 let passed = results
482 .iter()
483 .filter(|result| result.status == GateStatus::Passed)
484 .count();
485 let failed = results
486 .iter()
487 .filter(|result| result.status == GateStatus::Failed)
488 .count();
489 let skipped = results
490 .iter()
491 .filter(|result| result.status == GateStatus::Skipped)
492 .count();
493 let success = failed == 0 && skipped == 0;
494 let verifier_verdict = VerifierVerdict::from_counts(results.len(), failed, skipped);
495 let summary = if success {
496 format!("All {passed} verifier gates passed.")
497 } else {
498 format!("{passed} passed, {failed} failed, {skipped} skipped.")
499 };
500
501 let output = RunVerifiersOutput {
502 success,
503 profile: profile.as_str().to_string(),
504 level: level.as_str().to_string(),
505 workspace: context.workspace.display().to_string(),
506 gate_count: results.len(),
507 passed,
508 failed,
509 skipped,
510 verifier_verdict,
511 summary,
512 gates: results,
513 };
514
515 verifier_tool_result(&output)
516 }
517 }
518
519 /// Run quick auto verifier gates after a successful workflow completion (#4013).
520 pub(crate) async fn run_workflow_completion_gates(
521 context: &ToolContext,
522 ) -> Result<Value, ToolError> {
523 let gates = build_gate_plan(
524 context,
525 VerifierProfile::Auto,
526 VerifierLevel::Quick,
527 DEFAULT_MAX_PYTHON_FILES,
528 &[],
529 GateTimeouts::DEFAULT,
530 )?;
531 if gates.is_empty() {
532 return Ok(json!({
533 "success": false,
534 "profile": "auto",
535 "level": "quick",
536 "gate_count": 0,
537 "summary": "No verifier gates detected for this workspace.",
538 "gates": [],
539 }));
540 }
541
542 let mut results = futures_util::future::join_all(
543 gates
544 .into_iter()
545 .map(|gate| run_gate(gate, &context.state_namespace)),
546 )
547 .await;
548 results.sort_by(|a, b| a.name.cmp(&b.name));
549
550 let passed = results
551 .iter()
552 .filter(|result| result.status == GateStatus::Passed)
553 .count();
554 let failed = results
555 .iter()
556 .filter(|result| result.status == GateStatus::Failed)
557 .count();
558 let skipped = results
559 .iter()
560 .filter(|result| result.status == GateStatus::Skipped)
561 .count();
562 let success = failed == 0 && skipped == 0;
563 if !success {
564 return Err(ToolError::execution_failed(format!(
565 "{passed} passed, {failed} failed, {skipped} skipped"
566 )));
567 }
568 Ok(json!({
569 "success": true,
570 "profile": "auto",
571 "level": "quick",
572 "gate_count": results.len(),
573 "passed": passed,
574 "failed": failed,
575 "skipped": skipped,
576 "summary": format!("All {passed} verifier gates passed."),
577 "gates": results,
578 }))
579 }
580
581 fn verifier_tool_result(output: &RunVerifiersOutput) -> Result<ToolResult, ToolError> {
582 ToolResult::json(output)
583 .map_err(|err| ToolError::execution_failed(err.to_string()))
584 .map(|result| {
585 result.with_metadata(json!({
586 "verifier_verdict": output.verifier_verdict,
587 }))
588 })
589 }
590
591 fn start_background_gates(
592 context: &ToolContext,
593 profile: VerifierProfile,
594 level: VerifierLevel,
595 gates: Vec<VerifierGate>,
596 ) -> Result<ToolResult, ToolError> {
597 let mut jobs = Vec::with_capacity(gates.len());
598 let mut started = 0usize;
599 let mut skipped = 0usize;
600 let mut failed_to_start = 0usize;
601
602 for gate in gates {
603 let cwd = gate.cwd.display().to_string();
604 let Some(program) = gate.program.as_deref() else {
605 skipped += 1;
606 jobs.push(BackgroundGateJob {
607 name: gate.name,
608 ecosystem: gate.ecosystem,
609 status: "skipped".to_string(),
610 command: String::new(),
611 cwd,
612 task_id: None,
613 skipped_reason: gate.skipped_reason,
614 error: None,
615 });
616 continue;
617 };
618
619 let command = render_gate_command(program, &gate.args)?;
620 let env: HashMap<String, String> = gate.env.into_iter().collect();
621 let spawn_result = {
622 let mut manager = context
623 .shell_manager
624 .lock()
625 .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
626 manager.execute_with_options_env_for_session(
627 &command,
628 Some(&cwd),
629 BACKGROUND_GATE_TIMEOUT_MS,
630 true,
631 None,
632 false,
633 context.elevated_sandbox_policy.clone(),
634 env,
635 &context.state_namespace,
636 )
637 };
638
639 match spawn_result {
640 Ok(result) => {
641 started += 1;
642 jobs.push(BackgroundGateJob {
643 name: gate.name,
644 ecosystem: gate.ecosystem,
645 status: "running".to_string(),
646 command,
647 cwd,
648 task_id: result.task_id,
649 skipped_reason: None,
650 error: None,
651 });
652 }
653 Err(err) => {
654 failed_to_start += 1;
655 jobs.push(BackgroundGateJob {
656 name: gate.name,
657 ecosystem: gate.ecosystem,
658 status: "failed_to_start".to_string(),
659 command,
660 cwd,
661 task_id: None,
662 skipped_reason: None,
663 error: Some(err.to_string()),
664 });
665 }
666 }
667 }
668
669 jobs.sort_by(|a, b| a.name.cmp(&b.name));
670 let success = failed_to_start == 0 && started > 0;
671 let summary = if failed_to_start == 0 {
672 format!(
673 "Started {started} verifier gate(s) in the background; {skipped} skipped. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
674 )
675 } else {
676 format!(
677 "Started {started} verifier gate(s), failed to start {failed_to_start}, and skipped {skipped}. Completion is tracked in task/status state. Continue inspecting or implementing while they run."
678 )
679 };
680 let task_ids = jobs
681 .iter()
682 .filter_map(|job| job.task_id.clone())
683 .collect::<Vec<_>>();
684 let output = RunVerifiersBackgroundOutput {
685 success,
686 profile: profile.as_str().to_string(),
687 level: level.as_str().to_string(),
688 workspace: context.workspace.display().to_string(),
689 background: true,
690 gate_count: jobs.len(),
691 started,
692 skipped,
693 failed_to_start,
694 summary,
695 jobs,
696 };
697
698 let mut result =
699 ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string()))?;
700 result.success = success;
701 Ok(result.with_metadata(json!({
702 "backgrounded": true,
703 "detached_start": true,
704 "verifier_background": true,
705 "auto_resume_on_completion": false,
706 "completion_surface": "task_status",
707 "background_policy": "nonblocking",
708 "task_ids": task_ids,
709 "poll_with": ["task_shell_wait"]
710 })))
711 }
712
713 fn render_gate_command(program: &str, args: &[String]) -> Result<String, ToolError> {
714 try_join(std::iter::once(program).chain(args.iter().map(String::as_str)))
715 .map_err(|err| ToolError::execution_failed(format!("failed to render gate command: {err}")))
716 }
717
718 fn build_gate_plan(
719 context: &ToolContext,
720 profile: VerifierProfile,
721 level: VerifierLevel,
722 max_python_files: usize,
723 custom_commands: &[CustomVerifierInput],
724 timeouts: GateTimeouts,
725 ) -> Result<Vec<VerifierGate>, ToolError> {
726 let workspace = &context.workspace;
727 let mut gates = Vec::new();
728
729 if profile == VerifierProfile::Auto && workspace.join(".git").exists() {
730 gates.push(gate(
731 "git-whitespace",
732 "git",
733 workspace,
734 "git",
735 ["diff", "--check"],
736 ));
737 }
738
739 if profile_matches(profile, VerifierProfile::Rust) && workspace.join("Cargo.toml").exists() {
740 add_rust_gates(&mut gates, workspace, level);
741 }
742 if profile_matches(profile, VerifierProfile::Node) && workspace.join("package.json").exists() {
743 add_node_gates(&mut gates, workspace, level);
744 }
745 if profile_matches(profile, VerifierProfile::Python) && has_python_project(workspace) {
746 add_python_gates(&mut gates, workspace, level, max_python_files);
747 }
748 if profile_matches(profile, VerifierProfile::Go) && workspace.join("go.mod").exists() {
749 add_go_gates(&mut gates, workspace, level);
750 }
751
752 let builtin_timeout = Duration::from_millis(timeouts.builtin_ms);
753 for gate in &mut gates {
754 gate.timeout = builtin_timeout;
755 }
756 for custom in custom_commands {
757 gates.push(custom_gate(context, custom, timeouts)?);
758 }
759
760 Ok(gates)
761 }
762
763 fn profile_matches(selected: VerifierProfile, candidate: VerifierProfile) -> bool {
764 selected == VerifierProfile::Auto || selected == candidate
765 }
766
767 fn add_rust_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
768 let locked = workspace.join("Cargo.lock").exists();
769 gates.push(gate(
770 "rust-fmt",
771 "rust",
772 workspace,
773 "cargo",
774 ["fmt", "--all", "--", "--check"],
775 ));
776
777 let metadata_args = if locked {
778 vec!["metadata", "--locked", "--format-version", "1", "--no-deps"]
779 } else {
780 vec!["metadata", "--format-version", "1", "--no-deps"]
781 };
782 gates.push(gate_vec(
783 "rust-metadata",
784 "rust",
785 workspace,
786 "cargo",
787 metadata_args,
788 ));
789
790 let mut check_args = vec!["check", "--workspace", "--all-targets"];
791 if locked {
792 check_args.push("--locked");
793 }
794 gates.push(gate_vec(
795 "rust-check",
796 "rust",
797 workspace,
798 "cargo",
799 check_args,
800 ));
801
802 if level == VerifierLevel::Full {
803 let mut clippy_args = vec!["clippy", "--workspace", "--all-targets", "--all-features"];
804 if locked {
805 clippy_args.push("--locked");
806 }
807 clippy_args.extend(["--", "-D", "warnings"]);
808 gates.push(gate_vec(
809 "rust-clippy",
810 "rust",
811 workspace,
812 "cargo",
813 clippy_args,
814 ));
815
816 let mut test_args = vec!["test", "--workspace", "--all-features"];
817 if locked {
818 test_args.push("--locked");
819 }
820 gates.push(gate_vec("rust-test", "rust", workspace, "cargo", test_args));
821 }
822 }
823
824 fn add_node_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
825 let scripts = package_json_scripts(workspace);
826 let Some(scripts) = scripts else {
827 gates.push(skipped_gate(
828 "node-package-json",
829 "node",
830 workspace,
831 "package.json is missing or could not be parsed",
832 ));
833 return;
834 };
835 let package_manager = detect_node_package_manager(workspace);
836 for script in ["format:check", "check", "typecheck", "lint"] {
837 if has_meaningful_script(&scripts, script) {
838 gates.push(node_script_gate(workspace, &package_manager, script));
839 }
840 }
841 if level == VerifierLevel::Full && has_meaningful_script(&scripts, "test") {
842 gates.push(node_script_gate(workspace, &package_manager, "test"));
843 }
844 }
845
846 fn add_python_gates(
847 gates: &mut Vec<VerifierGate>,
848 workspace: &Path,
849 level: VerifierLevel,
850 max_python_files: usize,
851 ) {
852 let python_files = collect_python_files(workspace, max_python_files);
853 match python_files {
854 PythonFiles::Files(files) if !files.is_empty() => {
855 gates.push(python_syntax_gate(workspace, &files));
856 }
857 PythonFiles::TooMany { limit, found } => gates.push(skipped_gate(
858 "python-syntax",
859 "python",
860 workspace,
861 format!(
862 "found more than {limit} Python files ({found}); raise max_python_files to verify them"
863 ),
864 )),
865 PythonFiles::Files(_) => {}
866 }
867
868 if level == VerifierLevel::Full && has_pytest_signal(workspace) {
869 gates.push(python_module_gate(
870 "python-pytest",
871 workspace,
872 ["-m", "pytest"],
873 ));
874 }
875 }
876
877 fn add_go_gates(gates: &mut Vec<VerifierGate>, workspace: &Path, level: VerifierLevel) {
878 gates.push(gate("go-test", "go", workspace, "go", ["test", "./..."]));
879 if level == VerifierLevel::Full {
880 gates.push(gate("go-vet", "go", workspace, "go", ["vet", "./..."]));
881 }
882 }
883
884 fn gate<const N: usize>(
885 name: &str,
886 ecosystem: &str,
887 cwd: &Path,
888 program: &str,
889 args: [&str; N],
890 ) -> VerifierGate {
891 gate_vec(name, ecosystem, cwd, program, args)
892 }
893
894 fn gate_vec<I, S>(name: &str, ecosystem: &str, cwd: &Path, program: &str, args: I) -> VerifierGate
895 where
896 I: IntoIterator<Item = S>,
897 S: AsRef<str>,
898 {
899 VerifierGate {
900 name: name.to_string(),
901 ecosystem: ecosystem.to_string(),
902 cwd: cwd.to_path_buf(),
903 program: Some(program.to_string()),
904 args: args
905 .into_iter()
906 .map(|arg| arg.as_ref().to_string())
907 .collect(),
908 env: Vec::new(),
909 skipped_reason: None,
910 timeout: Duration::from_millis(DEFAULT_GATE_TIMEOUT_MS),
911 }
912 }
913
914 fn skipped_gate(
915 name: &str,
916 ecosystem: &str,
917 cwd: &Path,
918 reason: impl Into<String>,
919 ) -> VerifierGate {
920 VerifierGate {
921 name: name.to_string(),
922 ecosystem: ecosystem.to_string(),
923 cwd: cwd.to_path_buf(),
924 program: None,
925 args: Vec::new(),
926 env: Vec::new(),
927 skipped_reason: Some(reason.into()),
928 timeout: Duration::from_millis(DEFAULT_GATE_TIMEOUT_MS),
929 }
930 }
931
932 fn custom_gate(
933 context: &ToolContext,
934 custom: &CustomVerifierInput,
935 timeouts: GateTimeouts,
936 ) -> Result<VerifierGate, ToolError> {
937 if custom.name.trim().is_empty() {
938 return Err(ToolError::invalid_input(
939 "Custom verifier command is missing 'name'",
940 ));
941 }
942 if custom.program.trim().is_empty() {
943 return Err(ToolError::invalid_input(format!(
944 "Custom verifier '{}' is missing 'program'",
945 custom.name
946 )));
947 }
948 let cwd = match custom.cwd.as_deref() {
949 Some(raw) if !raw.trim().is_empty() => context.resolve_path(raw)?,
950 _ => context.workspace.clone(),
951 };
952 let timeout_ms = custom.timeout_ms.unwrap_or(timeouts.custom_default_ms);
953 check_gate_timeout(
954 &format!("Custom verifier '{}' timeout_ms", custom.name),
955 timeout_ms,
956 )?;
957 Ok(VerifierGate {
958 name: custom.name.clone(),
959 ecosystem: "custom".to_string(),
960 cwd,
961 program: Some(custom.program.clone()),
962 args: custom.args.clone(),
963 env: Vec::new(),
964 skipped_reason: None,
965 timeout: Duration::from_millis(timeout_ms),
966 })
967 }
968
969 fn node_script_gate(
970 workspace: &Path,
971 package_manager: &NodePackageManager,
972 script: &str,
973 ) -> VerifierGate {
974 let (program, args) = package_manager.command_for_script(script);
975 gate_vec(&format!("node-{script}"), "node", workspace, program, args)
976 }
977
978 fn python_syntax_gate(workspace: &Path, files: &[PathBuf]) -> VerifierGate {
979 let Some((program, mut args)) = python_command_parts() else {
980 return skipped_gate(
981 "python-syntax",
982 "python",
983 workspace,
984 "Python interpreter is not installed or not in PATH",
985 );
986 };
987 args.push("-c".to_string());
988 args.push(PYTHON_SYNTAX_SCRIPT.to_string());
989 args.extend(files.iter().map(|path| path.display().to_string()));
990 let mut gate = gate_vec("python-syntax", "python", workspace, &program, args);
991 gate.env
992 .push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string()));
993 gate
994 }
995
996 fn python_module_gate<const N: usize>(
997 name: &str,
998 workspace: &Path,
999 module_args: [&str; N],
1000 ) -> VerifierGate {
1001 let Some((program, mut args)) = python_command_parts() else {
1002 return skipped_gate(
1003 name,
1004 "python",
1005 workspace,
1006 "Python interpreter is not installed or not in PATH",
1007 );
1008 };
1009 args.extend(module_args.into_iter().map(str::to_string));
1010 gate_vec(name, "python", workspace, &program, args)
1011 }
1012
1013 fn python_command_parts() -> Option<(String, Vec<String>)> {
1014 let spec = crate::dependencies::Python::resolve()?;
1015 Some(crate::dependencies::split_interpreter_spec(&spec))
1016 }
1017
1018 const PYTHON_SYNTAX_SCRIPT: &str = r#"
1019 import ast
1020 import pathlib
1021 import sys
1022
1023 failures = []
1024 for raw in sys.argv[1:]:
1025 path = pathlib.Path(raw)
1026 try:
1027 source = path.read_text(encoding="utf-8")
1028 ast.parse(source, filename=raw)
1029 except Exception as exc:
1030 failures.append(f"{raw}: {exc.__class__.__name__}: {exc}")
1031
1032 if failures:
1033 print("\n".join(failures), file=sys.stderr)
1034 sys.exit(1)
1035
1036 print(f"parsed {len(sys.argv) - 1} Python file(s)")
1037 "#;
1038
1039 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1040 enum NodePackageManager {
1041 Npm,
1042 Pnpm,
1043 Yarn,
1044 Bun,
1045 }
1046
1047 impl NodePackageManager {
1048 fn command_for_script(self, script: &str) -> (&'static str, Vec<String>) {
1049 match self {
1050 Self::Npm => ("npm", vec!["run".to_string(), script.to_string()]),
1051 Self::Pnpm => ("pnpm", vec!["run".to_string(), script.to_string()]),
1052 Self::Yarn => ("yarn", vec!["run".to_string(), script.to_string()]),
1053 Self::Bun => ("bun", vec!["run".to_string(), script.to_string()]),
1054 }
1055 }
1056 }
1057
1058 fn detect_node_package_manager(workspace: &Path) -> NodePackageManager {
1059 if workspace.join("pnpm-lock.yaml").exists() {
1060 NodePackageManager::Pnpm
1061 } else if workspace.join("yarn.lock").exists() {
1062 NodePackageManager::Yarn
1063 } else if workspace.join("bun.lock").exists() || workspace.join("bun.lockb").exists() {
1064 NodePackageManager::Bun
1065 } else {
1066 NodePackageManager::Npm
1067 }
1068 }
1069
1070 fn package_json_scripts(workspace: &Path) -> Option<HashMap<String, String>> {
1071 let raw = fs::read_to_string(workspace.join("package.json")).ok()?;
1072 let parsed = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
1073 let scripts = parsed.get("scripts")?.as_object()?;
1074 Some(
1075 scripts
1076 .iter()
1077 .filter_map(|(key, value)| {
1078 value
1079 .as_str()
1080 .map(|script| (key.clone(), script.to_string()))
1081 })
1082 .collect(),
1083 )
1084 }
1085
1086 fn has_meaningful_script(scripts: &HashMap<String, String>, name: &str) -> bool {
1087 let Some(script) = scripts.get(name).map(|value| value.trim()) else {
1088 return false;
1089 };
1090 !(script.is_empty()
1091 || name == "test"
1092 && script.contains("Error: no test specified")
1093 && script.contains("exit 1"))
1094 }
1095
1096 fn has_python_project(workspace: &Path) -> bool {
1097 workspace.join("pyproject.toml").exists()
1098 || workspace.join("setup.py").exists()
1099 || workspace.join("setup.cfg").exists()
1100 || workspace.join("requirements.txt").exists()
1101 || match collect_python_files(workspace, 1) {
1102 PythonFiles::Files(files) => !files.is_empty(),
1103 PythonFiles::TooMany { .. } => true,
1104 }
1105 }
1106
1107 fn has_pytest_signal(workspace: &Path) -> bool {
1108 if workspace.join("pytest.ini").exists()
1109 || workspace.join("tox.ini").exists()
1110 || workspace.join("tests").is_dir()
1111 {
1112 return true;
1113 }
1114 let pyproject = workspace.join("pyproject.toml");
1115 fs::read_to_string(pyproject)
1116 .map(|raw| raw.contains("pytest") || raw.contains("[tool.pytest"))
1117 .unwrap_or(false)
1118 }
1119
1120 #[derive(Debug, Clone, PartialEq, Eq)]
1121 enum PythonFiles {
1122 Files(Vec<PathBuf>),
1123 TooMany { limit: usize, found: usize },
1124 }
1125
1126 fn collect_python_files(workspace: &Path, limit: usize) -> PythonFiles {
1127 let mut files = BTreeSet::new();
1128 collect_python_files_inner(workspace, workspace, limit, &mut files);
1129 let found = files.len();
1130 if found > limit {
1131 PythonFiles::TooMany { limit, found }
1132 } else {
1133 PythonFiles::Files(files.into_iter().collect())
1134 }
1135 }
1136
1137 fn collect_python_files_inner(
1138 root: &Path,
1139 dir: &Path,
1140 limit: usize,
1141 files: &mut BTreeSet<PathBuf>,
1142 ) {
1143 if files.len() > limit {
1144 return;
1145 }
1146 let Ok(entries) = fs::read_dir(dir) else {
1147 return;
1148 };
1149 for entry in entries.flatten() {
1150 if files.len() > limit {
1151 return;
1152 }
1153 let path = entry.path();
1154 let name = entry.file_name();
1155 if path.is_dir() {
1156 if should_skip_dir_name(&name.to_string_lossy()) {
1157 continue;
1158 }
1159 collect_python_files_inner(root, &path, limit, files);
1160 } else if path.extension().and_then(|ext| ext.to_str()) == Some("py")
1161 && let Ok(relative) = path.strip_prefix(root)
1162 {
1163 files.insert(relative.to_path_buf());
1164 }
1165 }
1166 }
1167
1168 fn should_skip_dir_name(name: &str) -> bool {
1169 matches!(
1170 name,
1171 ".git"
1172 | ".hg"
1173 | ".svn"
1174 | ".venv"
1175 | "venv"
1176 | "env"
1177 | "__pycache__"
1178 | ".mypy_cache"
1179 | ".pytest_cache"
1180 | ".tox"
1181 | "node_modules"
1182 | "target"
1183 | "dist"
1184 | "build"
1185 )
1186 }
1187
1188 async fn run_gate(gate: VerifierGate, session_id: &str) -> GateResult {
1189 let command = render_command(gate.program.as_deref(), &gate.args);
1190 if let Some(reason) = gate.skipped_reason {
1191 return GateResult {
1192 name: gate.name,
1193 ecosystem: gate.ecosystem,
1194 status: GateStatus::Skipped,
1195 command,
1196 cwd: gate.cwd.display().to_string(),
1197 exit_code: None,
1198 duration_ms: 0,
1199 stdout: String::new(),
1200 stderr: String::new(),
1201 stdout_truncated: false,
1202 stderr_truncated: false,
1203 stdout_log_ref: None,
1204 stderr_log_ref: None,
1205 skipped_reason: Some(reason),
1206 };
1207 }
1208
1209 let Some(program) = gate.program else {
1210 return GateResult {
1211 name: gate.name,
1212 ecosystem: gate.ecosystem,
1213 status: GateStatus::Skipped,
1214 command,
1215 cwd: gate.cwd.display().to_string(),
1216 exit_code: None,
1217 duration_ms: 0,
1218 stdout: String::new(),
1219 stderr: String::new(),
1220 stdout_truncated: false,
1221 stderr_truncated: false,
1222 stdout_log_ref: None,
1223 stderr_log_ref: None,
1224 skipped_reason: Some("verifier has no executable program".to_string()),
1225 };
1226 };
1227
1228 let started = Instant::now();
1229 let mut cmd = tokio::process::Command::new(&program);
1230 cmd.args(&gate.args)
1231 .current_dir(&gate.cwd)
1232 // A gate never reads input; an inherited stdin could block it on the
1233 // Engine's own terminal or pipe.
1234 .stdin(Stdio::null())
1235 .stdout(Stdio::piped())
1236 .stderr(Stdio::piped())
1237 .kill_on_drop(true);
1238 // Its own process group, so a timeout or Stop reaches every helper the
1239 // gate started (headless Chrome forks several), not just the leader.
1240 #[cfg(unix)]
1241 cmd.process_group(0);
1242 // Gates run workspace code: start from the sanitized child environment
1243 // and layer only the gate's own declared variables on top.
1244 crate::child_env::apply_to_tokio_command(
1245 &mut cmd,
1246 gate.env
1247 .iter()
1248 .map(|(key, value)| (key.as_str(), value.as_str())),
1249 );
1250
1251 let mut child = match cmd.spawn() {
1252 Ok(child) => child,
1253 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1254 return GateResult {
1255 name: gate.name,
1256 ecosystem: gate.ecosystem,
1257 status: GateStatus::Skipped,
1258 command,
1259 cwd: gate.cwd.display().to_string(),
1260 exit_code: None,
1261 duration_ms: started.elapsed().as_millis() as u64,
1262 stdout: String::new(),
1263 stderr: String::new(),
1264 stdout_truncated: false,
1265 stderr_truncated: false,
1266 stdout_log_ref: None,
1267 stderr_log_ref: None,
1268 skipped_reason: Some(format!("{program} is not installed or not in PATH")),
1269 };
1270 }
1271 Err(err) => {
1272 return GateResult {
1273 name: gate.name,
1274 ecosystem: gate.ecosystem,
1275 status: GateStatus::Failed,
1276 command,
1277 cwd: gate.cwd.display().to_string(),
1278 exit_code: None,
1279 duration_ms: started.elapsed().as_millis() as u64,
1280 stdout: String::new(),
1281 stderr: format!("Failed to spawn verifier: {err}"),
1282 stdout_truncated: false,
1283 stderr_truncated: false,
1284 stdout_log_ref: None,
1285 stderr_log_ref: None,
1286 skipped_reason: None,
1287 };
1288 }
1289 };
1290 // Armed until the gate settles cleanly; dropping this future (Stop)
1291 // kills the whole group.
1292 let mut group = GateProcessGroup::new(child.id());
1293
1294 let mut stdout = GateCapture::new(session_id, &gate.name, "stdout");
1295 let mut stderr = GateCapture::new(session_id, &gate.name, "stderr");
1296 let mut exit: Option<std::io::Result<std::process::ExitStatus>> = None;
1297 let mut pipes_open = true;
1298 let mut timed_out = false;
1299 {
1300 let read_stdout = read_capped(child.stdout.take(), &mut stdout);
1301 let read_stderr = read_capped(child.stderr.take(), &mut stderr);
1302 let mut read_pipes = std::pin::pin!(async move {
1303 tokio::join!(read_stdout, read_stderr);
1304 });
1305 let deadline = tokio::time::sleep(gate.timeout);
1306 let mut deadline = std::pin::pin!(deadline);
1307 let mut drain_deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
1308 loop {
1309 if exit.is_some() && !pipes_open {
1310 break;
1311 }
1312 tokio::select! {
1313 status = child.wait(), if exit.is_none() => {
1314 exit = Some(status);
1315 drain_deadline = Some(Box::pin(tokio::time::sleep(GATE_PIPE_DRAIN_GRACE)));
1316 }
1317 () = &mut read_pipes, if pipes_open => pipes_open = false,
1318 () = async { drain_deadline.as_mut().expect("guarded").await },
1319 if drain_deadline.is_some() => break,
1320 () = &mut deadline => {
1321 timed_out = true;
1322 break;
1323 }
1324 }
1325 }
1326 }
1327
1328 let mut notes = Vec::new();
1329 if timed_out || pipes_open {
1330 group.kill();
1331 if exit.is_none() {
1332 // The group is SIGKILLed; reaping the leader is bounded anyway so
1333 // an uninterruptible child cannot wedge the verifier again.
1334 exit = tokio::time::timeout(GATE_PIPE_DRAIN_GRACE, child.wait())
1335 .await
1336 .ok();
1337 }
1338 } else {
1339 group.disarm();
1340 }
1341 if timed_out {
1342 notes.push(format!(
1343 "[verifier gate timed out after {}s and its process group was killed; raise timeout_ms if it legitimately needs longer]",
1344 gate.timeout.as_secs_f64()
1345 ));
1346 } else if pipes_open {
1347 notes.push(
1348 "[the gate exited but a process it started kept stdout/stderr open; its process group was killed]"
1349 .to_string(),
1350 );
1351 }
1352
1353 let exit_status = match exit {
1354 Some(Ok(status)) => Some(status),
1355 Some(Err(err)) => {
1356 notes.push(format!("[failed to wait for verifier: {err}]"));
1357 None
1358 }
1359 None => None,
1360 };
1361 let stdout = stdout.finish().await;
1362 let stderr = stderr.finish().await;
1363 let mut stderr_text = stderr.text;
1364 for note in notes {
1365 if !stderr_text.is_empty() && !stderr_text.ends_with('\n') {
1366 stderr_text.push('\n');
1367 }
1368 stderr_text.push_str(&note);
1369 }
1370 let passed = !timed_out && exit_status.is_some_and(|status| status.success());
1371 GateResult {
1372 name: gate.name,
1373 ecosystem: gate.ecosystem,
1374 status: if passed {
1375 GateStatus::Passed
1376 } else {
1377 GateStatus::Failed
1378 },
1379 command,
1380 cwd: gate.cwd.display().to_string(),
1381 exit_code: exit_status.and_then(|status| status.code()),
1382 duration_ms: started.elapsed().as_millis() as u64,
1383 stdout: stdout.text,
1384 stderr: stderr_text,
1385 stdout_truncated: stdout.truncated,
1386 stderr_truncated: stderr.truncated,
1387 stdout_log_ref: stdout.log_ref,
1388 stderr_log_ref: stderr.log_ref,
1389 skipped_reason: None,
1390 }
1391 }
1392
1393 /// Read a gate's pipe to EOF into `capture`.
1394 async fn read_capped<R>(pipe: Option<R>, capture: &mut GateCapture)
1395 where
1396 R: tokio::io::AsyncRead + Unpin,
1397 {
1398 use tokio::io::AsyncReadExt;
1399 let Some(mut pipe) = pipe else {
1400 return;
1401 };
1402 let mut chunk = [0u8; 8192];
1403 loop {
1404 match pipe.read(&mut chunk).await {
1405 Ok(0) | Err(_) => return,
1406 Ok(n) => capture.push(&chunk[..n]).await,
1407 }
1408 }
1409 }
1410
1411 /// One gate stream: every byte while it fits in memory, then its first
1412 /// [`GATE_CAPTURE_HEAD_BYTES`] and a rolling last [`GATE_CAPTURE_TAIL_BYTES`],
1413 /// with every byte teed to a session artifact so nothing is lost.
1414 struct GateCapture {
1415 head: Vec<u8>,
1416 tail: std::collections::VecDeque<u8>,
1417 total: u64,
1418 session_id: String,
1419 artifact_id: String,
1420 log: Option<tokio::fs::File>,
1421 /// Opening or writing the log failed; the middle is gone, and the result
1422 /// says so instead of naming a ref.
1423 log_failed: bool,
1424 }
1425
1426 /// What a gate stream contributes to its [`GateResult`].
1427 struct CapturedStream {
1428 text: String,
1429 truncated: bool,
1430 log_ref: Option<String>,
1431 }
1432
1433 impl GateCapture {
1434 fn new(session_id: &str, gate_name: &str, stream: &str) -> Self {
1435 let id = uuid::Uuid::new_v4().simple().to_string();
1436 Self {
1437 head: Vec::new(),
1438 tail: std::collections::VecDeque::new(),
1439 total: 0,
1440 session_id: session_id.to_string(),
1441 artifact_id: crate::artifacts::artifact_id_for_tool_call(&format!(
1442 "gate_{gate_name}_{stream}_{}",
1443 &id[..12]
1444 )),
1445 log: None,
1446 log_failed: false,
1447 }
1448 }
1449
1450 fn overflowed(&self) -> bool {
1451 self.total > (GATE_CAPTURE_HEAD_BYTES + GATE_CAPTURE_TAIL_BYTES) as u64
1452 }
1453
1454 async fn push(&mut self, bytes: &[u8]) {
1455 use tokio::io::AsyncWriteExt;
1456 self.total += bytes.len() as u64;
1457 if !self.overflowed() {
1458 self.head.extend_from_slice(bytes);
1459 return;
1460 }
1461 if self.log.is_none() && !self.log_failed {
1462 // First overflow: everything so far is still in `head`. Save it,
1463 // then keep only the head window in memory.
1464 self.log = self.open_log().await;
1465 self.log_failed = self.log.is_none();
1466 let kept = std::mem::take(&mut self.head);
1467 if let Some(log) = self.log.as_mut()
1468 && log.write_all(&kept).await.is_err()
1469 {
1470 self.log = None;
1471 self.log_failed = true;
1472 }
1473 self.head = kept[..GATE_CAPTURE_HEAD_BYTES.min(kept.len())].to_vec();
1474 self.tail
1475 .extend(&kept[GATE_CAPTURE_HEAD_BYTES.min(kept.len())..]);
1476 }
1477 if let Some(log) = self.log.as_mut()
1478 && log.write_all(bytes).await.is_err()
1479 {
1480 self.log = None;
1481 self.log_failed = true;
1482 }
1483 if self.head.len() < GATE_CAPTURE_HEAD_BYTES {
1484 let room = GATE_CAPTURE_HEAD_BYTES - self.head.len();
1485 self.head.extend_from_slice(&bytes[..room.min(bytes.len())]);
1486 self.tail.extend(&bytes[room.min(bytes.len())..]);
1487 } else {
1488 self.tail.extend(bytes);
1489 }
1490 let excess = self.tail.len().saturating_sub(GATE_CAPTURE_TAIL_BYTES);
1491 self.tail.drain(..excess);
1492 }
1493
1494 async fn open_log(&self) -> Option<tokio::fs::File> {
1495 let relative = crate::artifacts::session_artifact_relative_path(&self.artifact_id);
1496 let path = crate::artifacts::session_artifact_absolute_path(&self.session_id, &relative)?;
1497 if let Some(parent) = path.parent() {
1498 tokio::fs::create_dir_all(parent).await.ok()?;
1499 }
1500 tokio::fs::File::create(&path).await.ok()
1501 }
1502
1503 async fn finish(mut self) -> CapturedStream {
1504 use tokio::io::AsyncWriteExt;
1505 if !self.overflowed() {
1506 return CapturedStream {
1507 text: String::from_utf8_lossy(&self.head).into_owned(),
1508 truncated: false,
1509 log_ref: None,
1510 };
1511 }
1512 let saved = match self.log.as_mut() {
1513 Some(log) => log.flush().await.is_ok() && !self.log_failed,
1514 None => false,
1515 };
1516 let omitted = self.total - (self.head.len() + self.tail.len()) as u64;
1517 let middle = if saved {
1518 format!(
1519 "[{omitted} bytes omitted from the middle; the whole stream is artifact {}: read it with retrieve_tool_result]",
1520 self.artifact_id
1521 )
1522 } else {
1523 format!("[{omitted} bytes omitted from the middle; the full stream could not be saved]")
1524 };
1525 let tail: Vec<u8> = self.tail.into_iter().collect();
1526 CapturedStream {
1527 text: format!(
1528 "{}\n\n{middle}\n\n{}",
1529 String::from_utf8_lossy(&self.head),
1530 String::from_utf8_lossy(&tail)
1531 ),
1532 truncated: true,
1533 log_ref: saved.then_some(self.artifact_id),
1534 }
1535 }
1536 }
1537
1538 /// The process group a foreground gate runs in. Killed on timeout, on
1539 /// held-open pipes, and on drop unless the gate settled cleanly first.
1540 struct GateProcessGroup {
1541 pid: Option<u32>,
1542 }
1543
1544 impl GateProcessGroup {
1545 fn new(pid: Option<u32>) -> Self {
1546 Self { pid }
1547 }
1548
1549 fn disarm(&mut self) {
1550 self.pid = None;
1551 }
1552
1553 fn kill(&mut self) {
1554 let Some(pid) = self.pid.take() else {
1555 return;
1556 };
1557 #[cfg(unix)]
1558 if let Ok(pgid) = libc::pid_t::try_from(pid)
1559 && pgid > 0
1560 {
1561 // SAFETY: kill(2) dereferences no pointers; a negative pid targets
1562 // the group this gate created with process_group(0).
1563 unsafe {
1564 libc::kill(-pgid, libc::SIGKILL);
1565 }
1566 }
1567 // Elsewhere `kill_on_drop` on the child covers the leader.
1568 #[cfg(not(unix))]
1569 let _ = pid;
1570 }
1571 }
1572
1573 impl Drop for GateProcessGroup {
1574 fn drop(&mut self) {
1575 self.kill();
1576 }
1577 }
1578
1579 fn render_command(program: Option<&str>, args: &[String]) -> String {
1580 let mut parts = Vec::new();
1581 parts.push(program.unwrap_or("<unavailable>").to_string());
1582 parts.extend(args.iter().cloned());
1583 parts.join(" ")
1584 }
1585
1586 #[cfg(test)]
1587 mod tests {
1588 use super::*;
1589 use crate::tools::shell::ShellStatus;
1590 use std::time::Duration;
1591 use tempfile::tempdir;
1592
1593 const BACKGROUND_COMPLETION_WAIT_MS: u64 = 30_000;
1594
1595 fn capture_stream(session_id: &str, content: &[u8]) -> CapturedStream {
1596 tokio::runtime::Builder::new_current_thread()
1597 .build()
1598 .expect("runtime")
1599 .block_on(async {
1600 let mut capture = GateCapture::new(session_id, "cargo test", "stdout");
1601 for chunk in content.chunks(8192) {
1602 capture.push(chunk).await;
1603 }
1604 capture.finish().await
1605 })
1606 }
1607
1608 #[test]
1609 fn gate_output_over_1mib_keeps_head_and_tail_and_writes_full_log() {
1610 // #6508: the verifier kept only the first 1 MiB of a stream and
1611 // dropped the end, where a failing gate reports its failure.
1612 let home = tempdir().expect("tempdir");
1613 crate::tools::truncate::with_test_home(home.path(), || {
1614 let content = format!(
1615 "FIRST LINE\n{}LAST LINE: test result: FAILED\n",
1616 "gate output line\n".repeat(100_000)
1617 );
1618 assert!(content.len() > GATE_CAPTURE_HEAD_BYTES + GATE_CAPTURE_TAIL_BYTES);
1619
1620 let captured = capture_stream("session-6508", content.as_bytes());
1621
1622 assert!(captured.truncated);
1623 assert!(captured.text.starts_with("FIRST LINE"));
1624 assert!(captured.text.ends_with("LAST LINE: test result: FAILED\n"));
1625 let reference = captured.log_ref.expect("full log ref");
1626 assert!(captured.text.contains(&reference));
1627 let path = crate::artifacts::session_artifact_absolute_path(
1628 "session-6508",
1629 &crate::artifacts::session_artifact_relative_path(&reference),
1630 )
1631 .expect("artifact path");
1632 let log = fs::read(path).expect("full log");
1633 assert_eq!(log.len(), content.len());
1634 assert_eq!(log, content.as_bytes());
1635 });
1636 }
1637
1638 #[test]
1639 fn gate_output_that_fits_is_kept_whole_without_a_log() {
1640 let content = "short gate output\n".repeat(1_000);
1641 let captured = capture_stream("session-6508", content.as_bytes());
1642 assert!(!captured.truncated);
1643 assert_eq!(captured.text, content);
1644 assert!(captured.log_ref.is_none());
1645 }
1646
1647 #[test]
1648 fn gate_output_whose_log_cannot_be_saved_says_so() {
1649 // An invalid session id has no artifact directory, so no ref is
1650 // promised; head and tail are still kept.
1651 let content = format!("HEAD\n{}TAIL\n", "x".repeat(1_200_000));
1652 let captured = capture_stream("", content.as_bytes());
1653 assert!(captured.truncated);
1654 assert!(captured.log_ref.is_none());
1655 assert!(captured.text.starts_with("HEAD"));
1656 assert!(captured.text.ends_with("TAIL\n"));
1657 assert!(captured.text.contains("the full stream could not be saved"));
1658 }
1659
1660 fn wait_for_completed_shell(
1661 manager: &mut crate::tools::shell::ShellManager,
1662 task_id: &str,
1663 ) -> crate::tools::shell::ShellResult {
1664 let deadline = Instant::now() + Duration::from_millis(BACKGROUND_COMPLETION_WAIT_MS);
1665
1666 loop {
1667 let result = manager
1668 .get_output(task_id, true, 1_000)
1669 .expect("background output");
1670 if result.status != ShellStatus::Running || Instant::now() >= deadline {
1671 return result;
1672 }
1673 std::thread::sleep(Duration::from_millis(50));
1674 }
1675 }
1676
1677 #[test]
1678 fn run_verifiers_requires_user_approval() {
1679 let tool = RunVerifiersTool;
1680 assert_eq!(
1681 tool.approval_requirement(),
1682 ApprovalRequirement::Required,
1683 "run_verifiers executes project code and must require approval"
1684 );
1685 }
1686
1687 #[test]
1688 fn run_verifiers_background_advertises_detached_start() {
1689 let tool = RunVerifiersTool;
1690 let schema = tool.input_schema();
1691 let background_description = schema["properties"]["background"]["description"]
1692 .as_str()
1693 .expect("background description");
1694
1695 assert!(background_description.contains("Bash"));
1696 assert!(background_description.contains("task_shell_wait"));
1697 assert!(
1698 !background_description.contains("exec_shell"),
1699 "live descriptions must not teach the retired exec_shell name"
1700 );
1701 assert!(tool.starts_detached_for(&json!({"background": true})));
1702 assert!(!tool.starts_detached_for(&json!({"profile": "auto"})));
1703 }
1704
1705 #[test]
1706 fn auto_profile_detects_multiple_ecosystems_without_bash() {
1707 let tmp = tempdir().expect("tempdir");
1708 fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest");
1709 fs::write(
1710 tmp.path().join("package.json"),
1711 r#"{"scripts":{"lint":"eslint .","test":"echo ok"}}"#,
1712 )
1713 .expect("package json");
1714 fs::write(tmp.path().join("main.py"), "print('ok')\n").expect("python file");
1715 fs::write(tmp.path().join("go.mod"), "module example.com/app\n").expect("go mod");
1716
1717 let ctx = ToolContext::new(tmp.path());
1718 let gates = build_gate_plan(
1719 &ctx,
1720 VerifierProfile::Auto,
1721 VerifierLevel::Quick,
1722 DEFAULT_MAX_PYTHON_FILES,
1723 &[],
1724 GateTimeouts::DEFAULT,
1725 )
1726 .expect("plan");
1727 let names: BTreeSet<&str> = gates.iter().map(|gate| gate.name.as_str()).collect();
1728
1729 assert!(names.contains("rust-fmt"));
1730 assert!(names.contains("node-lint"));
1731 assert!(names.contains("python-syntax"));
1732 assert!(names.contains("go-test"));
1733 assert!(
1734 gates
1735 .iter()
1736 .filter_map(|gate| gate.program.as_deref())
1737 .all(|program| program != "bash"),
1738 "built-in verifier gates must not require bash"
1739 );
1740 }
1741
1742 #[test]
1743 fn custom_commands_can_choose_bash_explicitly() {
1744 let tmp = tempdir().expect("tempdir");
1745 let ctx = ToolContext::new(tmp.path());
1746 let custom = CustomVerifierInput {
1747 name: "shell-check".to_string(),
1748 program: "bash".to_string(),
1749 args: vec!["-lc".to_string(), "echo ok".to_string()],
1750 cwd: None,
1751 timeout_ms: None,
1752 };
1753
1754 let gate = custom_gate(&ctx, &custom, GateTimeouts::DEFAULT).expect("custom gate");
1755
1756 assert_eq!(gate.program.as_deref(), Some("bash"));
1757 assert_eq!(gate.args, vec!["-lc", "echo ok"]);
1758 }
1759
1760 #[test]
1761 fn node_default_npm_init_test_script_is_not_a_verifier() {
1762 let mut scripts = HashMap::new();
1763 scripts.insert(
1764 "test".to_string(),
1765 "echo \"Error: no test specified\" && exit 1".to_string(),
1766 );
1767
1768 assert!(!has_meaningful_script(&scripts, "test"));
1769 }
1770
1771 #[tokio::test]
1772 async fn run_verifiers_executes_custom_direct_command() {
1773 if !crate::dependencies::RustC::available() {
1774 return;
1775 }
1776 let tmp = tempdir().expect("tempdir");
1777 let ctx = ToolContext::new(tmp.path());
1778 let tool = RunVerifiersTool;
1779 let result = tool
1780 .execute(
1781 json!({
1782 "profile": "auto",
1783 "commands": [
1784 {
1785 "name": "rustc-version",
1786 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1787 "args": ["--version"]
1788 }
1789 ]
1790 }),
1791 &ctx,
1792 )
1793 .await
1794 .expect("execute");
1795
1796 let parsed: RunVerifiersOutput =
1797 serde_json::from_str(&result.content).expect("verifier output json");
1798 assert!(parsed.success, "result: {}", result.content);
1799 assert_eq!(parsed.passed, 1);
1800 assert_eq!(parsed.failed, 0);
1801 assert_eq!(parsed.skipped, 0);
1802 assert!(
1803 parsed.gates[0].stdout.contains("rustc"),
1804 "stdout should include rustc version: {:?}",
1805 parsed.gates[0].stdout
1806 );
1807 }
1808
1809 #[tokio::test]
1810 async fn run_verifiers_emits_verdict_mapping() {
1811 let tmp = tempdir().expect("tempdir");
1812 let ctx = ToolContext::new(tmp.path());
1813 let tool = RunVerifiersTool;
1814
1815 let partial = tool
1816 .execute(json!({"profile": "auto"}), &ctx)
1817 .await
1818 .expect("execute partial verifier");
1819 assert_verdict(&partial, "partial");
1820
1821 if !crate::dependencies::RustC::available() {
1822 return;
1823 }
1824
1825 let pass = tool
1826 .execute(
1827 json!({
1828 "profile": "auto",
1829 "commands": [
1830 {
1831 "name": "rustc-version",
1832 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1833 "args": ["--version"]
1834 }
1835 ]
1836 }),
1837 &ctx,
1838 )
1839 .await
1840 .expect("execute passing verifier");
1841 assert_verdict(&pass, "pass");
1842
1843 let fail = tool
1844 .execute(
1845 json!({
1846 "profile": "auto",
1847 "commands": [
1848 {
1849 "name": "rustc-bad-flag",
1850 "program": crate::dependencies::RustC::resolve().expect("rustc"),
1851 "args": ["--definitely-not-a-rustc-flag"]
1852 }
1853 ]
1854 }),
1855 &ctx,
1856 )
1857 .await
1858 .expect("execute failing verifier");
1859 assert_verdict(&fail, "fail");
1860 }
1861
1862 #[tokio::test]
1863 async fn run_verifiers_cwd_scopes_detection_to_subdir() {
1864 let tmp = tempdir().expect("tempdir");
1865 let sub = tmp.path().join("nested");
1866 std::fs::create_dir(&sub).expect("subdir");
1867 let ctx = ToolContext::new(tmp.path());
1868
1869 // Empty subdir: no gates detected, and the reported workspace is the
1870 // scoped root rather than the parent workspace.
1871 let result = RunVerifiersTool
1872 .execute(json!({"profile": "auto", "cwd": "nested"}), &ctx)
1873 .await
1874 .expect("cwd-scoped execute");
1875 let parsed: RunVerifiersOutput =
1876 serde_json::from_str(&result.content).expect("verifier output json");
1877 assert_eq!(parsed.gate_count, 0);
1878 assert_eq!(
1879 parsed.workspace,
1880 sub.canonicalize().expect("canonical").display().to_string()
1881 );
1882
1883 // Missing dir: refused with the fallback named.
1884 let err = RunVerifiersTool
1885 .execute(json!({"profile": "auto", "cwd": "no-such-dir"}), &ctx)
1886 .await
1887 .expect_err("missing dir must be refused");
1888 let message = err.to_string();
1889 assert!(message.contains("not an existing directory"), "{message}");
1890 assert!(message.contains("drop `cwd`"), "{message}");
1891 }
1892
1893 fn assert_verdict(result: &ToolResult, verifier: &str) {
1894 let parsed: Value = serde_json::from_str(&result.content).expect("verifier output json");
1895 assert_eq!(parsed["verifier_verdict"], verifier, "{}", result.content);
1896 assert!(parsed.get("hunt_verdict").is_none(), "{}", result.content);
1897 assert!(parsed.get("goal_status").is_none(), "{}", result.content);
1898 let metadata = result.metadata.as_ref().expect("verifier metadata");
1899 assert_eq!(metadata["verifier_verdict"], verifier, "{metadata}");
1900 assert!(metadata.get("hunt_verdict").is_none(), "{metadata}");
1901 assert!(metadata.get("task_updates").is_none(), "{metadata}");
1902 }
1903
1904 #[tokio::test]
1905 async fn run_verifiers_background_starts_shell_jobs_and_returns_task_ids() {
1906 let tmp = tempdir().expect("tempdir");
1907 let ctx = ToolContext::new(tmp.path());
1908 let tool = RunVerifiersTool;
1909 // Unix: drive this very libtest binary with `--list` (no rustup, no
1910 // $HOME). Windows: the background gate is rendered with POSIX
1911 // quoting and handed to PowerShell, which cannot parse a quoted
1912 // absolute path with backslashes, so use a bare `cmd` there — the
1913 // listing shape (`name: test`) is the same either way.
1914 #[cfg(not(windows))]
1915 let (program, args) = (
1916 std::env::current_exe()
1917 .expect("test executable")
1918 .to_string_lossy()
1919 .into_owned(),
1920 vec!["--list".to_string()],
1921 );
1922 #[cfg(windows)]
1923 let (program, args) = (
1924 "cmd".to_string(),
1925 vec!["/c".to_string(), "echo test-list: test".to_string()],
1926 );
1927 let result = tool
1928 .execute(
1929 json!({
1930 "profile": "auto",
1931 "background": true,
1932 "commands": [
1933 {
1934 "name": "test-list",
1935 "program": program,
1936 "args": args
1937 }
1938 ]
1939 }),
1940 &ctx,
1941 )
1942 .await
1943 .expect("execute");
1944
1945 let parsed: RunVerifiersBackgroundOutput =
1946 serde_json::from_str(&result.content).expect("background verifier output json");
1947 assert!(parsed.success, "result: {}", result.content);
1948 assert!(parsed.background);
1949 assert_eq!(parsed.started, 1);
1950 assert_eq!(parsed.failed_to_start, 0);
1951 assert!(parsed.summary.contains("Completion is tracked"));
1952 let task_id = parsed.jobs[0]
1953 .task_id
1954 .as_deref()
1955 .expect("background task id");
1956 let metadata = result.metadata.as_ref().expect("metadata");
1957 assert!(
1958 metadata
1959 .get("verifier_background")
1960 .and_then(Value::as_bool)
1961 .unwrap_or(false),
1962 "metadata should mark verifier background start"
1963 );
1964 assert_eq!(
1965 metadata
1966 .get("auto_notify_on_completion")
1967 .and_then(Value::as_bool),
1968 None
1969 );
1970 assert_eq!(
1971 metadata
1972 .get("auto_resume_on_completion")
1973 .and_then(Value::as_bool),
1974 Some(false)
1975 );
1976 assert_eq!(
1977 metadata.get("completion_surface").and_then(Value::as_str),
1978 Some("task_status")
1979 );
1980 assert_eq!(
1981 metadata.get("background_policy").and_then(Value::as_str),
1982 Some("nonblocking")
1983 );
1984
1985 let output = wait_for_completed_shell(
1986 &mut ctx.shell_manager.lock().expect("shell manager"),
1987 task_id,
1988 );
1989 assert_eq!(
1990 output.status,
1991 ShellStatus::Completed,
1992 "stdout: {:?} stderr: {:?}",
1993 output.stdout,
1994 output.stderr
1995 );
1996 assert!(
1997 output.stdout.lines().any(|line| line.ends_with(": test")),
1998 "stdout should include the test listing: {:?}",
1999 output.stdout
2000 );
2001 }
2002
2003 /// Headless Chrome (`--print-to-pdf` / `--dump-dom`) writes its output and
2004 /// then never exits while a helper keeps stdout/stderr open. This script
2005 /// reproduces that shape deterministically: a leader plus a background
2006 /// child, both holding the gate's pipes, neither ever exiting.
2007 #[cfg(unix)]
2008 fn hung_gate_script(pidfile: &Path) -> String {
2009 format!(
2010 "sleep 600 & echo $! > '{p}'; echo $$ >> '{p}'; echo started; wait",
2011 p = pidfile.display()
2012 )
2013 }
2014
2015 #[cfg(unix)]
2016 async fn read_gate_pids(pidfile: &Path) -> Vec<libc::pid_t> {
2017 let deadline = Instant::now() + Duration::from_secs(10);
2018 loop {
2019 let pids: Vec<libc::pid_t> = fs::read_to_string(pidfile)
2020 .unwrap_or_default()
2021 .lines()
2022 .filter_map(|line| line.trim().parse().ok())
2023 .collect();
2024 if pids.len() >= 2 {
2025 return pids;
2026 }
2027 assert!(Instant::now() < deadline, "hung gate never started");
2028 tokio::time::sleep(Duration::from_millis(20)).await;
2029 }
2030 }
2031
2032 #[cfg(unix)]
2033 async fn assert_pids_gone(pids: &[libc::pid_t]) {
2034 let deadline = Instant::now() + Duration::from_secs(5);
2035 loop {
2036 // SAFETY: signal 0 only probes for existence.
2037 let alive: Vec<_> = pids
2038 .iter()
2039 .copied()
2040 .filter(|pid| unsafe { libc::kill(*pid, 0) } == 0)
2041 .collect();
2042 if alive.is_empty() {
2043 return;
2044 }
2045 if Instant::now() >= deadline {
2046 for pid in &alive {
2047 // SAFETY: best-effort cleanup of the leaked test processes.
2048 unsafe {
2049 libc::kill(*pid, libc::SIGKILL);
2050 }
2051 }
2052 panic!("verifier gate processes still running: {alive:?}");
2053 }
2054 tokio::time::sleep(Duration::from_millis(20)).await;
2055 }
2056 }
2057
2058 #[cfg(unix)]
2059 #[tokio::test]
2060 async fn run_verifiers_hung_gate_times_out_and_kills_its_process_group() {
2061 let tmp = tempdir().expect("tempdir");
2062 let pidfile = tmp.path().join("pids");
2063 let ctx = ToolContext::new(tmp.path());
2064 let started = Instant::now();
2065 let result = tokio::time::timeout(
2066 Duration::from_secs(20),
2067 RunVerifiersTool.execute(
2068 json!({
2069 "profile": "auto",
2070 "commands": [{
2071 "name": "hung",
2072 "program": "/bin/sh",
2073 "args": ["-c", hung_gate_script(&pidfile)],
2074 "timeout_ms": 500
2075 }]
2076 }),
2077 &ctx,
2078 ),
2079 )
2080 .await
2081 .expect("a hung verifier gate must not hang run_verifiers")
2082 .expect("execute");
2083 assert!(
2084 started.elapsed() < Duration::from_secs(10),
2085 "timed-out gate took {:?}",
2086 started.elapsed()
2087 );
2088
2089 let parsed: RunVerifiersOutput =
2090 serde_json::from_str(&result.content).expect("verifier output json");
2091 assert_eq!(parsed.failed, 1, "result: {}", result.content);
2092 let gate = &parsed.gates[0];
2093 assert_eq!(gate.status, GateStatus::Failed);
2094 assert!(
2095 gate.stderr.contains("timed out"),
2096 "stderr: {:?}",
2097 gate.stderr
2098 );
2099 assert!(
2100 gate.stdout.contains("started"),
2101 "partial stdout kept: {:?}",
2102 gate.stdout
2103 );
2104 assert_pids_gone(&read_gate_pids(&pidfile).await).await;
2105 }
2106
2107 #[cfg(unix)]
2108 #[tokio::test]
2109 async fn run_verifiers_dropped_on_stop_kills_running_gates() {
2110 let tmp = tempdir().expect("tempdir");
2111 let pidfile = tmp.path().join("pids");
2112 let ctx = ToolContext::new(tmp.path());
2113 let script = hung_gate_script(&pidfile);
2114 // Stop drops the active tool future (turn_loop's biased select on the
2115 // cancel token). That drop must take the gate's processes with it.
2116 let task = tokio::spawn(async move {
2117 RunVerifiersTool
2118 .execute(
2119 json!({
2120 "profile": "auto",
2121 "commands": [{
2122 "name": "hung",
2123 "program": "/bin/sh",
2124 "args": ["-c", script]
2125 }]
2126 }),
2127 &ctx,
2128 )
2129 .await
2130 });
2131 let pids = read_gate_pids(&pidfile).await;
2132 task.abort();
2133 let _ = task.await;
2134 assert_pids_gone(&pids).await;
2135 }
2136
2137 #[test]
2138 fn built_in_gates_take_the_call_timeout_and_custom_gates_keep_their_own() {
2139 let tmp = tempdir().expect("tempdir");
2140 fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").expect("cargo manifest");
2141 let ctx = ToolContext::new(tmp.path());
2142 let custom = [
2143 CustomVerifierInput {
2144 name: "default".to_string(),
2145 program: "true".to_string(),
2146 ..CustomVerifierInput::default()
2147 },
2148 CustomVerifierInput {
2149 name: "explicit".to_string(),
2150 program: "true".to_string(),
2151 timeout_ms: Some(42_000),
2152 ..CustomVerifierInput::default()
2153 },
2154 ];
2155 let long = MAX_GATE_TIMEOUT_MS;
2156 let gates = build_gate_plan(
2157 &ctx,
2158 VerifierProfile::Rust,
2159 VerifierLevel::Full,
2160 DEFAULT_MAX_PYTHON_FILES,
2161 &custom,
2162 GateTimeouts::for_call(Some(long)).expect("in range"),
2163 )
2164 .expect("plan");
2165 let rust: Vec<_> = gates
2166 .iter()
2167 .filter(|gate| gate.ecosystem == "rust")
2168 .collect();
2169 assert!(!rust.is_empty(), "rust gates detected");
2170 // A `full` run on a large workspace is no longer capped at 600s.
2171 for gate in rust {
2172 assert_eq!(gate.timeout, Duration::from_millis(long), "{}", gate.name);
2173 }
2174 let timeout_of = |name: &str| {
2175 gates
2176 .iter()
2177 .find(|gate| gate.name == name)
2178 .unwrap_or_else(|| panic!("gate {name}"))
2179 .timeout
2180 };
2181 assert_eq!(
2182 timeout_of("default"),
2183 Duration::from_millis(DEFAULT_CUSTOM_GATE_TIMEOUT_MS)
2184 );
2185 assert_eq!(timeout_of("explicit"), Duration::from_millis(42_000));
2186 assert!(GateTimeouts::for_call(Some(0)).is_err());
2187 assert!(GateTimeouts::for_call(Some(long + 1)).is_err());
2188 }
2189
2190 #[tokio::test]
2191 async fn run_verifiers_background_refuses_timeout_ms_it_cannot_enforce() {
2192 let tmp = tempdir().expect("tempdir");
2193 let ctx = ToolContext::new(tmp.path());
2194 for input in [
2195 json!({"background": true, "timeout_ms": 1000,
2196 "commands": [{"name": "a", "program": "true"}]}),
2197 json!({"background": true,
2198 "commands": [{"name": "a", "program": "true", "timeout_ms": 1000}]}),
2199 ] {
2200 let err = RunVerifiersTool
2201 .execute(input.clone(), &ctx)
2202 .await
2203 .expect_err("background timeout_ms must be refused");
2204 assert!(err.to_string().contains("foreground"), "{input}: {err}");
2205 }
2206 let jobs = ctx.shell_manager.lock().expect("shell manager").list_jobs();
2207 assert!(jobs.is_empty(), "nothing may start: {jobs:?}");
2208 }
2209
2210 #[cfg(unix)]
2211 #[tokio::test]
2212 async fn run_gate_does_not_inherit_parent_secret_env_but_keeps_gate_env() {
2213 use crate::test_support::{EnvVarGuard, lock_test_env};
2214 let _env_lock = lock_test_env();
2215 let _secret = EnvVarGuard::set("CODEWHALE_TEST_VERIFIER_SECRET", "verifier-secret-value");
2216 let tmp = tempdir().expect("tempdir");
2217 let gate = VerifierGate {
2218 name: "env-probe".to_string(),
2219 ecosystem: "custom".to_string(),
2220 cwd: tmp.path().to_path_buf(),
2221 program: Some("/bin/sh".to_string()),
2222 args: vec![
2223 "-c".to_string(),
2224 "printf '%s|%s' \"${CODEWHALE_TEST_VERIFIER_SECRET-unset}\" \"${GATE_DECLARED-missing}\""
2225 .to_string(),
2226 ],
2227 env: vec![("GATE_DECLARED".to_string(), "declared".to_string())],
2228 skipped_reason: None,
2229 timeout: Duration::from_secs(30),
2230 };
2231 let result = run_gate(gate, "env-probe-test").await;
2232 assert_eq!(result.stdout.trim(), "unset|declared", "{result:?}");
2233 }
2234 }
2235
2235 lines RUST