Skip to main content

forge_sandbox/
host.rs

1//! SandboxHost — parent-side management of isolated worker child processes.
2//!
3//! Spawns `forgemax-worker` as a child process with a clean environment,
4//! communicates over length-delimited JSON IPC (stdin/stdout), and routes
5//! tool calls through the parent's [`ToolDispatcher`].
6
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use tokio::io::BufReader;
12use tokio::process::Command;
13
14use crate::error::SandboxError;
15use tokio::io::{AsyncRead, AsyncWrite};
16
17use crate::ipc::{
18    read_message, write_message, ChildMessage, IpcDispatchError, ParentMessage, WorkerConfig,
19};
20use crate::{ResourceDispatcher, StashDispatcher, ToolDispatcher};
21
22/// Maximum bytes to capture from worker stderr in debug mode.
23const MAX_STDERR_CAPTURE_BYTES: usize = 4096;
24
25/// Read at most [`MAX_STDERR_CAPTURE_BYTES`] from worker stderr and log via tracing.
26///
27/// This prevents unbounded memory growth from LLM-generated JS error output
28/// while still providing debug visibility. Never uses `Stdio::inherit()`.
29pub(crate) async fn capture_bounded_stderr<R: tokio::io::AsyncRead + Unpin>(mut stderr: R) {
30    use tokio::io::AsyncReadExt;
31    let mut buf = vec![0u8; MAX_STDERR_CAPTURE_BYTES];
32    let mut total = 0;
33    loop {
34        match stderr.read(&mut buf[total..]).await {
35            Ok(0) => break,
36            Ok(n) => {
37                total += n;
38                if total >= MAX_STDERR_CAPTURE_BYTES {
39                    break;
40                }
41            }
42            Err(_) => break,
43        }
44    }
45    if total > 0 {
46        let text = String::from_utf8_lossy(&buf[..total]);
47        tracing::debug!(target: "forge::sandbox::worker::stderr", "{}", text);
48    }
49    // Drain any remaining bytes without storing them
50    let mut discard = [0u8; 1024];
51    loop {
52        match stderr.read(&mut discard).await {
53            Ok(0) | Err(_) => break,
54            Ok(_) => continue,
55        }
56    }
57}
58
59/// Manages spawning and communicating with sandbox worker child processes.
60pub struct SandboxHost;
61
62impl SandboxHost {
63    /// Execute code in an isolated child process.
64    ///
65    /// 1. Spawns `forgemax-worker` with a clean environment
66    /// 2. Sends the code and config via IPC
67    /// 3. Routes tool call requests through the parent's dispatcher
68    /// 4. Returns the execution result (or kills the child on timeout)
69    ///
70    #[tracing::instrument(skip(code, config, dispatcher, resource_dispatcher, stash_dispatcher, known_servers, known_tools), fields(code_len = code.len()))]
71    pub async fn execute_in_child(
72        code: &str,
73        config: &crate::SandboxConfig,
74        dispatcher: Arc<dyn ToolDispatcher>,
75        resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
76        stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
77        known_servers: Option<std::collections::HashSet<String>>,
78        known_tools: Option<Vec<(String, String)>>,
79    ) -> Result<serde_json::Value, SandboxError> {
80        let worker_bin = find_worker_binary()?;
81        let mut worker_config = WorkerConfig::from(config);
82        worker_config.known_tools = known_tools;
83        worker_config.known_servers = known_servers;
84        let timeout = config.timeout;
85
86        // Spawn the worker with a clean environment.
87        // stderr is always piped (debug) or null (non-debug) — never inherit.
88        let debug_mode = std::env::var("FORGE_DEBUG").is_ok();
89        let mut child = Command::new(&worker_bin)
90            .stdin(std::process::Stdio::piped())
91            .stdout(std::process::Stdio::piped())
92            .stderr(if debug_mode {
93                std::process::Stdio::piped()
94            } else {
95                std::process::Stdio::null()
96            })
97            .env_clear()
98            .kill_on_drop(true)
99            .spawn()
100            .map_err(|e| {
101                SandboxError::Execution(anyhow::anyhow!(
102                    "failed to spawn worker at {}: {}",
103                    worker_bin.display(),
104                    e
105                ))
106            })?;
107
108        // Bounded stderr capture in debug mode (max 4KB, logged via tracing)
109        let _stderr_handle = if debug_mode {
110            child
111                .stderr
112                .take()
113                .map(|stderr| tokio::spawn(capture_bounded_stderr(stderr)))
114        } else {
115            None
116        };
117
118        let mut child_stdin = child
119            .stdin
120            .take()
121            .ok_or_else(|| SandboxError::Execution(anyhow::anyhow!("no stdin on child")))?;
122        let child_stdout = child
123            .stdout
124            .take()
125            .ok_or_else(|| SandboxError::Execution(anyhow::anyhow!("no stdout on child")))?;
126        let mut child_stdout = BufReader::new(child_stdout);
127
128        // Send the Execute message
129        let execute_msg = ParentMessage::Execute {
130            code: code.to_string(),
131            manifest: None,
132            config: worker_config,
133        };
134        write_message(&mut child_stdin, &execute_msg)
135            .await
136            .map_err(|e| {
137                SandboxError::Execution(anyhow::anyhow!("failed to send Execute: {}", e))
138            })?;
139
140        // IPC event loop with overall timeout
141        let result = tokio::time::timeout(
142            // Give the child a bit more time than its internal timeout,
143            // so the child can report its own timeout error cleanly.
144            timeout + Duration::from_secs(2),
145            ipc_event_loop(
146                &mut child_stdin,
147                &mut child_stdout,
148                dispatcher,
149                resource_dispatcher,
150                stash_dispatcher,
151            ),
152        )
153        .await;
154
155        match result {
156            Ok(inner) => inner,
157            Err(_elapsed) => {
158                // Timeout — kill the child process
159                let _ = child.kill().await;
160                Err(SandboxError::Timeout {
161                    timeout_ms: timeout.as_millis() as u64,
162                })
163            }
164        }
165    }
166}
167
168/// Run the IPC event loop: read messages from the child, dispatch tool calls,
169/// resource reads, and stash operations, then return the final result.
170///
171/// Generic over I/O types so both single-execution [`SandboxHost`] and the
172/// worker pool can reuse this loop.
173#[tracing::instrument(skip_all)]
174pub(crate) async fn ipc_event_loop<W, R>(
175    child_stdin: &mut W,
176    child_stdout: &mut R,
177    dispatcher: Arc<dyn ToolDispatcher>,
178    resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
179    stash_dispatcher: Option<Arc<dyn StashDispatcher>>,
180) -> Result<serde_json::Value, SandboxError>
181where
182    W: AsyncWrite + Unpin,
183    R: AsyncRead + Unpin,
184{
185    loop {
186        let msg: Option<ChildMessage> = read_message(child_stdout)
187            .await
188            .map_err(|e| SandboxError::Execution(anyhow::anyhow!("IPC read error: {}", e)))?;
189
190        match msg {
191            Some(ChildMessage::ExecutionComplete {
192                result,
193                error_kind,
194                timeout_ms: structured_timeout_ms,
195            }) => {
196                return match result {
197                    Ok(value) => Ok(value),
198                    Err(err) => {
199                        // Use error_kind to reconstruct the correct SandboxError variant.
200                        // Falls back to JsError if error_kind is absent (backward compat
201                        // with workers that predate the error_kind field).
202                        match error_kind {
203                            Some(crate::ipc::ErrorKind::Timeout) => {
204                                // Prefer structured timeout_ms field (v0.3.1+).
205                                // Fall back to string parsing for v0.3.0 workers.
206                                let timeout_ms = structured_timeout_ms.unwrap_or_else(|| {
207                                    err.split("after ")
208                                        .nth(1)
209                                        .and_then(|s| s.trim_end_matches("ms").parse::<u64>().ok())
210                                        .unwrap_or(0)
211                                });
212                                Err(SandboxError::Timeout { timeout_ms })
213                            }
214                            Some(crate::ipc::ErrorKind::HeapLimit) => {
215                                Err(SandboxError::HeapLimitExceeded)
216                            }
217                            Some(crate::ipc::ErrorKind::Execution) => {
218                                Err(SandboxError::Execution(anyhow::anyhow!("{}", err)))
219                            }
220                            Some(crate::ipc::ErrorKind::JsError) | None => {
221                                // Strip "javascript error: " prefix if already applied by the
222                                // worker's SandboxError::JsError.to_string(), preventing double
223                                // wrapping like "javascript error: javascript error: <msg>".
224                                let message = err
225                                    .strip_prefix("javascript error: ")
226                                    .unwrap_or(&err)
227                                    .to_string();
228                                Err(SandboxError::JsError { message })
229                            }
230                        }
231                    }
232                };
233            }
234            Some(ChildMessage::ToolCallRequest {
235                request_id,
236                server,
237                tool,
238                args,
239            }) => {
240                // Dispatch the tool call through the parent's dispatcher
241                let tool_result = dispatcher.call_tool(&server, &tool, args).await;
242
243                let response = ParentMessage::ToolCallResult {
244                    request_id,
245                    result: tool_result.map_err(|e| IpcDispatchError::from(&e)),
246                };
247
248                write_message(child_stdin, &response).await.map_err(|e| {
249                    SandboxError::Execution(anyhow::anyhow!("failed to send tool result: {}", e))
250                })?;
251            }
252            Some(ChildMessage::ResourceReadRequest {
253                request_id,
254                server,
255                uri,
256            }) => {
257                // Defense-in-depth: validate URI at host level too
258                let result = if let Err(e) = crate::ops::validate_resource_uri(&uri) {
259                    Err(IpcDispatchError::from_string(e))
260                } else {
261                    match &resource_dispatcher {
262                        Some(rd) => rd
263                            .read_resource(&server, &uri)
264                            .await
265                            .map_err(|e| IpcDispatchError::from(&e)),
266                        None => Err(IpcDispatchError::from_string(
267                            "resource dispatcher not available".to_string(),
268                        )),
269                    }
270                };
271
272                let response = ParentMessage::ResourceReadResult { request_id, result };
273
274                write_message(child_stdin, &response).await.map_err(|e| {
275                    SandboxError::Execution(anyhow::anyhow!(
276                        "failed to send resource result: {}",
277                        e
278                    ))
279                })?;
280            }
281            Some(ChildMessage::StashPut {
282                request_id,
283                key,
284                value,
285                ttl_secs,
286                group,
287            }) => {
288                let result = match &stash_dispatcher {
289                    Some(sd) => sd
290                        .put(&key, value, ttl_secs, group)
291                        .await
292                        .map_err(|e| IpcDispatchError::from(&e)),
293                    None => Err(IpcDispatchError::from_string(
294                        "stash dispatcher not available".to_string(),
295                    )),
296                };
297
298                let response = ParentMessage::StashResult { request_id, result };
299                write_message(child_stdin, &response).await.map_err(|e| {
300                    SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
301                })?;
302            }
303            Some(ChildMessage::StashGet {
304                request_id,
305                key,
306                group,
307            }) => {
308                let result = match &stash_dispatcher {
309                    Some(sd) => sd
310                        .get(&key, group)
311                        .await
312                        .map_err(|e| IpcDispatchError::from(&e)),
313                    None => Err(IpcDispatchError::from_string(
314                        "stash dispatcher not available".to_string(),
315                    )),
316                };
317
318                let response = ParentMessage::StashResult { request_id, result };
319                write_message(child_stdin, &response).await.map_err(|e| {
320                    SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
321                })?;
322            }
323            Some(ChildMessage::StashDelete {
324                request_id,
325                key,
326                group,
327            }) => {
328                let result = match &stash_dispatcher {
329                    Some(sd) => sd
330                        .delete(&key, group)
331                        .await
332                        .map_err(|e| IpcDispatchError::from(&e)),
333                    None => Err(IpcDispatchError::from_string(
334                        "stash dispatcher not available".to_string(),
335                    )),
336                };
337
338                let response = ParentMessage::StashResult { request_id, result };
339                write_message(child_stdin, &response).await.map_err(|e| {
340                    SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
341                })?;
342            }
343            Some(ChildMessage::StashKeys { request_id, group }) => {
344                let result = match &stash_dispatcher {
345                    Some(sd) => sd.keys(group).await.map_err(|e| IpcDispatchError::from(&e)),
346                    None => Err(IpcDispatchError::from_string(
347                        "stash dispatcher not available".to_string(),
348                    )),
349                };
350
351                let response = ParentMessage::StashResult { request_id, result };
352                write_message(child_stdin, &response).await.map_err(|e| {
353                    SandboxError::Execution(anyhow::anyhow!("failed to send stash result: {}", e))
354                })?;
355            }
356            Some(ChildMessage::Log { message }) => {
357                tracing::info!(target: "forge::sandbox::worker", "{}", message);
358            }
359            Some(ChildMessage::ResetComplete) => {
360                // Unexpected in single-execution mode; ignore.
361                tracing::warn!("received unexpected ResetComplete in single-execution mode");
362            }
363            None => {
364                // Child closed stdout without sending ExecutionComplete
365                return Err(SandboxError::Execution(anyhow::anyhow!(
366                    "worker exited without sending result"
367                )));
368            }
369        }
370    }
371}
372
373/// Find the `forgemax-worker` binary.
374///
375/// Search order:
376/// 1. `FORGE_WORKER_BIN` environment variable (must be absolute path)
377/// 2. Same directory as the current executable
378///
379/// On Unix, rejects world-writable binaries (mode & 0o002 != 0).
380#[tracing::instrument]
381pub fn find_worker_binary() -> Result<PathBuf, SandboxError> {
382    // 1. Explicit env var — must be an absolute path
383    if let Ok(path) = std::env::var("FORGE_WORKER_BIN") {
384        let p = PathBuf::from(&path);
385        if !p.is_absolute() {
386            return Err(SandboxError::Execution(anyhow::anyhow!(
387                "FORGE_WORKER_BIN must be an absolute path, got: {}",
388                path
389            )));
390        }
391        if p.exists() {
392            validate_binary_permissions(&p)?;
393            return Ok(p);
394        }
395    }
396
397    // 2. Same directory as current executable (or parent, for test binaries in deps/)
398    if let Ok(exe) = std::env::current_exe() {
399        if let Some(dir) = exe.parent() {
400            let worker = dir.join("forgemax-worker");
401            if worker.exists() {
402                validate_binary_permissions(&worker)?;
403                return Ok(worker);
404            }
405            // Test binaries are in target/debug/deps/ but worker is in target/debug/
406            if let Some(parent) = dir.parent() {
407                let worker = parent.join("forgemax-worker");
408                if worker.exists() {
409                    validate_binary_permissions(&worker)?;
410                    return Ok(worker);
411                }
412            }
413        }
414    }
415
416    Err(SandboxError::Execution(anyhow::anyhow!(
417        "forgemax-worker binary not found. Set FORGE_WORKER_BIN or install alongside forgemax"
418    )))
419}
420
421/// Validate binary file permissions (Unix only).
422///
423/// Rejects:
424/// - World-writable binaries (mode & 0o002)
425/// - Binaries in world-writable directories without the sticky bit,
426///   which would allow binary replacement attacks.
427fn validate_binary_permissions(_path: &std::path::Path) -> Result<(), SandboxError> {
428    #[cfg(unix)]
429    {
430        use std::os::unix::fs::PermissionsExt;
431
432        // Check the binary itself (follows symlinks via std::fs::metadata)
433        let metadata = std::fs::metadata(_path).map_err(|e| {
434            SandboxError::Execution(anyhow::anyhow!(
435                "cannot read metadata for {}: {}",
436                _path.display(),
437                e
438            ))
439        })?;
440        let mode = metadata.permissions().mode();
441        if mode & 0o002 != 0 {
442            return Err(SandboxError::Execution(anyhow::anyhow!(
443                "insecure permissions on worker binary {}: mode {:o} is world-writable",
444                _path.display(),
445                mode,
446            )));
447        }
448
449        // Check the parent directory — world-writable without sticky bit allows replacement
450        if let Some(parent) = _path.parent() {
451            let dir_metadata = std::fs::metadata(parent).map_err(|e| {
452                SandboxError::Execution(anyhow::anyhow!(
453                    "cannot read metadata for parent directory {}: {}",
454                    parent.display(),
455                    e
456                ))
457            })?;
458            let dir_mode = dir_metadata.permissions().mode();
459            // World-writable (0o002) without sticky bit (0o1000) is insecure
460            if dir_mode & 0o002 != 0 && dir_mode & 0o1000 == 0 {
461                return Err(SandboxError::Execution(anyhow::anyhow!(
462                    "insecure directory for worker binary {}: parent {} mode {:o} is world-writable without sticky bit",
463                    _path.display(),
464                    parent.display(),
465                    dir_mode,
466                )));
467            }
468        }
469    }
470    Ok(())
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn find_worker_binary_from_exe_dir() {
479        // This test checks that find_worker_binary can locate the worker
480        // when it's in the same directory as the test binary.
481        // During `cargo test`, the worker binary should be in the target dir.
482        // This may not always work, so we just verify the function doesn't panic.
483        let _ = find_worker_binary();
484    }
485
486    #[test]
487    fn find_worker_binary_rejects_relative_env_var() {
488        // A relative path in FORGE_WORKER_BIN should be rejected
489        temp_env::with_var("FORGE_WORKER_BIN", Some("./relative/path"), || {
490            let result = find_worker_binary();
491            let err = result.unwrap_err().to_string();
492            assert!(
493                err.contains("absolute"),
494                "expected 'absolute' in error: {err}"
495            );
496        });
497    }
498
499    #[test]
500    fn find_worker_binary_no_which_fallback() {
501        // With no binary anywhere and no env var, should get a clear error
502        // (not a random system path from `which`)
503        temp_env::with_var_unset("FORGE_WORKER_BIN", || {
504            let result = find_worker_binary();
505            if let Err(e) = result {
506                let msg = e.to_string();
507                assert!(
508                    !msg.contains("PATH"),
509                    "error should not mention PATH: {msg}"
510                );
511                assert!(
512                    msg.contains("FORGE_WORKER_BIN") || msg.contains("forgemax"),
513                    "error should guide user: {msg}"
514                );
515            }
516            // If Ok, the binary was found via exe dir — that's fine
517        });
518    }
519
520    #[cfg(unix)]
521    #[test]
522    fn find_worker_binary_rejects_world_writable() {
523        use std::os::unix::fs::PermissionsExt;
524
525        let dir = tempfile::tempdir().unwrap();
526        let bin = dir.path().join("forgemax-worker");
527        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
528        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o777)).unwrap();
529
530        temp_env::with_var("FORGE_WORKER_BIN", Some(bin.to_str().unwrap()), || {
531            let result = find_worker_binary();
532            let err = result.unwrap_err().to_string();
533            assert!(
534                err.contains("insecure"),
535                "expected 'insecure' in error: {err}"
536            );
537        });
538    }
539
540    #[cfg(unix)]
541    #[test]
542    fn find_worker_binary_accepts_secure_binary() {
543        use std::os::unix::fs::PermissionsExt;
544
545        let dir = tempfile::tempdir().unwrap();
546        let bin = dir.path().join("forgemax-worker");
547        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
548        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
549
550        temp_env::with_var("FORGE_WORKER_BIN", Some(bin.to_str().unwrap()), || {
551            let result = find_worker_binary();
552            assert!(result.is_ok(), "expected Ok, got: {:?}", result);
553        });
554    }
555
556    // --- BIN-SEC-01: symlink to world-writable binary is rejected ---
557    #[cfg(unix)]
558    #[test]
559    fn bin_sec_01_symlink_to_world_writable_rejected() {
560        use std::os::unix::fs::PermissionsExt;
561
562        let dir = tempfile::tempdir().unwrap();
563        let real_bin = dir.path().join("real-worker");
564        std::fs::write(&real_bin, b"#!/bin/sh\n").unwrap();
565        std::fs::set_permissions(&real_bin, std::fs::Permissions::from_mode(0o777)).unwrap();
566
567        let link = dir.path().join("forgemax-worker");
568        std::os::unix::fs::symlink(&real_bin, &link).unwrap();
569
570        // validate_binary_permissions follows symlinks (uses std::fs::metadata)
571        let result = validate_binary_permissions(&link);
572        assert!(
573            result.is_err(),
574            "should reject symlink to world-writable binary"
575        );
576        let msg = result.unwrap_err().to_string();
577        assert!(msg.contains("insecure"), "should say insecure: {msg}");
578    }
579
580    // --- BIN-SEC-02: symlink to secure binary in secure dir is accepted ---
581    #[cfg(unix)]
582    #[test]
583    fn bin_sec_02_symlink_to_secure_accepted() {
584        use std::os::unix::fs::PermissionsExt;
585
586        let dir = tempfile::tempdir().unwrap();
587        let real_bin = dir.path().join("real-worker");
588        std::fs::write(&real_bin, b"#!/bin/sh\n").unwrap();
589        std::fs::set_permissions(&real_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
590
591        let link = dir.path().join("forgemax-worker");
592        std::os::unix::fs::symlink(&real_bin, &link).unwrap();
593
594        let result = validate_binary_permissions(&link);
595        assert!(
596            result.is_ok(),
597            "should accept symlink to secure binary: {:?}",
598            result
599        );
600    }
601
602    // --- BIN-SEC-03: world-writable directory without sticky bit is rejected ---
603    #[cfg(unix)]
604    #[test]
605    fn bin_sec_03_world_writable_dir_without_sticky_rejected() {
606        use std::os::unix::fs::PermissionsExt;
607
608        let dir = tempfile::tempdir().unwrap();
609        // Make the directory world-writable without sticky bit
610        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
611
612        let bin = dir.path().join("forgemax-worker");
613        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
614        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
615
616        let result = validate_binary_permissions(&bin);
617        assert!(
618            result.is_err(),
619            "should reject binary in world-writable dir"
620        );
621        let msg = result.unwrap_err().to_string();
622        assert!(
623            msg.contains("world-writable"),
624            "should say world-writable: {msg}"
625        );
626    }
627
628    #[test]
629    fn worker_stderr_is_conditional_on_debug() {
630        // Verify the stderr configuration logic
631        // Without FORGE_DEBUG: should take the null path
632        temp_env::with_var_unset("FORGE_DEBUG", || {
633            assert!(std::env::var("FORGE_DEBUG").is_err());
634        });
635
636        // With FORGE_DEBUG: should take the inherit path
637        temp_env::with_var("FORGE_DEBUG", Some("1"), || {
638            assert!(std::env::var("FORGE_DEBUG").is_ok());
639        });
640    }
641
642    // --- H3: Worker Stderr Hardening Tests ---
643
644    #[test]
645    fn h3_01_host_worker_stderr_never_inherits() {
646        // In both debug and non-debug modes, we never use Stdio::inherit().
647        // Non-debug → null, debug → piped. This test verifies the code paths.
648        temp_env::with_var_unset("FORGE_DEBUG", || {
649            assert!(
650                std::env::var("FORGE_DEBUG").is_err(),
651                "FORGE_DEBUG should not be set"
652            );
653            // Non-debug path → Stdio::null() (no inherit)
654        });
655        temp_env::with_var("FORGE_DEBUG", Some("1"), || {
656            assert!(
657                std::env::var("FORGE_DEBUG").is_ok(),
658                "FORGE_DEBUG should be set"
659            );
660            // Debug path → Stdio::piped() (not inherit)
661        });
662    }
663
664    #[test]
665    fn h3_02_pool_worker_stderr_never_inherits() {
666        // Same verification for pool code path — the test confirms the logic
667        // doesn't use Stdio::inherit() in either mode.
668        temp_env::with_var_unset("FORGE_DEBUG", || {
669            let debug = std::env::var("FORGE_DEBUG").is_ok();
670            assert!(!debug, "non-debug should use null");
671        });
672        temp_env::with_var("FORGE_DEBUG", Some("1"), || {
673            let debug = std::env::var("FORGE_DEBUG").is_ok();
674            assert!(debug, "debug should use piped (not inherit)");
675        });
676    }
677
678    #[tokio::test]
679    async fn h3_03_debug_mode_captures_bounded_stderr() {
680        // Verify capture_bounded_stderr reads at most MAX_STDERR_CAPTURE_BYTES
681        use std::io::Cursor;
682
683        // Create oversized stderr data (8KB > 4KB limit)
684        let large_data = vec![b'E'; 8192];
685        let cursor = Cursor::new(large_data);
686
687        // Should not panic and should complete
688        capture_bounded_stderr(cursor).await;
689
690        // Also test with small data
691        let small_data = b"some warning\n".to_vec();
692        let cursor = Cursor::new(small_data);
693        capture_bounded_stderr(cursor).await;
694    }
695
696    #[tokio::test]
697    async fn h3_04_non_debug_mode_nulls_stderr() {
698        // Verify that without FORGE_DEBUG, we produce Stdio::null() not inherit
699        temp_env::with_var_unset("FORGE_DEBUG", || {
700            let debug = std::env::var("FORGE_DEBUG").is_ok();
701            assert!(
702                !debug,
703                "without FORGE_DEBUG, stderr should be null (not inherit)"
704            );
705        });
706    }
707
708    // --- H1: host-side group isolation tests ---
709
710    /// Mock StashDispatcher that records the group parameter
711    struct GroupRecordingStash {
712        recorded_groups: std::sync::Mutex<Vec<Option<String>>>,
713    }
714
715    #[async_trait::async_trait]
716    impl crate::StashDispatcher for GroupRecordingStash {
717        async fn put(
718            &self,
719            _key: &str,
720            _value: serde_json::Value,
721            _ttl_secs: Option<u32>,
722            current_group: Option<String>,
723        ) -> Result<serde_json::Value, forge_error::DispatchError> {
724            self.recorded_groups.lock().unwrap().push(current_group);
725            Ok(serde_json::json!({"ok": true}))
726        }
727
728        async fn get(
729            &self,
730            _key: &str,
731            current_group: Option<String>,
732        ) -> Result<serde_json::Value, forge_error::DispatchError> {
733            self.recorded_groups.lock().unwrap().push(current_group);
734            Ok(serde_json::json!(null))
735        }
736
737        async fn delete(
738            &self,
739            _key: &str,
740            current_group: Option<String>,
741        ) -> Result<serde_json::Value, forge_error::DispatchError> {
742            self.recorded_groups.lock().unwrap().push(current_group);
743            Ok(serde_json::json!({"deleted": true}))
744        }
745
746        async fn keys(
747            &self,
748            current_group: Option<String>,
749        ) -> Result<serde_json::Value, forge_error::DispatchError> {
750            self.recorded_groups.lock().unwrap().push(current_group);
751            Ok(serde_json::json!([]))
752        }
753    }
754
755    /// Mock ToolDispatcher (never called in these tests)
756    struct NeverCalledTool;
757
758    #[async_trait::async_trait]
759    impl crate::ToolDispatcher for NeverCalledTool {
760        async fn call_tool(
761            &self,
762            _server: &str,
763            _tool: &str,
764            _args: serde_json::Value,
765        ) -> Result<serde_json::Value, forge_error::DispatchError> {
766            panic!("tool call not expected");
767        }
768    }
769
770    /// Helper: write child messages then ExecutionComplete, run ipc_event_loop
771    async fn run_ipc_event_loop_with_messages(
772        messages: Vec<crate::ipc::ChildMessage>,
773        stash: Arc<GroupRecordingStash>,
774    ) {
775        use crate::ipc::write_message;
776
777        // Build the child's "stdout" (what parent reads)
778        let mut child_output = Vec::new();
779        for msg in &messages {
780            write_message(&mut child_output, msg).await.unwrap();
781        }
782        // Append ExecutionComplete
783        let complete = crate::ipc::ChildMessage::ExecutionComplete {
784            result: Ok(serde_json::json!("done")),
785            error_kind: None,
786            timeout_ms: None,
787        };
788        write_message(&mut child_output, &complete).await.unwrap();
789
790        let mut child_stdout = std::io::Cursor::new(child_output);
791        let mut child_stdin = Vec::new();
792
793        let tool: Arc<dyn crate::ToolDispatcher> = Arc::new(NeverCalledTool);
794        let resource: Option<Arc<dyn crate::ResourceDispatcher>> = None;
795        let stash_disp: Option<Arc<dyn crate::StashDispatcher>> = Some(stash);
796
797        let result = ipc_event_loop(
798            &mut child_stdin,
799            &mut child_stdout,
800            tool,
801            resource,
802            stash_disp,
803        )
804        .await;
805        assert!(result.is_ok());
806    }
807
808    #[tokio::test]
809    async fn h1_host_07_ipc_event_loop_passes_group_to_stash_put() {
810        let stash = Arc::new(GroupRecordingStash {
811            recorded_groups: std::sync::Mutex::new(Vec::new()),
812        });
813
814        run_ipc_event_loop_with_messages(
815            vec![crate::ipc::ChildMessage::StashPut {
816                request_id: 1,
817                key: "k".into(),
818                value: serde_json::json!("v"),
819                ttl_secs: None,
820                group: Some("mygroup".into()),
821            }],
822            stash.clone(),
823        )
824        .await;
825
826        let groups = stash.recorded_groups.lock().unwrap();
827        assert_eq!(groups.len(), 1);
828        assert_eq!(groups[0], Some("mygroup".into()));
829    }
830
831    #[tokio::test]
832    async fn h1_host_08_ipc_event_loop_passes_group_to_stash_get() {
833        let stash = Arc::new(GroupRecordingStash {
834            recorded_groups: std::sync::Mutex::new(Vec::new()),
835        });
836
837        run_ipc_event_loop_with_messages(
838            vec![crate::ipc::ChildMessage::StashGet {
839                request_id: 1,
840                key: "k".into(),
841                group: Some("getgroup".into()),
842            }],
843            stash.clone(),
844        )
845        .await;
846
847        let groups = stash.recorded_groups.lock().unwrap();
848        assert_eq!(groups.len(), 1);
849        assert_eq!(groups[0], Some("getgroup".into()));
850    }
851
852    #[tokio::test]
853    async fn h1_host_09_ipc_event_loop_passes_group_to_stash_delete() {
854        let stash = Arc::new(GroupRecordingStash {
855            recorded_groups: std::sync::Mutex::new(Vec::new()),
856        });
857
858        run_ipc_event_loop_with_messages(
859            vec![crate::ipc::ChildMessage::StashDelete {
860                request_id: 1,
861                key: "k".into(),
862                group: Some("delgroup".into()),
863            }],
864            stash.clone(),
865        )
866        .await;
867
868        let groups = stash.recorded_groups.lock().unwrap();
869        assert_eq!(groups.len(), 1);
870        assert_eq!(groups[0], Some("delgroup".into()));
871    }
872
873    #[tokio::test]
874    async fn h1_host_10_ipc_event_loop_passes_group_to_stash_keys() {
875        let stash = Arc::new(GroupRecordingStash {
876            recorded_groups: std::sync::Mutex::new(Vec::new()),
877        });
878
879        run_ipc_event_loop_with_messages(
880            vec![crate::ipc::ChildMessage::StashKeys {
881                request_id: 1,
882                group: Some("keysgroup".into()),
883            }],
884            stash.clone(),
885        )
886        .await;
887
888        let groups = stash.recorded_groups.lock().unwrap();
889        assert_eq!(groups.len(), 1);
890        assert_eq!(groups[0], Some("keysgroup".into()));
891    }
892
893    #[tokio::test]
894    async fn h1_host_11_ipc_event_loop_passes_none_group_when_absent() {
895        let stash = Arc::new(GroupRecordingStash {
896            recorded_groups: std::sync::Mutex::new(Vec::new()),
897        });
898
899        run_ipc_event_loop_with_messages(
900            vec![crate::ipc::ChildMessage::StashPut {
901                request_id: 1,
902                key: "k".into(),
903                value: serde_json::json!("v"),
904                ttl_secs: None,
905                group: None,
906            }],
907            stash.clone(),
908        )
909        .await;
910
911        let groups = stash.recorded_groups.lock().unwrap();
912        assert_eq!(groups.len(), 1);
913        assert_eq!(groups[0], None);
914    }
915
916    #[tokio::test]
917    async fn h1_host_12_ipc_event_loop_all_stash_ops_with_same_group() {
918        let stash = Arc::new(GroupRecordingStash {
919            recorded_groups: std::sync::Mutex::new(Vec::new()),
920        });
921
922        run_ipc_event_loop_with_messages(
923            vec![
924                crate::ipc::ChildMessage::StashPut {
925                    request_id: 1,
926                    key: "k".into(),
927                    value: serde_json::json!("v"),
928                    ttl_secs: None,
929                    group: Some("shared".into()),
930                },
931                crate::ipc::ChildMessage::StashGet {
932                    request_id: 2,
933                    key: "k".into(),
934                    group: Some("shared".into()),
935                },
936                crate::ipc::ChildMessage::StashDelete {
937                    request_id: 3,
938                    key: "k".into(),
939                    group: Some("shared".into()),
940                },
941                crate::ipc::ChildMessage::StashKeys {
942                    request_id: 4,
943                    group: Some("shared".into()),
944                },
945            ],
946            stash.clone(),
947        )
948        .await;
949
950        let groups = stash.recorded_groups.lock().unwrap();
951        assert_eq!(groups.len(), 4);
952        for g in groups.iter() {
953            assert_eq!(g, &Some("shared".into()));
954        }
955    }
956}