返回 CodeWhale
tasks.rs
根目录 / crates / tui / src / tools / tasks.rs
1 //! Durable task, gate, and PR-attempt tools.
2
3 use std::path::{Path, PathBuf};
4 use std::time::Instant;
5
6 use async_trait::async_trait;
7 use chrono::Utc;
8 use serde_json::{Value, json};
9 use tokio::process::Command;
10 use uuid::Uuid;
11
12 use crate::dependencies::ExternalTool;
13 use crate::task_manager::{
14 NewTaskRequest, TaskArtifactRef, TaskAttemptRecord, TaskCancelDisposition, TaskGateRecord,
15 TaskRecord,
16 };
17 use crate::tools::shell::BashTool;
18 use crate::tools::spec::{
19 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
20 optional_bool, optional_bool_opt, optional_str, optional_u64, required_str,
21 };
22 use crate::work_graph::{
23 CancelOutcome, OperationIntent, OperationObservation, OperationOwnerSnapshot, OwnerState,
24 task_owner_snapshot,
25 };
26 use codewhale_execpolicy::command_safety::{SafetyLevel, analyze_command};
27
28 const MAX_SUMMARY_CHARS: usize = 900;
29 const DEFAULT_GATE_TIMEOUT_MS: u64 = 120_000;
30 const MAX_GATE_TIMEOUT_MS: u64 = 600_000;
31
32 fn build_gate_command_parts(command: &str) -> (String, Vec<String>) {
33 (
34 "/bin/sh".to_string(),
35 vec!["-c".to_string(), command.to_string()],
36 )
37 }
38
39 /// Gate commands run workspace code, so they start like an `exec_shell`
40 /// command: inside this session's sandbox, from the sanitized environment.
41 fn build_gate_command(
42 command: &str,
43 cwd: &Path,
44 context: &ToolContext,
45 timeout: std::time::Duration,
46 ) -> Result<Command, ToolError> {
47 let (program, args) = build_gate_command_parts(command);
48 crate::tools::shell::sandboxed_runner_command(context, &program, args, cwd, timeout)
49 }
50
51 fn task_shell_wait_input(mut input: Value) -> Value {
52 if input.get("wait").is_none_or(Value::is_null)
53 && input.get("block").is_none_or(Value::is_null)
54 && let Some(object) = input.as_object_mut()
55 {
56 object.insert("wait".to_string(), Value::Bool(false));
57 }
58 input
59 }
60
61 /// Unified durable-task tool (piagent phase B).
62 ///
63 /// The model sees one tool, `tasks`, with an `action` parameter routing to
64 /// the per-action logic below. The per-action `task_*` / `pr_attempt_*`
65 /// execution aliases were removed in v0.9.3.
66 ///
67 /// `TaskShellStartTool` / `TaskShellWaitTool` stay separate: the registry
68 /// gates them behind `allow_shell` (see `with_runtime_task_shell_tools`),
69 /// which differs from every other action in this family.
70 pub struct TasksTool {
71 name: &'static str,
72 forced_action: Option<&'static str>,
73 read_only: bool,
74 }
75
76 pub struct TaskShellStartTool;
77 pub struct TaskShellWaitTool;
78
79 /// Actions the Plan-mode read-only surface exposes.
80 const READ_ACTIONS: &[&str] = &["list", "read", "pr_attempt_list", "pr_attempt_read"];
81 const ALL_ACTIONS: &[&str] = &[
82 "create",
83 "list",
84 "read",
85 "cancel",
86 "gate_run",
87 "pr_attempt_record",
88 "pr_attempt_list",
89 "pr_attempt_read",
90 "pr_attempt_preflight",
91 ];
92
93 impl TasksTool {
94 pub const fn new(name: &'static str) -> Self {
95 Self {
96 name,
97 forced_action: None,
98 read_only: false,
99 }
100 }
101
102 /// Plan-mode variant: only the read-only actions are advertised and routed.
103 pub const fn read_only(name: &'static str) -> Self {
104 Self {
105 name,
106 forced_action: None,
107 read_only: true,
108 }
109 }
110
111 #[cfg(test)]
112 pub const fn alias(name: &'static str, action: &'static str) -> Self {
113 Self {
114 name,
115 forced_action: Some(action),
116 read_only: false,
117 }
118 }
119
120 fn allowed_actions(&self) -> &'static [&'static str] {
121 if self.read_only {
122 READ_ACTIONS
123 } else {
124 ALL_ACTIONS
125 }
126 }
127
128 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
129 let action = match self.forced_action {
130 Some(action) => action,
131 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
132 ToolError::invalid_input(format!(
133 "tasks: missing `action` (one of: {})",
134 self.allowed_actions().join(", ")
135 ))
136 })?,
137 };
138 if self.allowed_actions().contains(&action) {
139 Ok(action)
140 } else {
141 Err(ToolError::invalid_input(format!(
142 "tasks: invalid action `{action}` (one of: {})",
143 self.allowed_actions().join(", ")
144 )))
145 }
146 }
147
148 fn action_is_read(action: &str) -> bool {
149 READ_ACTIONS.contains(&action)
150 }
151
152 /// Whether this action executes code (drives static capabilities and the
153 /// Plan-mode "no ExecutesCode tools" invariant).
154 fn action_executes_code(action: &str) -> bool {
155 action == "gate_run"
156 }
157
158 fn action_requires_approval(action: &str) -> bool {
159 !Self::action_is_read(action)
160 }
161 }
162
163 #[async_trait]
164 impl ToolSpec for TasksTool {
165 fn name(&self) -> &'static str {
166 self.name
167 }
168
169 fn model_visible(&self) -> bool {
170 self.forced_action.is_none()
171 }
172
173 fn description(&self) -> &'static str {
174 match self.forced_action {
175 Some("create") => {
176 "Create/enqueue a durable background task through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents."
177 }
178 Some("list") => {
179 "List recent durable tasks with status, linked thread/turn ids, and concise summaries."
180 }
181 Some("read") => {
182 "Read durable task detail including timeline, checklist, gate evidence, artifacts, and PR attempts."
183 }
184 Some("cancel") => {
185 "Cancel a queued or running durable task through TaskManager. Requires approval because it changes work state."
186 }
187 Some("gate_run") => {
188 "Run an approved verification gate command and return structured evidence. When inside a durable task, the gate result and log artifact are attached to that task. Dangerous commands are BLOCKED unless auto-approve is enabled; default timeout 120s."
189 }
190 Some("pr_attempt_record") => {
191 "Capture current git diff as a durable PR work attempt with patch artifact, changed files, and verification notes. Requires approval because it records work state."
192 }
193 Some("pr_attempt_list") => "List PR attempts recorded on a durable task.",
194 Some("pr_attempt_read") => {
195 "Read one recorded PR attempt and its patch artifact reference."
196 }
197 Some("pr_attempt_preflight") => {
198 "Run `git apply --check` for a recorded attempt patch. This is a no-mutation preflight and itself requires approval; the actual apply stays a separate explicit step."
199 }
200 _ if self.read_only => {
201 "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"."
202 }
203 _ => {
204 "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Use task_shell_start for long-running shell work."
205 }
206 }
207 }
208
209 fn input_schema(&self) -> Value {
210 if let Some(action) = self.forced_action {
211 return legacy_action_schema(action);
212 }
213 let actions: Vec<&str> = self.allowed_actions().to_vec();
214 let mut properties = serde_json::Map::new();
215 properties.insert(
216 "action".to_string(),
217 json!({
218 "type": "string",
219 "enum": actions,
220 "description": "Action to perform."
221 }),
222 );
223 if !self.read_only {
224 properties.insert(
225 "prompt".to_string(),
226 json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }),
227 );
228 properties.insert(
229 "name".to_string(),
230 json!({ "type": "string", "description": "Short run name shown in queues; omit to derive from the prompt. (action=create)" }),
231 );
232 properties.insert(
233 "model_provider".to_string(),
234 json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }),
235 );
236 properties.insert(
237 "model_provider_id".to_string(),
238 json!({ "type": "string", "description": "Exact configured provider id, including named custom routes. Keeps the model on that route." }),
239 );
240 properties.insert(
241 "model".to_string(),
242 json!({ "type": "string", "description": "(action=create)" }),
243 );
244 properties.insert(
245 "workspace".to_string(),
246 json!({ "type": "string", "description": "Workspace path; defaults to current workspace. (action=create)" }),
247 );
248 properties.insert(
249 "mode".to_string(),
250 json!({ "type": "string", "enum": ["agent", "plan", "operate"], "description": "(action=create)" }),
251 );
252 properties.insert(
253 "allow_shell".to_string(),
254 json!({ "type": "boolean", "description": "(action=create)" }),
255 );
256 properties.insert(
257 "trust_mode".to_string(),
258 json!({ "type": "boolean", "description": "(action=create)" }),
259 );
260 properties.insert(
261 "auto_approve".to_string(),
262 json!({ "type": "boolean", "description": "(action=create)" }),
263 );
264 properties.insert(
265 "gate".to_string(),
266 json!({
267 "type": "string",
268 "enum": ["fmt", "check", "clippy", "test", "custom"],
269 "description": "Gate category. (action=gate_run)"
270 }),
271 );
272 properties.insert(
273 "command".to_string(),
274 json!({ "type": "string", "description": "Command to run. (action=gate_run)" }),
275 );
276 properties.insert(
277 "cwd".to_string(),
278 json!({ "type": "string", "description": "Optional working directory within the workspace. (action=gate_run)" }),
279 );
280 properties.insert(
281 "timeout_ms".to_string(),
282 json!({ "type": "integer", "minimum": 1000, "maximum": 600000, "description": "(action=gate_run)" }),
283 );
284 properties.insert(
285 "attempt_group_id".to_string(),
286 json!({ "type": "string", "description": "(action=pr_attempt_record)" }),
287 );
288 properties.insert(
289 "attempt_index".to_string(),
290 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
291 );
292 properties.insert(
293 "attempt_count".to_string(),
294 json!({ "type": "integer", "minimum": 1, "description": "(action=pr_attempt_record)" }),
295 );
296 properties.insert(
297 "summary".to_string(),
298 json!({ "type": "string", "description": "Attempt summary (action=pr_attempt_record)." }),
299 );
300 properties.insert(
301 "verification".to_string(),
302 json!({ "type": "array", "items": { "type": "string" }, "description": "(action=pr_attempt_record)" }),
303 );
304 }
305 properties.insert(
306 "attempt_id".to_string(),
307 json!({ "type": "string", "description": "(action=pr_attempt_read/preflight)" }),
308 );
309 properties.insert(
310 "task_id".to_string(),
311 json!({ "type": "string", "description": "Full task id or unambiguous prefix (action=read/cancel); task id, defaults to active task (action=pr_attempt_*)." }),
312 );
313 properties.insert(
314 "limit".to_string(),
315 json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "(action=list)" }),
316 );
317 json!({
318 "type": "object",
319 "properties": properties,
320 "additionalProperties": false
321 })
322 }
323
324 fn capabilities(&self) -> Vec<ToolCapability> {
325 match self.forced_action {
326 Some(action) if Self::action_executes_code(action) => {
327 vec![
328 ToolCapability::ExecutesCode,
329 ToolCapability::RequiresApproval,
330 ]
331 }
332 Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly],
333 Some(_) => vec![ToolCapability::RequiresApproval],
334 None if self.read_only => vec![ToolCapability::ReadOnly],
335 None => vec![
336 ToolCapability::ExecutesCode,
337 ToolCapability::RequiresApproval,
338 ],
339 }
340 }
341
342 fn approval_requirement(&self) -> ApprovalRequirement {
343 match self.forced_action {
344 Some(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
345 Some(_) => ApprovalRequirement::Auto,
346 None if self.read_only => ApprovalRequirement::Auto,
347 None => ApprovalRequirement::Required,
348 }
349 }
350
351 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
352 match self.resolve_action(input) {
353 Ok(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required,
354 Ok(_) => ApprovalRequirement::Auto,
355 Err(_) => self.approval_requirement(),
356 }
357 }
358
359 fn is_read_only_for(&self, input: &Value) -> bool {
360 match self.resolve_action(input) {
361 Ok(action) => Self::action_is_read(action),
362 Err(_) => self.is_read_only(),
363 }
364 }
365
366 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
367 crate::core::engine::tool_catalog::enforce_tool_denial(
368 context,
369 self.name(),
370 &json!({"action": self.resolve_action(&input)?}),
371 )?;
372 match self.resolve_action(&input)? {
373 "create" => self.execute_create(&input, context).await,
374 "list" => self.execute_list(&input, context).await,
375 "read" => self.execute_read(&input, context).await,
376 "cancel" => self.execute_cancel(&input, context).await,
377 "gate_run" => self.execute_gate_run(&input, context).await,
378 "pr_attempt_record" => self.execute_pr_attempt_record(&input, context).await,
379 "pr_attempt_list" => self.execute_pr_attempt_list(&input, context).await,
380 "pr_attempt_read" => self.execute_pr_attempt_read(&input, context).await,
381 "pr_attempt_preflight" => self.execute_pr_attempt_preflight(&input, context).await,
382 action => Err(ToolError::invalid_input(format!(
383 "tasks: invalid action `{action}`"
384 ))),
385 }
386 }
387 }
388
389 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
390 /// registrations report an identical contract to the pre-unification tools.
391 fn legacy_action_schema(action: &str) -> Value {
392 match action {
393 "create" => json!({
394 "type": "object",
395 "properties": {
396 "prompt": { "type": "string", "description": "Work prompt for the durable task." },
397 "model": { "type": "string" },
398 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
399 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
400 "workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." },
401 "mode": { "type": "string", "enum": ["agent", "plan", "operate"] },
402 "allow_shell": { "type": "boolean" },
403 "trust_mode": { "type": "boolean" },
404 "auto_approve": { "type": "boolean" }
405 },
406 "required": ["prompt"],
407 "additionalProperties": false
408 }),
409 "list" => json!({
410 "type": "object",
411 "properties": {
412 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
413 },
414 "additionalProperties": false
415 }),
416 "read" | "cancel" => json!({
417 "type": "object",
418 "properties": {
419 "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." }
420 },
421 "required": ["task_id"],
422 "additionalProperties": false
423 }),
424 "gate_run" => json!({
425 "type": "object",
426 "properties": {
427 "gate": {
428 "type": "string",
429 "enum": ["fmt", "check", "clippy", "test", "custom"],
430 "description": "Gate category."
431 },
432 "command": { "type": "string", "description": "Command to run." },
433 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
434 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 }
435 },
436 "required": ["gate", "command"],
437 "additionalProperties": false
438 }),
439 "pr_attempt_record" => json!({
440 "type": "object",
441 "properties": {
442 "task_id": { "type": "string", "description": "Task to attach to; defaults to active task." },
443 "attempt_group_id": { "type": "string" },
444 "attempt_index": { "type": "integer", "minimum": 1 },
445 "attempt_count": { "type": "integer", "minimum": 1 },
446 "summary": { "type": "string" },
447 "verification": { "type": "array", "items": { "type": "string" } }
448 },
449 "required": ["summary"],
450 "additionalProperties": false
451 }),
452 "pr_attempt_list" => task_id_schema(),
453 // pr_attempt_read / pr_attempt_preflight share the attempt-id schema.
454 _ => json!({
455 "type": "object",
456 "properties": {
457 "task_id": { "type": "string", "description": "Task id; defaults to active task." },
458 "attempt_id": { "type": "string" }
459 },
460 "required": ["attempt_id"],
461 "additionalProperties": false
462 }),
463 }
464 }
465
466 impl TasksTool {
467 async fn execute_create(
468 &self,
469 input: &Value,
470 context: &ToolContext,
471 ) -> Result<ToolResult, ToolError> {
472 let manager = context
473 .runtime
474 .task_manager
475 .as_ref()
476 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
477 // A task may run in another directory only where this session could
478 // already reach: inside its workspace, or anywhere in trust mode.
479 let workspace = match optional_str(input, "workspace")? {
480 Some(raw) => context.resolve_path(raw)?,
481 None => context.workspace.clone(),
482 };
483 let prompt = required_str(input, "prompt")?.to_string();
484 // Authority declarations: read strictly (a malformed value that
485 // silently reads as "unset" is a restriction that evaporates), then
486 // capped at what this session holds.
487 let (allow_shell, trust_mode, auto_approve) = context.cap_delegated_authority(
488 optional_bool_opt(input, "allow_shell")?,
489 optional_bool_opt(input, "trust_mode")?,
490 optional_bool_opt(input, "auto_approve")?,
491 );
492 let req = NewTaskRequest {
493 prompt: prompt.clone(),
494 name: optional_str(input, "name")?.map(ToString::to_string),
495 model: optional_str(input, "model")?.map(ToString::to_string),
496 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
497 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
498 workspace: Some(workspace),
499 mode: optional_str(input, "mode")?.map(ToString::to_string),
500 allow_shell,
501 trust_mode,
502 auto_approve,
503 // The task runs on the posture this session is in. The bits above
504 // are declarations the engine only reads when no posture is given,
505 // and a task started from a session must not run under authority
506 // that session was never granted.
507 permission_posture: Some(
508 crate::runtime_policy::approval_wire(context.approval_mode).to_string(),
509 ),
510 owner_session_id: Some(context.state_namespace.clone()),
511 };
512 let task_id = crate::task_manager::TaskManager::new_task_id();
513 if let Some(work) = context.runtime.work.as_ref()
514 && let Err(err) = work.register_operation(
515 &context.state_namespace,
516 OperationIntent::new(
517 format!("task:{task_id}"),
518 prompt,
519 true,
520 "task_create",
521 &task_id,
522 ),
523 )
524 {
525 // Bookkeeping must not veto the task: every later reconcile is
526 // guarded by `has_operation_binding`, so an unbound task merely
527 // goes unreported on the Work surface.
528 tracing::warn!(
529 task_id = %task_id,
530 error = %err,
531 "task work-graph registration skipped; running unbound"
532 );
533 }
534 let task = match manager.add_task_with_id(req, task_id.clone()).await {
535 Ok(task) => task,
536 Err(err) => {
537 if let Some(work) = context.runtime.work.as_ref() {
538 let _ = work.reconcile_operation(
539 &context.state_namespace,
540 OperationOwnerSnapshot::new(
541 format!("task:{task_id}"),
542 OwnerState::Failed,
543 1,
544 Utc::now().timestamp_millis(),
545 ),
546 );
547 }
548 return Err(ToolError::execution_failed(err.to_string()));
549 }
550 };
551 let lifecycle_warning = reconcile_task_record(context, &task).err().map(|err| {
552 tracing::warn!(task_id = %task.id, error = %err, "task was created but Work lifecycle reconciliation failed");
553 err.to_string()
554 });
555 task_result_with_lifecycle_warning("task_create", &task, lifecycle_warning.as_deref())
556 }
557
558 async fn execute_list(
559 &self,
560 input: &Value,
561 context: &ToolContext,
562 ) -> Result<ToolResult, ToolError> {
563 let manager = context
564 .runtime
565 .task_manager
566 .as_ref()
567 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
568 let limit = optional_u64(input, "limit", 20)?.clamp(1, 100) as usize;
569 let tasks = manager
570 .list_tasks_for_owner(Some(limit), None, &context.state_namespace)
571 .await
572 .map_err(|error| ToolError::execution_failed(error.to_string()))?;
573 ToolResult::json(&json!({
574 "summary": format!("{} durable task(s)", tasks.len()),
575 "tasks": tasks,
576 }))
577 .map_err(|e| ToolError::execution_failed(e.to_string()))
578 }
579
580 async fn execute_read(
581 &self,
582 input: &Value,
583 context: &ToolContext,
584 ) -> Result<ToolResult, ToolError> {
585 let task = read_task_for_input(input, context).await?;
586 task_result("task_read", &task)
587 }
588
589 async fn execute_cancel(
590 &self,
591 input: &Value,
592 context: &ToolContext,
593 ) -> Result<ToolResult, ToolError> {
594 let manager = context
595 .runtime
596 .task_manager
597 .as_ref()
598 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
599 let task_id = required_str(input, "task_id")?;
600 let cancellation = if context.runtime.active_task_id.as_deref() == Some(task_id) {
601 // `active_task_id` is stamped from the immutable runtime thread
602 // record, not model input. Preserve self-cancel for a running task,
603 // but fail closed for ownerless legacy records.
604 manager.cancel_task_for_active_runtime(task_id).await
605 } else {
606 manager
607 .cancel_task_for_owner(task_id, &context.state_namespace)
608 .await
609 }
610 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
611 let task = cancellation.task;
612 let cancel_outcome = match cancellation.disposition {
613 TaskCancelDisposition::Forced => CancelOutcome::Forced,
614 TaskCancelDisposition::Requested => CancelOutcome::Requested,
615 TaskCancelDisposition::AlreadyFinished => CancelOutcome::AlreadyFinished,
616 };
617 let mut lifecycle_warnings = Vec::new();
618 if let Some(work) = context.runtime.work.as_ref() {
619 let external = format!("task:{}", task.id);
620 if work.has_operation_binding(Some(&context.state_namespace), &external)
621 && let Err(err) = work.reconcile_observation(
622 &context.state_namespace,
623 &external,
624 OperationObservation::CancelUpdate {
625 outcome: cancel_outcome,
626 at: Utc::now().timestamp_millis(),
627 },
628 )
629 {
630 tracing::warn!(task_id = %task.id, error = %err, "task was cancelled but Work cancel reconciliation failed");
631 lifecycle_warnings.push(err);
632 }
633 }
634 if let Err(err) = reconcile_task_record(context, &task) {
635 tracing::warn!(task_id = %task.id, error = %err, "task cancellation succeeded but owner-state reconciliation failed");
636 lifecycle_warnings.push(err.to_string());
637 }
638 let lifecycle_warning =
639 (!lifecycle_warnings.is_empty()).then(|| lifecycle_warnings.join("; "));
640 task_result_with_lifecycle_warning("task_cancel", &task, lifecycle_warning.as_deref())
641 }
642
643 async fn execute_gate_run(
644 &self,
645 input: &Value,
646 context: &ToolContext,
647 ) -> Result<ToolResult, ToolError> {
648 crate::core::engine::tool_catalog::enforce_tool_denial(context, "task_gate_run", input)?;
649 if context.shell_policy != crate::worker_profile::ShellPolicy::Full {
650 return Err(ToolError::permission_denied(
651 "Gate commands require full shell permission.",
652 ));
653 }
654 let gate = required_str(input, "gate")?.to_string();
655 let command = required_str(input, "command")?.to_string();
656 let timeout_ms = optional_u64(input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
657 .clamp(1_000, MAX_GATE_TIMEOUT_MS);
658 let cwd = resolve_cwd(context, optional_str(input, "cwd")?)?;
659
660 let safety = analyze_command(&command);
661 if !context.auto_approve && matches!(safety.level, SafetyLevel::Dangerous) {
662 return Ok(ToolResult::error(format!(
663 "BLOCKED: gate command classified dangerous: {}",
664 safety.reasons.join("; ")
665 ))
666 .with_metadata(json!({
667 "safety_level": "dangerous",
668 "blocked": true,
669 "reasons": safety.reasons,
670 })));
671 }
672
673 let started = Instant::now();
674 let mut cmd = build_gate_command(
675 &command,
676 &cwd,
677 context,
678 std::time::Duration::from_millis(timeout_ms),
679 )?;
680 // Contained: when the timeout elapses the gate's whole process tree
681 // is killed, instead of leaving it running behind a "timeout" result,
682 // and what it wrote until then still reaches the log.
683 let output = crate::process_tree::contained_output_until(
684 &mut cmd,
685 tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)),
686 )
687 .await;
688
689 let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
690 let (exit_code, stdout, stderr, timed_out, spawn_error) = match output {
691 Ok(run) => (
692 if run.stopped {
693 None
694 } else {
695 run.output.status.code()
696 },
697 String::from_utf8_lossy(&run.output.stdout).to_string(),
698 String::from_utf8_lossy(&run.output.stderr).to_string(),
699 run.stopped,
700 None,
701 ),
702 Err(err) => (
703 None,
704 String::new(),
705 String::new(),
706 false,
707 Some(err.to_string()),
708 ),
709 };
710
711 let full_log = format!(
712 "$ {command}\n\n[stdout]\n{stdout}\n\n[stderr]\n{stderr}\n{}",
713 spawn_error
714 .as_ref()
715 .map(|e| format!("\n[spawn_error]\n{e}\n"))
716 .unwrap_or_default()
717 );
718 let summary_source = if !stderr.trim().is_empty() {
719 stderr.as_str()
720 } else if !stdout.trim().is_empty() {
721 stdout.as_str()
722 } else {
723 spawn_error.as_deref().unwrap_or("(no output)")
724 };
725 let summary = summarize(summary_source, MAX_SUMMARY_CHARS);
726 let status = if timed_out {
727 "timeout"
728 } else if spawn_error.is_some() {
729 "failed"
730 } else if exit_code == Some(0) {
731 "passed"
732 } else {
733 "failed"
734 };
735 let classification = classify_gate_failure(&gate, status, timed_out, &stderr, &stdout);
736 let log_path = write_runtime_artifact(context, "gate", &full_log).await?;
737 let gate_record = TaskGateRecord {
738 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
739 gate: gate.clone(),
740 command: command.clone(),
741 cwd: cwd.clone(),
742 exit_code,
743 status: status.to_string(),
744 classification,
745 duration_ms,
746 summary: summary.clone(),
747 log_path: log_path.clone(),
748 recorded_at: Utc::now(),
749 };
750
751 let content = json!({
752 "gate": gate_record,
753 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
754 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
755 });
756 let mut metadata = json!({
757 "command": command,
758 "cwd": cwd,
759 "exit_code": exit_code,
760 "duration_ms": duration_ms,
761 "timed_out": timed_out,
762 "task_updates": {
763 "gate": gate_record,
764 "artifacts": artifact_updates("gate_log", log_path.clone(), &summary)
765 }
766 });
767 if let Some(path) = log_path {
768 metadata["artifact_path"] = json!(path);
769 }
770 Ok(ToolResult::json(&content)
771 .map_err(|e| ToolError::execution_failed(e.to_string()))?
772 .with_metadata(metadata))
773 }
774
775 async fn execute_pr_attempt_record(
776 &self,
777 input: &Value,
778 context: &ToolContext,
779 ) -> Result<ToolResult, ToolError> {
780 let task_id = read_task_for_input(input, context).await?.id;
781 let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"])
782 .await
783 .ok();
784 let head_sha = base_sha.clone();
785 let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"])
786 .await
787 .ok();
788 let (diff, changed_files) = attempt_diff(&context.workspace).await?;
789 if diff.trim().is_empty() {
790 return Ok(ToolResult::error(
791 "No working-tree diff to record as an attempt.",
792 ));
793 }
794 let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff).await?;
795 let attempt = TaskAttemptRecord {
796 id: format!("attempt_{}", &Uuid::new_v4().to_string()[..8]),
797 attempt_group_id: optional_str(input, "attempt_group_id")?
798 .map(ToString::to_string)
799 .unwrap_or_else(|| format!("attempt_group_{}", &Uuid::new_v4().to_string()[..8])),
800 attempt_index: optional_u64(input, "attempt_index", 1)?.max(1) as u32,
801 attempt_count: optional_u64(input, "attempt_count", 1)?.max(1) as u32,
802 base_ref: branch.clone(),
803 base_sha,
804 head_ref: branch,
805 head_sha,
806 summary: required_str(input, "summary")?.to_string(),
807 changed_files,
808 patch_path: patch_path.clone(),
809 verification: input
810 .get("verification")
811 .and_then(Value::as_array)
812 .map(|items| {
813 items
814 .iter()
815 .filter_map(Value::as_str)
816 .map(ToString::to_string)
817 .collect()
818 })
819 .unwrap_or_default(),
820 selected: false,
821 recorded_at: Utc::now(),
822 };
823 let metadata = json!({
824 "task_id": task_id,
825 "task_updates": {
826 "attempt": attempt,
827 "artifacts": artifact_updates("attempt_patch", patch_path.clone(), "Captured git diff for PR attempt")
828 }
829 });
830 if context.runtime.active_task_id.as_deref() != Some(task_id.as_str())
831 && let Some(manager) = context.runtime.task_manager.as_ref()
832 {
833 manager
834 .record_tool_metadata(&task_id, &metadata)
835 .await
836 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
837 }
838 Ok(ToolResult::json(&metadata)
839 .map_err(|e| ToolError::execution_failed(e.to_string()))?
840 .with_metadata(metadata))
841 }
842
843 async fn execute_pr_attempt_list(
844 &self,
845 input: &Value,
846 context: &ToolContext,
847 ) -> Result<ToolResult, ToolError> {
848 let task = read_task_for_input(input, context).await?;
849 ToolResult::json(&json!({ "task_id": task.id, "attempts": task.attempts }))
850 .map_err(|e| ToolError::execution_failed(e.to_string()))
851 }
852
853 async fn execute_pr_attempt_read(
854 &self,
855 input: &Value,
856 context: &ToolContext,
857 ) -> Result<ToolResult, ToolError> {
858 let task = read_task_for_input(input, context).await?;
859 let attempt_id = required_str(input, "attempt_id")?;
860 let attempt = task
861 .attempts
862 .iter()
863 .find(|attempt| attempt.id == attempt_id)
864 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
865 ToolResult::json(attempt).map_err(|e| ToolError::execution_failed(e.to_string()))
866 }
867
868 async fn execute_pr_attempt_preflight(
869 &self,
870 input: &Value,
871 context: &ToolContext,
872 ) -> Result<ToolResult, ToolError> {
873 let manager = context
874 .runtime
875 .task_manager
876 .as_ref()
877 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
878 let task = read_task_for_input(input, context).await?;
879 let attempt_id = required_str(input, "attempt_id")?;
880 let attempt = task
881 .attempts
882 .iter()
883 .find(|attempt| attempt.id == attempt_id)
884 .ok_or_else(|| ToolError::invalid_input(format!("Attempt not found: {attempt_id}")))?;
885 let patch_ref = attempt
886 .patch_path
887 .as_ref()
888 .ok_or_else(|| ToolError::invalid_input("Attempt has no patch artifact"))?;
889 let patch_path = manager.artifact_absolute_path(patch_ref);
890 let workspace = context.workspace.clone();
891 let out = tokio::task::spawn_blocking(move || {
892 crate::dependencies::Git::command()
893 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "git not found"))?
894 .args(["apply", "--check"])
895 .arg(&patch_path)
896 .current_dir(&workspace)
897 .output()
898 })
899 .await
900 .map_err(|join_err| {
901 // Surface the otherwise-discarded join error for debugging; the
902 // returned ToolError (and thus user-facing behavior) is unchanged.
903 tracing::debug!(error = %join_err, "git apply --check spawn_blocking task failed to join");
904 ToolError::execution_failed(format!("git apply --check panicked: {join_err}"))
905 })?
906 .map_err(|e| ToolError::execution_failed(format!("git apply --check failed: {e}")))?;
907 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
908 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
909 ToolResult::json(&json!({
910 "attempt_id": attempt_id,
911 "patch_path": patch_ref,
912 "would_apply": out.status.success(),
913 "exit_code": out.status.code(),
914 "stdout_summary": summarize(&stdout, MAX_SUMMARY_CHARS),
915 "stderr_summary": summarize(&stderr, MAX_SUMMARY_CHARS),
916 "mutated_worktree": false
917 }))
918 .map_err(|e| ToolError::execution_failed(e.to_string()))
919 }
920 }
921
922 #[async_trait]
923 impl ToolSpec for TaskShellStartTool {
924 fn name(&self) -> &'static str {
925 "task_shell_start"
926 }
927
928 fn description(&self) -> &'static str {
929 "Start a long-running shell command in the background and return a shell task_id immediately. Completion is delivered automatically as an internal runtime event and remains visible in the task/status surface; use task_shell_wait only for early output, explicit barriers, or gate evidence on the active durable task."
930 }
931
932 fn input_schema(&self) -> Value {
933 json!({
934 "type": "object",
935 "properties": {
936 "command": { "type": "string" },
937 "cwd": { "type": "string", "description": "Optional working directory within the workspace." },
938 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Accepted for interface compatibility but not enforced: the command always starts in the background and is not bounded by this timeout. A running shell task can be stopped from the model surface via exec_shell with action=cancel and the returned task_id; otherwise it ends when the command finishes." },
939 "stdin": { "type": "string" },
940 "tty": { "type": "boolean" }
941 },
942 "required": ["command"],
943 "additionalProperties": false
944 })
945 }
946
947 fn capabilities(&self) -> Vec<ToolCapability> {
948 vec![
949 ToolCapability::ExecutesCode,
950 ToolCapability::RequiresApproval,
951 ]
952 }
953
954 fn approval_requirement(&self) -> ApprovalRequirement {
955 ApprovalRequirement::Required
956 }
957
958 fn starts_detached_for(&self, input: &Value) -> bool {
959 input.get("command").and_then(Value::as_str).is_some()
960 }
961
962 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
963 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
964 let mut shell_input = json!({
965 "command": required_str(&input, "command")?,
966 "background": true,
967 "timeout_ms": optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
968 .clamp(1_000, MAX_GATE_TIMEOUT_MS),
969 });
970 if let Some(cwd) = optional_str(&input, "cwd")? {
971 let cwd = resolve_cwd(context, Some(cwd))?;
972 shell_input["cwd"] = json!(cwd);
973 }
974 if let Some(stdin) = optional_str(&input, "stdin")? {
975 shell_input["stdin"] = json!(stdin);
976 }
977 if optional_bool(&input, "tty", false)? {
978 shell_input["tty"] = json!(true);
979 }
980 let mut result = BashTool::new("Bash").execute(shell_input, context).await?;
981 if let Some(metadata) = result.metadata.as_mut() {
982 metadata["background"] = json!(true);
983 metadata["task_shell"] = json!(true);
984 }
985 Ok(result)
986 }
987 }
988
989 #[async_trait]
990 impl ToolSpec for TaskShellWaitTool {
991 fn name(&self) -> &'static str {
992 "task_shell_wait"
993 }
994
995 fn description(&self) -> &'static str {
996 "Poll a background shell task without blocking the agent indefinitely. Completion is delivered automatically; use this only for early output, explicit barriers, or gate evidence. If `gate` is supplied and the shell task has completed, records structured gate evidence on the active durable task."
997 }
998
999 fn input_schema(&self) -> Value {
1000 json!({
1001 "type": "object",
1002 "properties": {
1003 "task_id": { "type": "string", "description": "Background shell task id returned by task_shell_start." },
1004 "wait": { "type": "boolean", "default": false },
1005 "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 },
1006 "gate": { "type": "string", "enum": ["fmt", "check", "clippy", "test", "custom"] },
1007 "command": { "type": "string", "description": "Original command, used when recording gate evidence." }
1008 },
1009 "required": ["task_id"],
1010 "additionalProperties": false
1011 })
1012 }
1013
1014 fn capabilities(&self) -> Vec<ToolCapability> {
1015 vec![
1016 ToolCapability::WritesFiles,
1017 ToolCapability::RequiresApproval,
1018 ]
1019 }
1020
1021 fn approval_requirement(&self) -> ApprovalRequirement {
1022 ApprovalRequirement::Required
1023 }
1024
1025 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
1026 if self.is_read_only_for(input) {
1027 ApprovalRequirement::Auto
1028 } else {
1029 ApprovalRequirement::Required
1030 }
1031 }
1032
1033 fn is_read_only_for(&self, input: &Value) -> bool {
1034 input.get("gate").is_none_or(Value::is_null)
1035 }
1036
1037 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1038 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
1039 let shell_input = task_shell_wait_input(input.clone());
1040 let result = BashTool::alias("exec_shell_wait", "wait")
1041 .execute(shell_input, context)
1042 .await?;
1043 let Some(gate) = optional_str(&input, "gate")? else {
1044 return Ok(result);
1045 };
1046 let status = result
1047 .metadata
1048 .as_ref()
1049 .and_then(|m| m.get("status"))
1050 .and_then(Value::as_str)
1051 .unwrap_or("Running");
1052 if status == "Running" {
1053 return Ok(result);
1054 }
1055 let exit_code = result
1056 .metadata
1057 .as_ref()
1058 .and_then(|m| m.get("exit_code"))
1059 .and_then(Value::as_i64)
1060 .and_then(|v| i32::try_from(v).ok());
1061 let duration_ms = result
1062 .metadata
1063 .as_ref()
1064 .and_then(|m| m.get("duration_ms"))
1065 .and_then(Value::as_u64)
1066 .unwrap_or_default();
1067 let command = optional_str(&input, "command")?.unwrap_or("(background shell)");
1068 let log_path = write_runtime_artifact(context, "background_gate", &result.content).await?;
1069 let gate_status = if exit_code == Some(0) {
1070 "passed"
1071 } else if status == "TimedOut" {
1072 "timeout"
1073 } else {
1074 "failed"
1075 };
1076 let gate_record = TaskGateRecord {
1077 id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
1078 gate: gate.to_string(),
1079 command: command.to_string(),
1080 cwd: context.workspace.clone(),
1081 exit_code,
1082 status: gate_status.to_string(),
1083 classification: classify_gate_failure(
1084 gate,
1085 gate_status,
1086 status == "TimedOut",
1087 &result.content,
1088 "",
1089 ),
1090 duration_ms,
1091 summary: summarize(&result.content, MAX_SUMMARY_CHARS),
1092 log_path: log_path.clone(),
1093 recorded_at: Utc::now(),
1094 };
1095 let mut metadata = result.metadata.clone().unwrap_or_else(|| json!({}));
1096 metadata["background"] = json!(true);
1097 metadata["task_updates"] = json!({
1098 "gate": gate_record,
1099 "artifacts": artifact_updates("background_gate_log", log_path, "Background shell gate output")
1100 });
1101 Ok(result.with_metadata(metadata))
1102 }
1103 }
1104
1105 fn reconcile_task_record(context: &ToolContext, task: &TaskRecord) -> Result<(), ToolError> {
1106 let Some(work) = context.runtime.work.as_ref() else {
1107 return Ok(());
1108 };
1109 let external = format!("task:{}", task.id);
1110 if !work.has_operation_binding(Some(&context.state_namespace), &external) {
1111 return Ok(());
1112 }
1113 work.reconcile_operation(
1114 &context.state_namespace,
1115 task_owner_snapshot(
1116 &task.id,
1117 task.status,
1118 task.lifecycle_seq,
1119 task.created_at,
1120 task.started_at,
1121 task.ended_at,
1122 ),
1123 )
1124 .map(|_| ())
1125 .map_err(ToolError::execution_failed)
1126 }
1127
1128 fn task_result(label: &str, task: &TaskRecord) -> Result<ToolResult, ToolError> {
1129 task_result_with_lifecycle_warning(label, task, None)
1130 }
1131
1132 fn task_result_with_lifecycle_warning(
1133 label: &str,
1134 task: &TaskRecord,
1135 lifecycle_warning: Option<&str>,
1136 ) -> Result<ToolResult, ToolError> {
1137 ToolResult::json(&json!({
1138 "summary": format!("{label}: {} ({:?})", task.id, task.status),
1139 "task": task,
1140 "lifecycle_warning": lifecycle_warning,
1141 "execution_ownership": if task.execution_scope.is_some() { "scope_bound" } else { "unverified" },
1142 }))
1143 .map_err(|e| ToolError::execution_failed(e.to_string()))
1144 }
1145
1146 fn resolve_cwd(context: &ToolContext, raw: Option<&str>) -> Result<PathBuf, ToolError> {
1147 match raw {
1148 Some(path) => {
1149 let resolved = context.resolve_path(path)?;
1150 if resolved.is_dir() {
1151 Ok(resolved)
1152 } else {
1153 Err(ToolError::invalid_input(format!(
1154 "cwd must be a directory: {path}"
1155 )))
1156 }
1157 }
1158 None => Ok(context.workspace.clone()),
1159 }
1160 }
1161
1162 async fn write_runtime_artifact(
1163 context: &ToolContext,
1164 label: &str,
1165 content: &str,
1166 ) -> Result<Option<PathBuf>, ToolError> {
1167 let Some(task_id) = context.runtime.active_task_id.as_deref() else {
1168 return Ok(None);
1169 };
1170 let manager = context.runtime.task_manager.as_ref();
1171 if let Some(manager) = manager {
1172 return manager
1173 .write_task_artifact(task_id, label, content)
1174 .map(Some)
1175 .map_err(|e| ToolError::execution_failed(e.to_string()));
1176 }
1177 let Some(data_dir) = context.runtime.task_data_dir.as_ref() else {
1178 return Ok(None);
1179 };
1180 let artifact_dir = data_dir.join("artifacts").join(task_id);
1181 let filename = format!(
1182 "{}_{}.txt",
1183 Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
1184 sanitize_filename(label)
1185 );
1186 let absolute = artifact_dir.join(filename);
1187 let content_owned = content.to_owned();
1188 let abs = absolute.clone();
1189 tokio::task::spawn_blocking(move || {
1190 std::fs::create_dir_all(&artifact_dir)?;
1191 std::fs::write(&abs, content_owned)?;
1192 Ok::<(), std::io::Error>(())
1193 })
1194 .await
1195 .map_err(|e| {
1196 // Surface the otherwise-discarded join error for debugging; the
1197 // returned ToolError (and thus user-facing behavior) is unchanged.
1198 tracing::debug!(error = %e, "artifact write spawn_blocking task failed to join");
1199 ToolError::execution_failed(format!("artifact write task panicked: {e}"))
1200 })?
1201 .map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
1202 Ok(Some(
1203 absolute
1204 .strip_prefix(data_dir)
1205 .map(PathBuf::from)
1206 .unwrap_or(absolute),
1207 ))
1208 }
1209
1210 async fn write_task_artifact_for(
1211 context: &ToolContext,
1212 task_id: &str,
1213 label: &str,
1214 content: &str,
1215 ) -> Result<Option<PathBuf>, ToolError> {
1216 if let Some(manager) = context.runtime.task_manager.as_ref() {
1217 return manager
1218 .write_task_artifact(task_id, label, content)
1219 .map(Some)
1220 .map_err(|e| ToolError::execution_failed(e.to_string()));
1221 }
1222 if context.runtime.active_task_id.as_deref() != Some(task_id) {
1223 return Ok(None);
1224 }
1225 write_runtime_artifact(context, label, content).await
1226 }
1227
1228 fn artifact_updates(label: &str, path: Option<PathBuf>, summary: &str) -> Value {
1229 match path {
1230 Some(path) => json!([TaskArtifactRef {
1231 label: label.to_string(),
1232 path,
1233 summary: summarize(summary, 240),
1234 created_at: Utc::now(),
1235 }]),
1236 None => json!([]),
1237 }
1238 }
1239
1240 async fn read_task_for_input(
1241 input: &Value,
1242 context: &ToolContext,
1243 ) -> Result<TaskRecord, ToolError> {
1244 let manager = context
1245 .runtime
1246 .task_manager
1247 .as_ref()
1248 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
1249 let task_id = task_id_from_input_or_context(input, context)?;
1250 if context.runtime.active_task_id.as_deref() == Some(task_id.as_str()) {
1251 // Runtime thread construction stamps `active_task_id` from its durable
1252 // thread record. It is not a tool parameter, so an owned task may read
1253 // itself even though its execution engine has a distinct namespace.
1254 manager
1255 .get_task_for_active_runtime(&task_id)
1256 .await
1257 .map_err(|e| ToolError::execution_failed(e.to_string()))
1258 } else {
1259 manager
1260 .get_task_for_owner(&task_id, &context.state_namespace)
1261 .await
1262 .map_err(|e| ToolError::execution_failed(e.to_string()))
1263 }
1264 }
1265
1266 fn task_id_from_input_or_context(
1267 input: &Value,
1268 context: &ToolContext,
1269 ) -> Result<String, ToolError> {
1270 optional_str(input, "task_id")?
1271 .map(ToString::to_string)
1272 .or_else(|| context.runtime.active_task_id.clone())
1273 .ok_or_else(|| {
1274 ToolError::invalid_input("task_id is required when no durable task is active")
1275 })
1276 }
1277
1278 fn task_id_schema() -> Value {
1279 json!({
1280 "type": "object",
1281 "properties": {
1282 "task_id": { "type": "string", "description": "Task id; defaults to active task." }
1283 },
1284 "additionalProperties": false
1285 })
1286 }
1287
1288 /// The working-tree patch and changed paths an attempt records, read without
1289 /// running repository-configured diff drivers or filters.
1290 async fn attempt_diff(workspace: &Path) -> Result<(String, Vec<String>), ToolError> {
1291 let review = crate::dependencies::Git::REVIEW_DIFF_ARGS;
1292 let diff = git_output(
1293 workspace,
1294 &[&["diff", "--binary", "--no-color"][..], &review[..]].concat(),
1295 )
1296 .await?;
1297 let changed_files = git_output(
1298 workspace,
1299 &[&["diff", "--name-only"][..], &review[..]].concat(),
1300 )
1301 .await?
1302 .lines()
1303 .filter(|line| !line.trim().is_empty())
1304 .map(ToString::to_string)
1305 .collect();
1306 Ok((diff, changed_files))
1307 }
1308
1309 async fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
1310 let args_owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
1311 let cwd = workspace.to_path_buf();
1312 // Reads repository content, so repository-configured filters, fsmonitor
1313 // and hooks stay off.
1314 let out = tokio::task::spawn_blocking(move || {
1315 crate::dependencies::Git::review_command(&cwd)
1316 .map_err(|e| std::io::Error::other(format!("{e:#}")))?
1317 .args(&args_owned)
1318 .output()
1319 })
1320 .await
1321 .map_err(|e| {
1322 // Surface the otherwise-discarded join error for debugging; the
1323 // returned ToolError (and thus user-facing behavior) is unchanged.
1324 tracing::debug!(error = %e, "git spawn_blocking task failed to join");
1325 ToolError::execution_failed(format!("git task panicked: {e}"))
1326 })?
1327 .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
1328 if !out.status.success() {
1329 return Err(ToolError::execution_failed(format!(
1330 "git {} failed: {}",
1331 args.join(" "),
1332 String::from_utf8_lossy(&out.stderr).trim()
1333 )));
1334 }
1335 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
1336 }
1337
1338 fn classify_gate_failure(
1339 gate: &str,
1340 status: &str,
1341 timed_out: bool,
1342 stderr: &str,
1343 stdout: &str,
1344 ) -> String {
1345 if timed_out {
1346 return "timeout".to_string();
1347 }
1348 if status == "passed" {
1349 return "passed".to_string();
1350 }
1351 let haystack = format!("{stderr}\n{stdout}").to_ascii_lowercase();
1352 if haystack.contains("address already in use") || haystack.contains("port") {
1353 "environment_port_binding".to_string()
1354 } else if gate == "clippy" || haystack.contains("warning:") {
1355 "lint_failure".to_string()
1356 } else if gate == "test" || haystack.contains("test result: failed") {
1357 "test_failure".to_string()
1358 } else if haystack.contains("error: could not compile")
1359 || haystack.contains("compilation failed")
1360 {
1361 "compile_error".to_string()
1362 } else {
1363 "environment_or_tooling_failure".to_string()
1364 }
1365 }
1366
1367 fn summarize(text: &str, limit: usize) -> String {
1368 let mut out = String::new();
1369 for (idx, ch) in text.chars().enumerate() {
1370 if idx >= limit.saturating_sub(3) {
1371 out.push_str("...");
1372 return out;
1373 }
1374 if ch.is_control() && ch != '\n' && ch != '\t' {
1375 continue;
1376 }
1377 out.push(ch);
1378 }
1379 if out.trim().is_empty() {
1380 "(no output)".to_string()
1381 } else {
1382 out
1383 }
1384 }
1385
1386 fn sanitize_filename(input: &str) -> String {
1387 let mut out = String::new();
1388 for ch in input.chars() {
1389 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1390 out.push(ch);
1391 } else {
1392 out.push('_');
1393 }
1394 }
1395 if out.is_empty() {
1396 "artifact".to_string()
1397 } else {
1398 out
1399 }
1400 }
1401
1402 #[cfg(test)]
1403 mod tests {
1404 use super::*;
1405 use crate::tools::spec::ToolSpec;
1406
1407 /// Recording an attempt reads the working-tree patch; repository diff
1408 /// drivers and clean filters must not run, and the patch stays a patch.
1409 #[cfg(unix)]
1410 #[tokio::test]
1411 async fn attempt_diff_runs_no_repository_configured_commands() {
1412 use std::os::unix::fs::PermissionsExt;
1413 let tmp = tempfile::tempdir().expect("tempdir");
1414 let outside = tempfile::tempdir().expect("tempdir");
1415 let marker = outside.path().join("marker");
1416 let script = |name: &str, body: &str| {
1417 let path = outside.path().join(name);
1418 std::fs::write(
1419 &path,
1420 format!("#!/bin/sh\necho {name} >> '{}'\n{body}", marker.display()),
1421 )
1422 .expect("write script");
1423 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1424 path.display().to_string()
1425 };
1426 let clean = script("clean.sh", "cat\n");
1427 let external = script("external.sh", "");
1428 let textconv = script("textconv.sh", "cat \"$1\"\n");
1429 let repo = tmp.path();
1430 let git = |args: &[&str]| {
1431 let status = crate::dependencies::Git::status(args, repo).expect("git should spawn");
1432 assert!(status.success(), "git {args:?} failed");
1433 };
1434 git(&["init", "-q"]);
1435 git(&["config", "user.email", "t@example.com"]);
1436 git(&["config", "user.name", "Test"]);
1437 git(&["config", "commit.gpgsign", "false"]);
1438 std::fs::write(
1439 repo.join(".gitattributes"),
1440 "a.md diff=conv\nf.txt filter=x\n",
1441 )
1442 .expect("attrs");
1443 std::fs::write(repo.join("a.md"), "one\n").expect("write");
1444 std::fs::write(repo.join("f.txt"), "one\n").expect("write");
1445 git(&["add", "."]);
1446 git(&["commit", "-q", "-m", "init"]);
1447 git(&["config", "filter.x.clean", &clean]);
1448 git(&["config", "diff.conv.textconv", &textconv]);
1449 std::fs::write(repo.join("a.md"), "two\n").expect("modify");
1450 std::fs::write(repo.join("f.txt"), "two\n").expect("modify");
1451 git(&["config", "diff.external", &external]);
1452
1453 let (diff, changed) = attempt_diff(repo).await.expect("attempt diff");
1454 assert!(
1455 !marker.exists(),
1456 "recording ran a repository-configured command: {}",
1457 std::fs::read_to_string(&marker).unwrap_or_default()
1458 );
1459 assert!(diff.contains("+two"), "{diff}");
1460 assert_eq!(changed, vec!["a.md".to_string(), "f.txt".to_string()]);
1461 }
1462
1463 #[test]
1464 fn durable_task_schema_requires_prompt() {
1465 let schema = TasksTool::alias("task_create", "create").input_schema();
1466 assert_eq!(schema["required"][0], "prompt");
1467 assert!(schema["properties"]["prompt"].is_object());
1468 }
1469
1470 #[test]
1471 fn create_mode_enum_advertises_operate_not_yolo() {
1472 let create = TasksTool::alias("task_create", "create").input_schema();
1473 assert_eq!(
1474 create["properties"]["mode"]["enum"],
1475 json!(["agent", "plan", "operate"])
1476 );
1477 let canonical = TasksTool::new("tasks").input_schema();
1478 assert_eq!(
1479 canonical["properties"]["mode"]["enum"],
1480 json!(["agent", "plan", "operate"])
1481 );
1482 }
1483
1484 #[test]
1485 fn gate_classifier_detects_timeout() {
1486 assert_eq!(
1487 classify_gate_failure("test", "timeout", true, "", ""),
1488 "timeout"
1489 );
1490 }
1491
1492 #[test]
1493 fn canonical_schema_lists_all_actions_and_union_fields() {
1494 let schema = TasksTool::new("tasks").input_schema();
1495 let actions = schema["properties"]["action"]["enum"]
1496 .as_array()
1497 .expect("action enum");
1498 for action in [
1499 "create",
1500 "list",
1501 "read",
1502 "cancel",
1503 "gate_run",
1504 "pr_attempt_record",
1505 "pr_attempt_list",
1506 "pr_attempt_read",
1507 "pr_attempt_preflight",
1508 ] {
1509 assert!(
1510 actions.iter().any(|value| value.as_str() == Some(action)),
1511 "canonical schema must offer action {action}"
1512 );
1513 }
1514 for field in [
1515 "prompt",
1516 "task_id",
1517 "gate",
1518 "command",
1519 "attempt_id",
1520 "limit",
1521 ] {
1522 assert!(
1523 schema["properties"][field].is_object(),
1524 "canonical schema must carry union field {field}"
1525 );
1526 }
1527 assert_eq!(schema["additionalProperties"], json!(false));
1528 }
1529
1530 #[test]
1531 fn read_only_variant_only_offers_read_actions() {
1532 let tool = TasksTool::read_only("tasks");
1533 let schema = tool.input_schema();
1534 assert_eq!(
1535 schema["properties"]["action"]["enum"],
1536 json!(["list", "read", "pr_attempt_list", "pr_attempt_read"])
1537 );
1538 assert!(!schema["properties"]["prompt"].is_object());
1539 assert!(!schema["properties"]["gate"].is_object());
1540 // pr_attempt_read is a read action: its id field must be advertised
1541 // on the read-only surface too.
1542 assert!(schema["properties"]["attempt_id"].is_object());
1543 assert!(schema["properties"]["task_id"].is_object());
1544 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
1545 assert!(tool.is_read_only());
1546 assert_eq!(tool.capabilities(), vec![ToolCapability::ReadOnly]);
1547 }
1548
1549 #[test]
1550 fn aliases_hide_from_model_and_force_action() {
1551 let create = TasksTool::alias("task_create", "create");
1552 assert!(!create.model_visible());
1553 assert_eq!(create.name(), "task_create");
1554 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
1555
1556 let gate = TasksTool::alias("task_gate_run", "gate_run");
1557 assert_eq!(gate.approval_requirement(), ApprovalRequirement::Required);
1558 assert!(gate.capabilities().contains(&ToolCapability::ExecutesCode));
1559
1560 let list = TasksTool::alias("task_list", "list");
1561 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
1562 assert!(list.is_read_only_for(&json!({})));
1563
1564 let canonical = TasksTool::new("tasks");
1565 assert!(canonical.model_visible());
1566 assert_eq!(
1567 canonical.approval_requirement_for(&json!({"action": "list"})),
1568 ApprovalRequirement::Auto
1569 );
1570 assert_eq!(
1571 canonical.approval_requirement_for(&json!({"action": "cancel"})),
1572 ApprovalRequirement::Required
1573 );
1574 assert_eq!(
1575 canonical.approval_requirement_for(&json!({"action": "gate_run"})),
1576 ApprovalRequirement::Required
1577 );
1578 assert!(canonical.is_read_only_for(&json!({"action": "pr_attempt_read"})));
1579 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
1580 }
1581
1582 #[test]
1583 fn canonical_rejects_unknown_or_missing_action() {
1584 let tool = TasksTool::new("tasks");
1585 let err = tool
1586 .resolve_action(&json!({}))
1587 .expect_err("missing action must fail");
1588 assert!(err.to_string().contains("missing `action`"));
1589 let err = tool
1590 .resolve_action(&json!({"action": "explode"}))
1591 .expect_err("unknown action must fail");
1592 assert!(err.to_string().contains("invalid action"));
1593
1594 let read_only = TasksTool::read_only("tasks");
1595 let err = read_only
1596 .resolve_action(&json!({"action": "gate_run"}))
1597 .expect_err("read-only surface must reject exec actions");
1598 assert!(err.to_string().contains("invalid action"));
1599 }
1600
1601 #[test]
1602 fn background_shell_schema_is_explicit() {
1603 let schema = TaskShellStartTool.input_schema();
1604 assert_eq!(schema["required"][0], "command");
1605 assert_eq!(schema["properties"]["timeout_ms"]["maximum"], 600000);
1606
1607 let wait_schema = TaskShellWaitTool.input_schema();
1608 assert_eq!(wait_schema["required"][0], "task_id");
1609 assert!(wait_schema["properties"]["gate"].is_object());
1610 }
1611
1612 // `timeout_ms` rides along for interface compatibility only: the shell
1613 // always starts in the background, where no deadline enforces it. The
1614 // schema must say so instead of implying a bounded run.
1615 #[test]
1616 fn background_shell_timeout_disclosure_admits_it_is_not_enforced() {
1617 let schema = TaskShellStartTool.input_schema();
1618 let timeout_ms = schema["properties"]["timeout_ms"]["description"]
1619 .as_str()
1620 .expect("timeout_ms must carry a description");
1621 assert!(
1622 timeout_ms.contains("not enforced"),
1623 "timeout_ms must disclose that the background run is unbounded: {timeout_ms}"
1624 );
1625 assert!(
1626 timeout_ms.contains("cancel"),
1627 "timeout_ms must point at the exec_shell cancel path that can stop the run: {timeout_ms}"
1628 );
1629 }
1630
1631 // The gate blocks dangerous commands unless auto-approve is on, and the
1632 // description is where the model learns that before composing a command.
1633 #[test]
1634 fn gate_run_description_discloses_dangerous_command_block_and_budget() {
1635 let gate_run = TasksTool::alias("task_gate_run", "gate_run");
1636 let description = gate_run.description();
1637 assert!(
1638 description.contains("BLOCKED") && description.contains("auto-approve"),
1639 "gate_run description must disclose the dangerous-command block: {description}"
1640 );
1641 assert!(
1642 description.contains("120s"),
1643 "gate_run description must disclose the default gate timeout: {description}"
1644 );
1645 }
1646
1647 #[test]
1648 fn runtime_surface_hardening_task_gate_recording_requires_approval() {
1649 let tool = TaskShellWaitTool;
1650 for input in [
1651 json!({"task_id": "shell_1"}),
1652 json!({"task_id": "shell_1", "gate": null}),
1653 ] {
1654 assert_eq!(
1655 tool.approval_requirement_for(&input),
1656 ApprovalRequirement::Auto
1657 );
1658 assert!(tool.is_read_only_for(&input));
1659 }
1660 for gate in ["fmt", "check", "clippy", "test", "custom"] {
1661 let input = json!({"task_id": "shell_1", "gate": gate, "command": "cargo check"});
1662 assert_eq!(
1663 tool.approval_requirement_for(&input),
1664 ApprovalRequirement::Required
1665 );
1666 assert!(!tool.is_read_only_for(&input));
1667 }
1668 assert!(tool.capabilities().contains(&ToolCapability::WritesFiles));
1669 assert!(!tool.is_read_only());
1670 }
1671
1672 #[test]
1673 fn task_shell_wait_keeps_its_documented_nonblocking_default() {
1674 assert_eq!(
1675 task_shell_wait_input(json!({"task_id": "shell_1"}))["wait"],
1676 false
1677 );
1678 assert_eq!(
1679 task_shell_wait_input(json!({"task_id": "shell_1", "wait": true}))["wait"],
1680 true
1681 );
1682 assert_eq!(
1683 task_shell_wait_input(json!({"task_id": "shell_1", "block": true}))["block"],
1684 true
1685 );
1686 assert_eq!(
1687 task_shell_wait_input(json!({"task_id": "shell_1", "wait": null}))["wait"],
1688 false
1689 );
1690 assert_eq!(
1691 task_shell_wait_input(json!({"task_id": "shell_1", "block": null}))["wait"],
1692 false
1693 );
1694 }
1695
1696 #[tokio::test]
1697 async fn task_shell_wait_null_is_a_nonblocking_snapshot() {
1698 let workspace = tempfile::tempdir().expect("workspace");
1699 let context = ToolContext::new(workspace.path());
1700 let started = TaskShellStartTool
1701 .execute(json!({"command": "sleep 2", "timeout_ms": 5_000}), &context)
1702 .await
1703 .expect("start background shell");
1704 let task_id = started
1705 .metadata
1706 .as_ref()
1707 .and_then(|metadata| metadata.get("task_id"))
1708 .and_then(Value::as_str)
1709 .expect("task id")
1710 .to_string();
1711
1712 let before = std::time::Instant::now();
1713 let snapshot = TaskShellWaitTool
1714 .execute(
1715 json!({"task_id": task_id, "wait": null, "timeout_ms": 5_000}),
1716 &context,
1717 )
1718 .await
1719 .expect("poll background shell");
1720 assert!(
1721 before.elapsed() < std::time::Duration::from_secs(1),
1722 "task_shell_wait wait:null must preserve the nonblocking default"
1723 );
1724 assert_eq!(
1725 snapshot
1726 .metadata
1727 .as_ref()
1728 .and_then(|metadata| metadata.get("status"))
1729 .and_then(Value::as_str),
1730 Some("Running")
1731 );
1732
1733 BashTool::alias("exec_shell_cancel", "cancel")
1734 .execute(json!({"task_id": task_id}), &context)
1735 .await
1736 .expect("cancel background shell");
1737 }
1738
1739 /// Creating a task from a session runs it on the posture that session
1740 /// holds: `auto_approve` is a legacy bit the engine only reads when no
1741 /// posture is given, so a task cannot talk itself into more authority than
1742 /// the session that asked for it was granted.
1743 #[tokio::test]
1744 async fn create_pins_the_session_posture_on_the_task() {
1745 struct NoopExecutor;
1746
1747 #[async_trait::async_trait]
1748 impl crate::task_manager::TaskExecutor for NoopExecutor {
1749 async fn execute(
1750 &self,
1751 _task: crate::task_manager::ExecutionTask,
1752 _events: tokio::sync::mpsc::Sender<crate::task_manager::TaskExecutionEvent>,
1753 _cancel: tokio_util::sync::CancellationToken,
1754 ) -> crate::task_manager::TaskExecutionResult {
1755 crate::task_manager::TaskExecutionResult {
1756 status: crate::task_manager::TaskStatus::Completed,
1757 result_text: Some("noop".to_string()),
1758 error: None,
1759 terminal_reason: crate::task_manager::TaskTerminalReason::Completed,
1760 }
1761 }
1762 }
1763
1764 let workspace = tempfile::tempdir().expect("workspace");
1765 let manager = crate::task_manager::TaskManager::start_with_executor(
1766 crate::task_manager::TaskManagerConfig {
1767 data_dir: workspace.path().to_path_buf(),
1768 worker_count: 1,
1769 default_workspace: workspace.path().to_path_buf(),
1770 default_model: "deepseek-v4-pro".to_string(),
1771 default_mode: "agent".to_string(),
1772 allow_shell: false,
1773 trust_mode: false,
1774 execution_limits: crate::task_manager::TaskExecutionLimits::default(),
1775 },
1776 std::sync::Arc::new(NoopExecutor),
1777 )
1778 .await
1779 .expect("task manager");
1780
1781 let mut context = ToolContext::new(workspace.path());
1782 context.approval_mode = codewhale_execpolicy::ApprovalMode::Auto;
1783 context.shell_policy = crate::worker_profile::ShellPolicy::None;
1784 context.runtime.task_manager = Some(manager.clone());
1785
1786 TasksTool::new("tasks")
1787 .execute(
1788 json!({
1789 "action": "create",
1790 "prompt": "run the sweep",
1791 "auto_approve": true,
1792 "trust_mode": true,
1793 "allow_shell": true,
1794 "mode": "yolo"
1795 }),
1796 &context,
1797 )
1798 .await
1799 .expect("create accepted");
1800
1801 let queued = manager.list_tasks(Some(1)).await.expect("queued task");
1802 let created = manager.get_task(&queued[0].id).await.expect("created task");
1803 assert_eq!(
1804 created.permission_posture.as_deref(),
1805 Some("auto_review"),
1806 "the task runs under its session's posture, not the model's legacy bit"
1807 );
1808 // Requested authority the session does not hold is not stored.
1809 assert!(!created.auto_approve);
1810 assert!(!created.trust_mode);
1811 assert!(!created.allow_shell);
1812
1813 // The turn the task starts runs under the pinned posture: neither the
1814 // legacy bit nor a legacy full-access mode alias can re-derive one.
1815 let turn = crate::task_manager::ExecutionTask::from(&created).turn_request();
1816 assert_eq!(turn.permission_posture.as_deref(), Some("auto_review"));
1817 assert_eq!(turn.auto_approve, None);
1818 let policy = crate::runtime_policy::RuntimePolicyProjection::from_request(
1819 turn.mode.as_deref().expect("mode"),
1820 turn.permission_posture.as_deref(),
1821 turn.auto_approve,
1822 )
1823 .expect("policy");
1824 assert_eq!(policy.permission, codewhale_execpolicy::ApprovalMode::Auto);
1825
1826 // A workspace outside what the session can reach is refused.
1827 let outside = TasksTool::new("tasks")
1828 .execute(
1829 json!({"action": "create", "prompt": "look", "workspace": "/"}),
1830 &context,
1831 )
1832 .await;
1833 assert!(
1834 outside.is_err(),
1835 "a workspace outside the session is refused"
1836 );
1837 }
1838
1839 #[cfg(unix)]
1840 #[tokio::test]
1841 async fn timed_out_gate_run_kills_the_gate_process_tree() {
1842 let workspace = tempfile::tempdir().expect("workspace");
1843 let context = ToolContext::new(workspace.path())
1844 .with_shell_policy(crate::worker_profile::ShellPolicy::Full);
1845 let result = TasksTool::new("tasks")
1846 .execute(
1847 json!({
1848 "action": "gate_run",
1849 "gate": "test",
1850 "command": "echo gate-started; sleep 300 & echo $! > gate-child.pid; wait",
1851 "timeout_ms": 5_000
1852 }),
1853 &context,
1854 )
1855 .await
1856 .expect("gate runs");
1857 let metadata = result.metadata.expect("metadata");
1858 assert_eq!(metadata["timed_out"], true, "{metadata}");
1859 // What the gate wrote before the timeout is kept.
1860 assert!(
1861 result.content.contains("gate-started"),
1862 "{}",
1863 result.content
1864 );
1865 let child = crate::process_tree::read_pid_file(
1866 &workspace.path().join("gate-child.pid"),
1867 std::time::Duration::from_secs(5),
1868 );
1869 assert!(
1870 crate::process_tree::wait_for_pid_exit(child, std::time::Duration::from_secs(5)),
1871 "the timed-out gate's process is still running"
1872 );
1873 }
1874
1875 #[test]
1876 fn gate_command_uses_non_login_shell_invocation() {
1877 // A login shell would source profile files that can re-export
1878 // credentials the sanitized child environment just removed.
1879 let (program, args) = build_gate_command_parts("echo hello");
1880 assert_eq!(program, "/bin/sh");
1881 assert_eq!(args, vec!["-c".to_string(), "echo hello".to_string()]);
1882 }
1883
1884 /// A gate command is workspace code: under a read-only posture it either
1885 /// runs inside the enforcing sandbox, where its write fails, or is refused
1886 /// outright where no enforcing sandbox exists. It never runs raw.
1887 #[cfg(unix)]
1888 #[tokio::test]
1889 async fn gate_command_is_confined_by_the_session_sandbox() {
1890 let tmp = crate::test_support::sandbox_visible_tempdir();
1891 let written = tmp.path().join("written-by-gate.txt");
1892 let mut context = ToolContext::new(tmp.path());
1893 context.elevated_sandbox_policy = Some(crate::sandbox::SandboxPolicy::ReadOnly);
1894 let command = format!(
1895 "printf ran; printf x > '{}' 2>/dev/null; true",
1896 written.display()
1897 );
1898 match build_gate_command(
1899 &command,
1900 tmp.path(),
1901 &context,
1902 std::time::Duration::from_secs(30),
1903 ) {
1904 Ok(mut cmd) => {
1905 let output = cmd.output().await.expect("sandboxed gate command runs");
1906 assert_eq!(String::from_utf8_lossy(&output.stdout), "ran");
1907 }
1908 Err(error) => assert!(
1909 error.to_string().contains("nothing was run"),
1910 "only a missing enforcing sandbox may refuse: {error}"
1911 ),
1912 }
1913 assert!(!written.exists(), "a read-only posture must stop the write");
1914 }
1915
1916 #[cfg(unix)]
1917 #[tokio::test]
1918 async fn gate_command_does_not_inherit_parent_secret_env() {
1919 use crate::test_support::{EnvVarGuard, lock_test_env};
1920 let _env_lock = lock_test_env();
1921 let _secret = EnvVarGuard::set("CODEWHALE_TEST_GATE_SECRET", "gate-secret-value");
1922 let tmp = tempfile::tempdir().expect("tempdir");
1923 let output = build_gate_command(
1924 "printf 'secret=%s\\n' \"${CODEWHALE_TEST_GATE_SECRET-unset}\"; printf 'path-ok\\n'",
1925 tmp.path(),
1926 &ToolContext::new(tmp.path()),
1927 std::time::Duration::from_secs(30),
1928 )
1929 .expect("gate command builds")
1930 .output()
1931 .await
1932 .expect("gate command runs");
1933 let stdout = String::from_utf8_lossy(&output.stdout);
1934 assert!(output.status.success(), "{stdout}");
1935 assert!(stdout.contains("secret=unset"), "{stdout}");
1936 assert!(!stdout.contains("gate-secret-value"), "{stdout}");
1937 assert!(stdout.contains("path-ok"), "{stdout}");
1938 }
1939 }
1940
1940 lines RUST