Skip to main content

kaish_kernel/
output_limit.rs

1//! Configurable output size limits for agent safety.
2//!
3//! When output exceeds the threshold the result is capped and `ExecResult.out`
4//! is replaced with a head+tail preview. Two strategies, selected at runtime by
5//! [`SpillMode`]:
6//! - [`SpillMode::Disk`] (default): the full output is written to a spill file
7//!   on the real filesystem and the preview points at it. The agent can then
8//!   selectively read the file.
9//! - [`SpillMode::Memory`]: the output is truncated in memory only — no disk
10//!   I/O, no recoverable file. For runtime read-only kernels (e.g. kaibo) that
11//!   must not touch the host filesystem even when `localfs` is compiled in.
12//!   Memory stays bounded regardless of how much the command produces.
13//!
14//! Either way the exit code is remapped to 3 (`did_spill`) so callers can tell
15//! the output was capped.
16//!
17//! Per-mode defaults: sandboxed-agent kernels get an 8KB limit, REPL/test
18//! kernels are unlimited. Runtime-switchable via the `kaish-output-limit` builtin.
19
20use std::path::PathBuf;
21
22use crate::interpreter::ExecResult;
23#[cfg(feature = "localfs")]
24use crate::paths;
25
26/// Default output limit for the sandboxed-agent preset (8KB).
27const DEFAULT_AGENT_LIMIT: usize = 8 * 1024;
28
29/// Default head preview size (bytes of output start to keep).
30const DEFAULT_HEAD_BYTES: usize = 1024;
31
32/// Default tail preview size (bytes of output end to keep).
33const DEFAULT_TAIL_BYTES: usize = 512;
34
35/// Where overflow output goes when it exceeds the limit.
36///
37/// This is a *runtime* choice, distinct from the compile-time `localfs`
38/// feature: a `localfs`-built kernel can still be told to truncate in memory.
39/// A build without `localfs` always behaves as [`SpillMode::Memory`] regardless
40/// of this setting, since disk I/O is unavailable.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42#[non_exhaustive]
43pub enum SpillMode {
44    /// Write overflow to a disk spill file under `paths::spill_dir()` and keep a
45    /// head+tail preview in the result (the message carries the file path).
46    /// Requires the `localfs` feature. This is the default.
47    ///
48    /// Auto-overridden to [`Memory`](Self::Memory) at kernel construction when
49    /// the VFS mount is `NoLocal` (memory-only) — such a kernel has no host
50    /// filesystem to spill to. See `Kernel::assemble`.
51    #[default]
52    Disk,
53    /// Truncate in memory to head+tail only — no disk I/O, no recoverable file.
54    /// For runtime read-only kernels (e.g. kaibo) that must never touch the host
55    /// filesystem even when `localfs` is compiled in.
56    Memory,
57}
58
59/// Configurable output size limit.
60///
61/// Threaded through `KernelConfig` → `ExecContext` → kernel pipeline execution.
62/// Runtime-mutable via the `kaish-output-limit` builtin.
63#[derive(Debug, Clone)]
64pub struct OutputLimitConfig {
65    max_bytes: Option<usize>,
66    head_bytes: usize,
67    tail_bytes: usize,
68    spill_mode: SpillMode,
69}
70
71impl OutputLimitConfig {
72    /// No limiting — REPL/embedded/test default.
73    pub fn none() -> Self {
74        Self {
75            max_bytes: None,
76            head_bytes: DEFAULT_HEAD_BYTES,
77            tail_bytes: DEFAULT_TAIL_BYTES,
78            spill_mode: SpillMode::Disk,
79        }
80    }
81
82    /// Default limit used by `on` subcommand and `set -o output-limit`.
83    pub fn default_limit() -> usize {
84        DEFAULT_AGENT_LIMIT
85    }
86
87    /// Sandboxed-agent defaults: 8KB limit, 1KB head, 512B tail, disk spill.
88    pub fn agent() -> Self {
89        Self {
90            max_bytes: Some(DEFAULT_AGENT_LIMIT),
91            head_bytes: DEFAULT_HEAD_BYTES,
92            tail_bytes: DEFAULT_TAIL_BYTES,
93            spill_mode: SpillMode::Disk,
94        }
95    }
96
97    /// Switch to in-memory truncation — no disk spill, no host filesystem
98    /// writes. For runtime read-only kernels (e.g. kaibo). Builder form of
99    /// [`set_spill_mode`](Self::set_spill_mode).
100    ///
101    /// Note: a `NoLocal` VFS mount forces this mode automatically at kernel
102    /// construction, so an embedder only needs this for a `localfs`-mounted
103    /// kernel it nonetheless wants to keep off the host disk.
104    pub fn in_memory(mut self) -> Self {
105        self.spill_mode = SpillMode::Memory;
106        self
107    }
108
109    /// Whether output limiting is enabled.
110    pub fn is_enabled(&self) -> bool {
111        self.max_bytes.is_some()
112    }
113
114    /// The spill mode (disk vs in-memory truncation).
115    pub fn spill_mode(&self) -> SpillMode {
116        self.spill_mode
117    }
118
119    /// Set the spill mode.
120    pub fn set_spill_mode(&mut self, mode: SpillMode) {
121        self.spill_mode = mode;
122    }
123
124    /// The maximum output size in bytes, if set.
125    pub fn max_bytes(&self) -> Option<usize> {
126        self.max_bytes
127    }
128
129    /// Bytes of output head to preserve in truncated result.
130    pub fn head_bytes(&self) -> usize {
131        self.head_bytes
132    }
133
134    /// Bytes of output tail to preserve in truncated result.
135    pub fn tail_bytes(&self) -> usize {
136        self.tail_bytes
137    }
138
139    /// Set the output limit. `None` disables limiting.
140    pub fn set_limit(&mut self, max: Option<usize>) {
141        self.max_bytes = max;
142    }
143
144    /// Set the head preview size.
145    pub fn set_head_bytes(&mut self, bytes: usize) {
146        self.head_bytes = bytes;
147    }
148
149    /// Set the tail preview size.
150    pub fn set_tail_bytes(&mut self, bytes: usize) {
151        self.tail_bytes = bytes;
152    }
153}
154
155/// Result of a spill operation.
156pub struct SpillResult {
157    pub path: PathBuf,
158    pub total_bytes: usize,
159}
160
161/// Check if the result output exceeds the limit and spill to disk if so.
162///
163/// Mutates `result.out` in place: replaces with head+tail+pointer message.
164/// Returns `Some(SpillResult)` if a spill file was written, `None` otherwise.
165///
166/// If the filesystem write fails, the result is replaced with an error.
167/// Fail fast: truncating output silently could corrupt structured data
168/// that an agent acts on. An explicit error is safer.
169///
170/// In [`SpillMode::Memory`], or in any build without the `localfs` feature,
171/// performs in-memory head+tail truncation (no disk I/O) instead.
172pub async fn spill_if_needed(
173    result: &mut ExecResult,
174    config: &OutputLimitConfig,
175) -> Option<SpillResult> {
176    let max = config.max_bytes?;
177
178    // Remember whether a lower-level capture ring already overflowed before
179    // this check ran (kernel.rs's `try_execute_external` / dispatch.rs's
180    // test-only twin flip `did_spill` when the fixed ~10MB stdout ring evicts
181    // output, independent of whether this output-limit is even enabled). If
182    // so, `result`'s current bytes are ALREADY a ring-capped tail, not the
183    // command's full output — any disk spill performed below would be
184    // spilling that already-partial tail, so a "full output at <path>"
185    // message would mislead (GH #212 part 2). Only read by the disk-spill
186    // path below, which is itself `localfs`-only.
187    #[cfg(feature = "localfs")]
188    let ring_already_overflowed = result.did_spill;
189
190    // Binary payloads are measured and spilled by RAW bytes. text_out() would
191    // lossy-decode a Bytes result — corrupting the spill file and mis-measuring
192    // the size (U+FFFD is 3 bytes per invalid byte). Handle them up front.
193    if let Some(total) = result.out_bytes().map(|b| b.len()) {
194        if total <= max {
195            return None;
196        }
197        #[cfg(feature = "localfs")]
198        if config.spill_mode == SpillMode::Disk {
199            let bytes = result.out_bytes().unwrap_or_default().to_vec();
200            return match write_spill_file(&bytes).await {
201                Ok((path, written)) => {
202                    result.set_out(format!(
203                        "[binary output: {total} bytes spilled to {} — read it with `cat {}`]",
204                        path.display(),
205                        path.display()
206                    ));
207                    result.did_spill = true;
208                    Some(SpillResult { path, total_bytes: written })
209                }
210                Err(e) => {
211                    tracing::error!("binary output spill failed: {}", e);
212                    *result = ExecResult::failure(
213                        1,
214                        format!(
215                            "binary output exceeded {max} byte limit ({total} bytes) and spill \
216                             to disk failed: {e}"
217                        ),
218                    );
219                    None
220                }
221            };
222        }
223        // Memory mode (or no localfs): bounded head+tail of the raw bytes. The
224        // result stays binary, just truncated.
225        let bytes = result.out_bytes().unwrap_or_default().to_vec();
226        let head_n = config.head_bytes.min(bytes.len());
227        let tail_n = config.tail_bytes.min(bytes.len().saturating_sub(head_n));
228        let mut truncated = bytes[..head_n].to_vec();
229        truncated.extend_from_slice(&bytes[bytes.len() - tail_n..]);
230        result.set_out_bytes(truncated);
231        result.did_spill = true;
232        return None;
233    }
234
235    // Disk spill requires `localfs` AND the caller selecting it. Memory mode
236    // (or a build without `localfs`) falls through to in-memory truncation.
237    #[cfg(feature = "localfs")]
238    if config.spill_mode == SpillMode::Disk {
239        // If result.out is already populated (external commands), check it directly
240        if !result.text_out().is_empty() && !result.has_output() {
241            let total = result.text_out().len();
242            if total <= max {
243                return None;
244            }
245            return spill_string(result, config, max, ring_already_overflowed).await;
246        }
247
248        // If we have structured OutputData, estimate size before materializing
249        let estimate = result.output().map(|o| o.estimated_byte_size());
250        if let Some(estimate) = estimate {
251            if estimate <= max {
252                // Re-check the actual size (the estimate is a lower bound) —
253                // but measure WITHOUT folding the tree into `.out`. `text_out()`
254                // renders the canonical string from `.output` when `.out` is
255                // empty, so the exact size is observable while the tree
256                // survives for an embedder to render: an `ls` listing keeps the
257                // per-node `EntryType` that makes colorizing possible at all.
258                if result.text_out().len() <= max {
259                    return None;
260                }
261                // Spilling replaces `.out` with a summary, so the tree stops
262                // describing this result and must not outlive it.
263                result.materialize();
264                return spill_string(result, config, max, ring_already_overflowed).await;
265            }
266
267            // Large — stream directly to spill file, never holding full String
268            return spill_output_data(result, config, max, ring_already_overflowed).await;
269        }
270
271        return None;
272    }
273
274    // In-memory head+tail truncation (Memory mode or no `localfs`): no disk I/O.
275    truncate_in_memory(result, config, max)
276}
277
278/// Apply the kernel's spill/exit-3 contract to a raw `ExecResult`: run the
279/// output-limit spill check when enabled, then remap the exit code to 3
280/// whenever `did_spill` ends up set — whether `spill_if_needed` just set it,
281/// or a lower-level capture-ring overflow already had (kernel.rs's
282/// `try_execute_external`, GH #191).
283///
284/// This is the ONE seam every execution surface that produces a raw
285/// `ExecResult` — the foreground pipeline (`Kernel::execute_pipeline`), a
286/// background job (`Kernel::execute_background`), a scatter worker
287/// (`run_parallel`) — must funnel through before treating that result as
288/// final. Without it, spilled output silently reports its ORIGINAL exit code
289/// instead of the loud 3, and a downstream success/failure decision (a
290/// background job's `JobStatus`, gather's 0-vs-123 aggregation) reads the
291/// wrong thing (GH #212).
292pub async fn apply_spill_contract(result: &mut ExecResult, config: &OutputLimitConfig) {
293    if config.is_enabled() {
294        let _ = spill_if_needed(result, config).await;
295    }
296    if result.did_spill {
297        // Idempotent: only capture `original_code` the first time. A result can
298        // arrive already remapped — backend conversions preserve both fields
299        // (`kaish-types/src/backend.rs`), so a pre-remapped result is supported
300        // input, not impossible state. Overwriting unconditionally replaced a
301        // real `Some(7)` with `Some(3)` and lost the actual exit code.
302        if result.original_code.is_none() {
303            result.original_code = Some(result.code);
304        }
305        result.code = 3;
306    }
307}
308
309/// Truncate output in memory to head+tail, with no disk I/O.
310///
311/// Sets `did_spill = true` so the kernel remaps the exit code to 3 — the same
312/// "output was capped" signal as a disk spill — but the message carries no file
313/// path because there is no recoverable file. Returns `None` (no `SpillResult`,
314/// since nothing was written); the caller distinguishes truncation via
315/// `result.did_spill`.
316///
317/// Memory is bounded: large structured `OutputData` is streamed through a byte
318/// budget rather than materialized into a full `String`, so a builtin emitting
319/// a huge tree (e.g. a recursive `ls` of a giant directory) cannot OOM a
320/// read-only kernel.
321fn truncate_in_memory(
322    result: &mut ExecResult,
323    config: &OutputLimitConfig,
324    max: usize,
325) -> Option<SpillResult> {
326    // Structured OutputData: estimate first. If it would clearly overflow,
327    // render only a bounded head prefix via `write_canonical` rather than
328    // materializing the whole thing.
329    if let Some(output) = result.output() {
330        let estimate = output.estimated_byte_size();
331        if estimate > max {
332            // Render a bounded head prefix only — no full materialization.
333            let mut buf = Vec::with_capacity(config.head_bytes + 64);
334            // write_canonical stops shortly after the budget; ignore the count.
335            let _ = output.write_canonical(&mut buf, Some(config.head_bytes));
336            let s = String::from_utf8_lossy(&buf);
337            let head = truncate_to_char_boundary(&s, config.head_bytes);
338            let truncated = format!(
339                "{}\n...\n[output truncated in memory: ~{} bytes (exceeds {} byte limit) — head only, no spill file]",
340                head, estimate, max
341            );
342            result.set_out(truncated);
343            // `.out` is now a truncation summary, not a rendering of the tree.
344            // Every renderer prefers `.output`, so leaving it would show the
345            // full listing and hide the fact that output was capped.
346            result.set_output(None);
347            result.did_spill = true;
348            return None;
349        }
350        // Small enough to measure exactly, without folding the tree in.
351    }
352
353    let total = result.text_out().len();
354    if total <= max {
355        return None;
356    }
357
358    // Already-materialized text fits in memory (it was produced into RAM
359    // regardless) — give a precise head+tail+total.
360    let text = result.text_out().into_owned();
361    let head = truncate_to_char_boundary(&text, config.head_bytes);
362    let tail = tail_from_str(&text, config.tail_bytes);
363    let truncated = format!(
364        "{}\n...\n{}\n[output truncated in memory: {} bytes total — no spill file]",
365        head, tail, total
366    );
367    result.set_out(truncated);
368    // Same reason as above: the text is a summary now, not the tree.
369    result.set_output(None);
370    result.did_spill = true;
371    None
372}
373
374/// Spill an already-materialized string in result.out.
375///
376/// `ring_already_overflowed` is `true` when a lower-level capture ring had
377/// already flipped `did_spill` before this check ran (see `spill_if_needed`)
378/// — the message then must not claim the spill file holds the "full output",
379/// since it only holds the ring-capped tail (GH #212 part 2).
380#[cfg(feature = "localfs")]
381async fn spill_string(
382    result: &mut ExecResult,
383    config: &OutputLimitConfig,
384    max: usize,
385    ring_already_overflowed: bool,
386) -> Option<SpillResult> {
387    let total = result.text_out().len();
388    match write_spill_file(result.text_out().as_bytes()).await {
389        Ok((path, written)) => {
390            let truncated =
391                build_truncated_output(&result.text_out(), config, &path, total, ring_already_overflowed);
392            result.set_out(truncated);
393            result.did_spill = true;
394            Some(SpillResult {
395                path,
396                total_bytes: written,
397            })
398        }
399        Err(e) => {
400            tracing::error!("output spill failed: {}", e);
401            *result = ExecResult::failure(1, format!(
402                "output exceeded {} byte limit ({} bytes) and spill to disk failed: {}",
403                max, total, e
404            ));
405            None
406        }
407    }
408}
409
410/// Stream OutputData directly to a spill file without materializing the full String.
411///
412/// `ring_already_overflowed` — see `spill_string`'s doc comment; same caveat
413/// applies to the "full output at" message built below.
414#[cfg(feature = "localfs")]
415async fn spill_output_data(
416    result: &mut ExecResult,
417    config: &OutputLimitConfig,
418    max: usize,
419    ring_already_overflowed: bool,
420) -> Option<SpillResult> {
421    let output = result.output()?;
422
423    let dir = paths::spill_dir();
424    if let Err(e) = tokio::fs::create_dir_all(&dir).await {
425        tracing::error!("output spill dir creation failed: {}", e);
426        *result = ExecResult::failure(1, format!(
427            "output exceeded {} byte limit and spill dir creation failed: {}", max, e
428        ));
429        return None;
430    }
431
432    let filename = generate_spill_filename();
433    let path = dir.join(&filename);
434
435    // Write OutputData directly to file via write_canonical
436    let total = match std::fs::File::create(&path) {
437        Ok(mut file) => {
438            match output.write_canonical(&mut file, None) {
439                Ok(n) => n,
440                Err(e) => {
441                    tracing::error!("output spill write failed: {}", e);
442                    *result = ExecResult::failure(1, format!(
443                        "output exceeded {} byte limit and spill to disk failed: {}", max, e
444                    ));
445                    return None;
446                }
447            }
448        }
449        Err(e) => {
450            tracing::error!("output spill file creation failed: {}", e);
451            *result = ExecResult::failure(1, format!(
452                "output exceeded {} byte limit and spill to disk failed: {}", max, e
453            ));
454            return None;
455        }
456    };
457
458    // Read head and tail from the spill file for the truncated preview
459    let head = read_head_from_file(&path, config.head_bytes).await.unwrap_or_default();
460    let tail = read_tail_from_file(&path, config.tail_bytes).await.unwrap_or_default();
461    let path_str = path.to_string_lossy();
462
463    result.set_out(format!(
464        "{}\n...\n{}\n[output truncated: {}]",
465        head, tail, spill_summary(total, ring_already_overflowed, &path_str)
466    ));
467    result.did_spill = true;
468
469    Some(SpillResult {
470        path,
471        total_bytes: total,
472    })
473}
474
475/// Write output bytes to a new spill file. Returns (path, bytes_written).
476#[cfg(feature = "localfs")]
477async fn write_spill_file(data: &[u8]) -> Result<(PathBuf, usize), std::io::Error> {
478    let dir = paths::spill_dir();
479    tokio::fs::create_dir_all(&dir).await?;
480
481    let filename = generate_spill_filename();
482    let path = dir.join(filename);
483    tokio::fs::write(&path, data).await?;
484    Ok((path, data.len()))
485}
486
487/// The whole body of the `[output truncated: ...]` message: the byte count,
488/// the word that qualifies it, and the pointer at the spill file.
489///
490/// The count and its qualifier are built together on purpose. `total` is the
491/// number of bytes this spill wrote, which is the command's whole output only
492/// when no capture ring evicted anything first (GH #212 part 2 — see
493/// `spill_if_needed`'s `ring_already_overflowed`). After a ring overflow the
494/// same number is just what survived, so the message says "captured" and sends
495/// the reader to the stderr overflow marker, which reports the true size.
496/// Splitting the number from its qualifier is how the two branches drifted into
497/// contradicting each other the first time.
498#[cfg(feature = "localfs")]
499fn spill_summary(total: usize, ring_already_overflowed: bool, path: &str) -> String {
500    if ring_already_overflowed {
501        format!(
502            "{total} bytes captured — tail only at {path}; earlier output was dropped \
503             before the spill, so see the stderr overflow marker for the true size"
504        )
505    } else {
506        format!("{total} bytes total — full output at {path}")
507    }
508}
509
510/// Build the truncated output string with head, tail, and pointer.
511#[cfg(feature = "localfs")]
512fn build_truncated_output(
513    full: &str,
514    config: &OutputLimitConfig,
515    spill_path: &std::path::Path,
516    total_bytes: usize,
517    ring_already_overflowed: bool,
518) -> String {
519    let head = truncate_to_char_boundary(full, config.head_bytes);
520    let tail = tail_from_str(full, config.tail_bytes);
521    let path_str = spill_path.to_string_lossy();
522    format!(
523        "{}\n...\n{}\n[output truncated: {}]",
524        head, tail, spill_summary(total_bytes, ring_already_overflowed, &path_str)
525    )
526}
527
528/// Truncate a string to at most `max_bytes`, respecting UTF-8 char boundaries.
529fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str {
530    if s.len() <= max_bytes {
531        return s;
532    }
533    // Find the last char boundary at or before max_bytes
534    let mut end = max_bytes;
535    while end > 0 && !s.is_char_boundary(end) {
536        end -= 1;
537    }
538    &s[..end]
539}
540
541/// Get the last `max_bytes` of a string, respecting UTF-8 char boundaries.
542fn tail_from_str(s: &str, max_bytes: usize) -> &str {
543    if s.len() <= max_bytes {
544        return s;
545    }
546    let start = s.len() - max_bytes;
547    let mut adjusted = start;
548    while adjusted < s.len() && !s.is_char_boundary(adjusted) {
549        adjusted += 1;
550    }
551    &s[adjusted..]
552}
553
554/// Read the first N bytes from a file for head preview.
555#[cfg(feature = "localfs")]
556async fn read_head_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
557    use tokio::io::AsyncReadExt;
558
559    let mut file = tokio::fs::File::open(path).await?;
560    let mut buf = vec![0u8; max_bytes];
561    let n = file.read(&mut buf).await?;
562    buf.truncate(n);
563
564    let s = String::from_utf8_lossy(&buf);
565    // Truncate to char boundary
566    let result = truncate_to_char_boundary(&s, max_bytes);
567    Ok(result.to_string())
568}
569
570/// Read the last N bytes from a file for tail preview.
571#[cfg(feature = "localfs")]
572async fn read_tail_from_file(path: &std::path::Path, max_bytes: usize) -> Result<String, std::io::Error> {
573    use tokio::io::{AsyncReadExt, AsyncSeekExt};
574
575    let mut file = tokio::fs::File::open(path).await?;
576    let metadata = file.metadata().await?;
577    let len = metadata.len() as usize;
578
579    if len <= max_bytes {
580        let mut buf = Vec::new();
581        file.read_to_end(&mut buf).await?;
582        return Ok(String::from_utf8_lossy(&buf).into_owned());
583    }
584
585    let offset = len - max_bytes;
586    file.seek(std::io::SeekFrom::Start(offset as u64)).await?;
587    let mut buf = vec![0u8; max_bytes];
588    let n = file.read(&mut buf).await?;
589    buf.truncate(n);
590
591    // Adjust to char boundary
592    let s = String::from_utf8_lossy(&buf);
593    Ok(s.into_owned())
594}
595
596/// Generate a unique spill filename using timestamp, PID, and monotonic counter.
597#[cfg(feature = "localfs")]
598fn generate_spill_filename() -> String {
599    use std::sync::atomic::{AtomicUsize, Ordering};
600    use std::time::SystemTime;
601
602    static COUNTER: AtomicUsize = AtomicUsize::new(0);
603    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
604    let ts = SystemTime::now()
605        .duration_since(SystemTime::UNIX_EPOCH)
606        .unwrap_or_default();
607    let pid = std::process::id();
608    format!("spill-{}.{}-{}-{}.txt", ts.as_secs(), ts.subsec_nanos(), pid, seq)
609}
610
611/// Parse a size string with optional K/M suffix into bytes.
612///
613/// Accepts: "64K", "64k", "1M", "1m", "65536" (raw bytes).
614pub fn parse_size(s: &str) -> Result<usize, String> {
615    let s = s.trim();
616    if s.is_empty() {
617        return Err("empty size string".to_string());
618    }
619
620    let (num_str, multiplier) = if let Some(n) = s.strip_suffix('K').or_else(|| s.strip_suffix('k')) {
621        (n, 1024)
622    } else if let Some(n) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) {
623        (n, 1024 * 1024)
624    } else {
625        (s, 1)
626    };
627
628    let num: usize = num_str
629        .parse()
630        .map_err(|_| format!("invalid size: {}", s))?;
631
632    Ok(num * multiplier)
633}
634
635#[cfg(all(test, feature = "localfs"))]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_none_is_disabled() {
641        let config = OutputLimitConfig::none();
642        assert!(!config.is_enabled());
643        assert_eq!(config.max_bytes(), None);
644    }
645
646    #[test]
647    fn test_agent_preset_is_enabled() {
648        let config = OutputLimitConfig::agent();
649        assert!(config.is_enabled());
650        assert_eq!(config.max_bytes(), Some(8 * 1024));
651        assert_eq!(config.head_bytes(), 1024);
652        assert_eq!(config.tail_bytes(), 512);
653    }
654
655    #[test]
656    fn test_set_limit() {
657        let mut config = OutputLimitConfig::none();
658        assert!(!config.is_enabled());
659
660        config.set_limit(Some(1024));
661        assert!(config.is_enabled());
662        assert_eq!(config.max_bytes(), Some(1024));
663
664        config.set_limit(None);
665        assert!(!config.is_enabled());
666    }
667
668    #[test]
669    fn test_set_head_tail() {
670        let mut config = OutputLimitConfig::agent();
671        config.set_head_bytes(2048);
672        config.set_tail_bytes(1024);
673        assert_eq!(config.head_bytes(), 2048);
674        assert_eq!(config.tail_bytes(), 1024);
675    }
676
677    #[test]
678    fn test_parse_size() {
679        assert_eq!(parse_size("64K").unwrap(), 64 * 1024);
680        assert_eq!(parse_size("64k").unwrap(), 64 * 1024);
681        assert_eq!(parse_size("1M").unwrap(), 1024 * 1024);
682        assert_eq!(parse_size("1m").unwrap(), 1024 * 1024);
683        assert_eq!(parse_size("65536").unwrap(), 65536);
684        assert!(parse_size("").is_err());
685        assert!(parse_size("abc").is_err());
686    }
687
688    #[test]
689    fn test_truncate_to_char_boundary() {
690        assert_eq!(truncate_to_char_boundary("hello", 10), "hello");
691        assert_eq!(truncate_to_char_boundary("hello", 3), "hel");
692        // Multi-byte: "日" is 3 bytes
693        assert_eq!(truncate_to_char_boundary("日本語", 3), "日");
694        assert_eq!(truncate_to_char_boundary("日本語", 4), "日");
695        assert_eq!(truncate_to_char_boundary("日本語", 6), "日本");
696    }
697
698    #[test]
699    fn test_tail_from_str() {
700        assert_eq!(tail_from_str("hello", 10), "hello");
701        assert_eq!(tail_from_str("hello", 3), "llo");
702        // Multi-byte
703        assert_eq!(tail_from_str("日本語", 3), "語");
704        assert_eq!(tail_from_str("日本語", 6), "本語");
705    }
706
707    #[test]
708    fn test_generate_spill_filename() {
709        let name = generate_spill_filename();
710        assert!(name.starts_with("spill-"));
711        assert!(name.ends_with(".txt"));
712    }
713
714    #[tokio::test]
715    async fn test_spill_if_needed_under_limit() {
716        let config = OutputLimitConfig::agent();
717        let mut result = ExecResult::success("short output");
718        let spill = spill_if_needed(&mut result, &config).await;
719        assert!(spill.is_none());
720        assert_eq!(&*result.text_out(), "short output");
721        assert!(!result.did_spill);
722    }
723
724    #[tokio::test]
725    async fn test_spill_if_needed_over_limit() {
726        let config = OutputLimitConfig {
727            max_bytes: Some(100),
728            head_bytes: 20,
729            tail_bytes: 10,
730            spill_mode: SpillMode::Disk,
731        };
732        let big_output = "x".repeat(200);
733        let mut result = ExecResult::success(big_output);
734        let spill = spill_if_needed(&mut result, &config).await;
735        assert!(spill.is_some());
736        assert!(result.did_spill);
737
738        let spill = spill.unwrap();
739        assert_eq!(spill.total_bytes, 200);
740        assert!(spill.path.exists());
741
742        // Verify truncated output
743        assert!(result.text_out().contains("..."));
744        assert!(result.text_out().contains("[output truncated: 200 bytes total"));
745        assert!(result.text_out().contains(&spill.path.to_string_lossy().to_string()));
746
747        // Verify head (first 20 bytes)
748        assert!(result.text_out().starts_with(&"x".repeat(20)));
749
750        // Verify spill file has full content
751        let spill_content = tokio::fs::read_to_string(&spill.path).await.unwrap();
752        assert_eq!(spill_content.len(), 200);
753
754        // Clean up
755        let _ = tokio::fs::remove_file(&spill.path).await;
756    }
757
758    #[tokio::test]
759    async fn test_spill_if_needed_disabled() {
760        let config = OutputLimitConfig::none();
761        let big_output = "x".repeat(200);
762        let mut result = ExecResult::success(big_output.clone());
763        let spill = spill_if_needed(&mut result, &config).await;
764        assert!(spill.is_none());
765        assert_eq!(&*result.text_out(), big_output);
766        assert!(!result.did_spill);
767    }
768
769    // GH #212 part 2: when a lower-level capture ring already overflowed
770    // (did_spill already true, e.g. kernel.rs's try_execute_external stdout
771    // ring, GH #191) BEFORE this enabled-limit spill runs, the file it writes
772    // is itself only the ring-capped tail — the message must say so instead
773    // of claiming "full output at <path>".
774    #[tokio::test]
775    async fn test_spill_if_needed_tunes_message_when_ring_already_overflowed() {
776        let config = OutputLimitConfig {
777            max_bytes: Some(100),
778            head_bytes: 20,
779            tail_bytes: 10,
780            spill_mode: SpillMode::Disk,
781        };
782        let big_output = "x".repeat(200);
783        let mut result = ExecResult::success(big_output).with_code(0);
784        // Simulate the ring having already overflowed before this check runs.
785        result.did_spill = true;
786
787        let spill = spill_if_needed(&mut result, &config).await;
788        let spill = spill.expect("still over the limit, must spill");
789        assert!(
790            !result.text_out().contains("full output at"),
791            "must not claim the spill file is the full output when the ring \
792             already truncated it: {}",
793            result.text_out()
794        );
795        // The byte count must be qualified as "captured", never "total": in
796        // this branch it is only what survived the ring, and the message goes
797        // on to say the true size is elsewhere.
798        assert!(
799            !result.text_out().contains("bytes total"),
800            "the count is not the total after a ring overflow: {}",
801            result.text_out()
802        );
803        assert!(
804            result.text_out().contains("bytes captured — tail only at"),
805            "should name the tail-only nature of the spill file: {}",
806            result.text_out()
807        );
808        let _ = tokio::fs::remove_file(&spill.path).await;
809    }
810
811    // GH #212: `apply_spill_contract` is the one seam every execution surface
812    // (foreground pipeline, background job, scatter worker) must funnel a raw
813    // `ExecResult` through — these two tests pin its two responsibilities
814    // directly, independent of any particular surface's plumbing.
815
816    #[tokio::test]
817    async fn apply_spill_contract_remaps_exit_code_when_spill_if_needed_flips_did_spill() {
818        // Memory mode: no disk I/O, nothing to clean up (CLAUDE.md: no real
819        // system paths in tests) — the exit-code remap is identical either way.
820        let config = OutputLimitConfig {
821            max_bytes: Some(100),
822            head_bytes: 20,
823            tail_bytes: 10,
824            spill_mode: SpillMode::Memory,
825        };
826        let mut result = ExecResult::success("x".repeat(200));
827        apply_spill_contract(&mut result, &config).await;
828
829        assert!(result.did_spill);
830        assert_eq!(result.code, 3, "a spill must remap to exit 3");
831        assert_eq!(result.original_code, Some(0), "original exit code preserved");
832    }
833
834    #[tokio::test]
835    async fn apply_spill_contract_remaps_exit_code_when_did_spill_already_set_and_limit_disabled() {
836        // Mirrors the disabled-limit ring-overflow path (GH #191): a lower
837        // level already flipped `did_spill` with the ORIGINAL exit code still
838        // in place, and the output limit itself is disabled (so
839        // `spill_if_needed` never runs). The remap must still happen.
840        let config = OutputLimitConfig::none();
841        let mut result = ExecResult::success("small").with_code(0);
842        result.did_spill = true;
843
844        apply_spill_contract(&mut result, &config).await;
845
846        assert_eq!(result.code, 3, "did_spill set anywhere must remap to exit 3");
847        assert_eq!(result.original_code, Some(0));
848    }
849
850    #[tokio::test]
851    async fn apply_spill_contract_is_idempotent_and_keeps_the_first_original_code() {
852        // A result can reach this seam already remapped — backend conversions
853        // preserve `did_spill`/`original_code` (kaish-types/src/backend.rs), so
854        // an embedder's pre-remapped result is supported input. Applying the
855        // contract twice used to overwrite `Some(7)` with `Some(3)`, destroying
856        // the real exit code the caller needed to explain the failure.
857        let config = OutputLimitConfig::none();
858        let mut result = ExecResult::success("spilled").with_code(7);
859        result.did_spill = true;
860
861        apply_spill_contract(&mut result, &config).await;
862        assert_eq!(result.code, 3);
863        assert_eq!(result.original_code, Some(7), "first pass captures the real code");
864
865        // Second pass: still 3, and the ORIGINAL 7 survives.
866        apply_spill_contract(&mut result, &config).await;
867        assert_eq!(result.code, 3, "still remapped");
868        assert_eq!(
869            result.original_code,
870            Some(7),
871            "a second pass must not overwrite the captured original with 3"
872        );
873    }
874
875    #[test]
876    fn test_build_truncated_output() {
877        let config = OutputLimitConfig {
878            max_bytes: Some(100),
879            head_bytes: 5,
880            tail_bytes: 3,
881            spill_mode: SpillMode::Disk,
882        };
883        let full = "abcdefghijklmnop";
884        let path = PathBuf::from("/tmp/test-spill.txt");
885        let result = build_truncated_output(full, &config, &path, 16, false);
886        assert!(result.starts_with("abcde"));
887        assert!(result.contains("..."));
888        assert!(result.contains("nop"));
889        assert!(result.contains("[output truncated: 16 bytes total — full output at /tmp/test-spill.txt]"));
890    }
891
892    // GH #212 part 2: a spill that runs AFTER a lower-level capture-ring
893    // overflow already flipped `did_spill` must not claim its spill file
894    // holds the "full output" — it only holds the ring-capped tail.
895    #[test]
896    fn test_build_truncated_output_tunes_message_when_ring_already_overflowed() {
897        let config = OutputLimitConfig {
898            max_bytes: Some(100),
899            head_bytes: 5,
900            tail_bytes: 3,
901            spill_mode: SpillMode::Disk,
902        };
903        let full = "abcdefghijklmnop";
904        let path = PathBuf::from("/tmp/test-spill.txt");
905        let result = build_truncated_output(full, &config, &path, 16, true);
906        assert!(
907            !result.contains("full output at"),
908            "must not claim the spill file is the full output when the ring already \
909             truncated it: {result}"
910        );
911        assert!(
912            result.contains(
913                "[output truncated: 16 bytes captured — tail only at /tmp/test-spill.txt; \
914                 earlier output was dropped before the spill, so see the stderr overflow \
915                 marker for the true size]"
916            ),
917            "the count must read as captured-not-total and point at the true size: {result}"
918        );
919    }
920
921    #[tokio::test]
922    async fn test_kernel_agent_truncates_large_output() {
923        use crate::kernel::{Kernel, KernelConfig};
924
925        // agent preset has 8K limit by default — use a smaller limit for testing
926        let config = KernelConfig::agent()
927            .with_output_limit(OutputLimitConfig {
928                max_bytes: Some(200),
929                head_bytes: 50,
930                tail_bytes: 30,
931                spill_mode: SpillMode::Disk,
932            });
933        let kernel = Kernel::new(config).expect("kernel creation");
934
935        // seq 1 10000 produces lots of output
936        let result = kernel.execute("seq 1 10000").await.expect("execute");
937        assert!(result.text_out().contains("[output truncated:"));
938        assert!(result.text_out().contains("full output at"));
939        // Head should contain the first numbers
940        assert!(result.text_out().starts_with("1\n"));
941    }
942
943    /// GH #177: every other Disk-mode test in this module re-specifies
944    /// `spill_mode: SpillMode::Disk` explicitly, so none of them actually pin
945    /// that a host-backed (`Sandboxed`) kernel's *untouched* default — no
946    /// `.with_output_limit()` override at all — is `Disk`. This is the literal
947    /// "`Kernel::new` without `.in_memory()`" scenario the issue names. The
948    /// forcing logic in `Kernel::assemble` (`no_host_side_channel`) must leave
949    /// a `Sandboxed` kernel's config alone; only `NoLocal`/`with_backend`
950    /// override it to `Memory`.
951    #[tokio::test]
952    async fn test_agent_kernel_unmodified_default_spills_to_disk() {
953        use crate::kernel::{Kernel, KernelConfig};
954
955        // Untouched: OutputLimitConfig::agent() — 8K limit, SpillMode::Disk.
956        let config = KernelConfig::agent();
957        assert_eq!(config.output_limit.spill_mode(), SpillMode::Disk);
958        let kernel = Kernel::new(config).expect("kernel creation");
959
960        let big = "x".repeat(8 * 1024 + 200);
961        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
962        assert_eq!(result.code, 3, "default 8K agent limit should trip the spill");
963        assert!(
964            result.text_out().contains("full output at"),
965            "an unmodified agent() default must spill to a real file, not truncate in \
966             memory: {}",
967            result.text_out()
968        );
969    }
970
971    #[tokio::test]
972    async fn test_spill_exits_3() {
973        use crate::kernel::{Kernel, KernelConfig};
974
975        let config = KernelConfig::agent()
976            .with_output_limit(OutputLimitConfig {
977                max_bytes: Some(100),
978                head_bytes: 30,
979                tail_bytes: 20,
980                spill_mode: SpillMode::Disk,
981            });
982        let kernel = Kernel::new(config).expect("kernel creation");
983
984        let big = "x".repeat(200);
985        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
986        assert_eq!(result.code, 3, "spill should always exit 3");
987        assert_eq!(result.original_code, Some(0), "original command exit code preserved");
988        assert!(result.text_out().contains("[output truncated:"));
989    }
990
991    #[tokio::test]
992    async fn test_kernel_repl_no_truncation() {
993        use crate::kernel::{Kernel, KernelConfig};
994
995        // REPL has no limit
996        let config = KernelConfig::repl();
997        let kernel = Kernel::new(config).expect("kernel creation");
998
999        let result = kernel.execute("seq 1 100").await.expect("execute");
1000        assert!(!result.text_out().contains("[output truncated:"));
1001        assert!(result.text_out().contains("100"));
1002    }
1003
1004    #[tokio::test]
1005    async fn test_kernel_builtin_truncation() {
1006        use crate::kernel::{Kernel, KernelConfig};
1007
1008        // Builtins go through post-hoc spill check
1009        let config = KernelConfig::agent()
1010            .with_output_limit(OutputLimitConfig {
1011                max_bytes: Some(100),
1012                head_bytes: 30,
1013                tail_bytes: 20,
1014                spill_mode: SpillMode::Disk,
1015            });
1016        let kernel = Kernel::new(config).expect("kernel creation");
1017
1018        // echo with a large string
1019        let big = "x".repeat(200);
1020        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1021        assert!(result.text_out().contains("[output truncated:"));
1022    }
1023
1024    // ── OutputData estimation and streaming tests ──
1025
1026    #[test]
1027    fn test_estimated_byte_size_text() {
1028        use crate::interpreter::OutputData;
1029        let data = OutputData::text("hello world");
1030        assert_eq!(data.estimated_byte_size(), 11);
1031    }
1032
1033    #[test]
1034    fn test_estimated_byte_size_table() {
1035        use crate::interpreter::{OutputData, OutputNode};
1036        let data = OutputData::table(
1037            vec!["NAME".into(), "SIZE".into()],
1038            vec![
1039                OutputNode::new("foo").with_cells(vec!["123".into()]),
1040                OutputNode::new("bar").with_cells(vec!["456".into()]),
1041            ],
1042        );
1043        // "foo\t123\nbar\t456" = 3+1+3 + 1 + 3+1+3 = 15
1044        assert_eq!(data.estimated_byte_size(), 15);
1045    }
1046
1047    #[test]
1048    fn test_estimated_byte_size_tree() {
1049        use crate::interpreter::{OutputData, OutputNode};
1050        let data = OutputData::nodes(vec![
1051            OutputNode::new("src").with_children(vec![
1052                OutputNode::new("main.rs"),
1053                OutputNode::new("lib.rs"),
1054            ]),
1055        ]);
1056        // "src/{main.rs,lib.rs}" = 3 + 2 + 7 + 1 + 6 + 1 = 20
1057        assert_eq!(data.estimated_byte_size(), 20);
1058    }
1059
1060    #[test]
1061    fn test_write_canonical_matches_to_canonical_string() {
1062        use crate::interpreter::{OutputData, OutputNode};
1063
1064        let cases: Vec<OutputData> = vec![
1065            OutputData::text("hello world"),
1066            OutputData::nodes(vec![
1067                OutputNode::new("file1"),
1068                OutputNode::new("file2"),
1069            ]),
1070            OutputData::table(
1071                vec!["NAME".into(), "SIZE".into()],
1072                vec![
1073                    OutputNode::new("foo").with_cells(vec!["123".into()]),
1074                    OutputNode::new("bar").with_cells(vec!["456".into()]),
1075                ],
1076            ),
1077            OutputData::nodes(vec![
1078                OutputNode::new("src").with_children(vec![
1079                    OutputNode::new("main.rs"),
1080                    OutputNode::new("lib.rs"),
1081                ]),
1082            ]),
1083        ];
1084
1085        for data in cases {
1086            let expected = data.to_canonical_string();
1087            let mut buf = Vec::new();
1088            let written = data.write_canonical(&mut buf, None).unwrap();
1089            let got = String::from_utf8(buf).unwrap();
1090            assert_eq!(got, expected, "write_canonical mismatch for {:?}", data);
1091            assert_eq!(written, expected.len(), "byte count mismatch");
1092        }
1093    }
1094
1095    #[test]
1096    fn test_write_canonical_budget_stops_early() {
1097        use crate::interpreter::{OutputData, OutputNode};
1098
1099        let data = OutputData::nodes(
1100            (0..1000).map(|i| OutputNode::new(format!("file_{:04}", i))).collect()
1101        );
1102        let mut buf = Vec::new();
1103        let written = data.write_canonical(&mut buf, Some(100)).unwrap();
1104        // Should have stopped shortly after 100 bytes
1105        assert!(written > 100, "should exceed budget slightly");
1106        assert!(written < 500, "should stop soon after budget: got {}", written);
1107    }
1108
1109    #[tokio::test]
1110    async fn test_spill_if_needed_large_output_data_no_oom() {
1111        use crate::interpreter::{OutputData, OutputNode};
1112
1113        let config = OutputLimitConfig {
1114            max_bytes: Some(1024),
1115            head_bytes: 100,
1116            tail_bytes: 50,
1117            spill_mode: SpillMode::Disk,
1118        };
1119
1120        // 100K nodes — large enough to detect OOM if materialized carelessly,
1121        // but small enough to not slow down the test
1122        let nodes: Vec<OutputNode> = (0..100_000)
1123            .map(|i| OutputNode::new(format!("node_{:06}", i)))
1124            .collect();
1125        let data = OutputData::nodes(nodes);
1126        let mut result = ExecResult::with_output(data);
1127
1128        let spill = spill_if_needed(&mut result, &config).await;
1129        assert!(spill.is_some(), "should have spilled");
1130        assert!(result.did_spill);
1131        assert!(result.text_out().contains("[output truncated:"));
1132
1133        // Clean up
1134        if let Some(s) = spill {
1135            let _ = tokio::fs::remove_file(&s.path).await;
1136        }
1137    }
1138
1139    // ── In-memory spill mode (SpillMode::Memory) ──
1140
1141    #[test]
1142    fn test_in_memory_builder_and_default() {
1143        assert_eq!(OutputLimitConfig::agent().spill_mode(), SpillMode::Disk);
1144        assert_eq!(OutputLimitConfig::agent().in_memory().spill_mode(), SpillMode::Memory);
1145
1146        let mut config = OutputLimitConfig::none();
1147        config.set_spill_mode(SpillMode::Memory);
1148        assert_eq!(config.spill_mode(), SpillMode::Memory);
1149    }
1150
1151    #[tokio::test]
1152    async fn test_memory_mode_truncates_string_without_disk() {
1153        let config = OutputLimitConfig {
1154            max_bytes: Some(100),
1155            head_bytes: 20,
1156            tail_bytes: 10,
1157            spill_mode: SpillMode::Memory,
1158        };
1159        let mut result = ExecResult::success("x".repeat(200));
1160        let spill = spill_if_needed(&mut result, &config).await;
1161
1162        // No SpillResult (no file written) but did_spill flags the truncation.
1163        assert!(spill.is_none(), "memory mode must not write a spill file");
1164        assert!(result.did_spill, "memory truncation must set did_spill for the exit-3 remap");
1165
1166        let out = result.text_out();
1167        assert!(out.contains("truncated in memory"), "got: {}", out);
1168        assert!(out.contains("200 bytes total"), "got: {}", out);
1169        assert!(!out.contains("full output at"), "memory mode must not point at a file: {}", out);
1170        assert!(out.starts_with(&"x".repeat(20)), "head preserved");
1171    }
1172
1173    #[tokio::test]
1174    async fn test_memory_mode_under_limit_untouched() {
1175        let config = OutputLimitConfig {
1176            max_bytes: Some(100),
1177            head_bytes: 20,
1178            tail_bytes: 10,
1179            spill_mode: SpillMode::Memory,
1180        };
1181        let mut result = ExecResult::success("short");
1182        let spill = spill_if_needed(&mut result, &config).await;
1183        assert!(spill.is_none());
1184        assert!(!result.did_spill);
1185        assert_eq!(&*result.text_out(), "short");
1186    }
1187
1188    #[tokio::test]
1189    async fn test_memory_mode_large_output_data_bounded() {
1190        use crate::interpreter::{OutputData, OutputNode};
1191
1192        let config = OutputLimitConfig {
1193            max_bytes: Some(1024),
1194            head_bytes: 100,
1195            tail_bytes: 50,
1196            spill_mode: SpillMode::Memory,
1197        };
1198
1199        // 100K nodes — would be a huge String if fully materialized.
1200        let nodes: Vec<OutputNode> = (0..100_000)
1201            .map(|i| OutputNode::new(format!("node_{:06}", i)))
1202            .collect();
1203        let mut result = ExecResult::with_output(OutputData::nodes(nodes));
1204
1205        let spill = spill_if_needed(&mut result, &config).await;
1206        assert!(spill.is_none(), "memory mode writes no file");
1207        assert!(result.did_spill);
1208        let out = result.text_out();
1209        assert!(out.contains("truncated in memory"), "got: {}", out);
1210        assert!(out.starts_with("node_000000"), "head rendered: {}", out);
1211        // Head-only path for oversized structured data: no tail section echoed.
1212        assert!(out.contains("head only"), "got: {}", out);
1213    }
1214
1215    #[tokio::test]
1216    async fn test_kernel_memory_mode_exits_3_preserves_original() {
1217        use crate::kernel::{Kernel, KernelConfig};
1218
1219        let config = KernelConfig::agent().with_output_limit(OutputLimitConfig {
1220            max_bytes: Some(100),
1221            head_bytes: 30,
1222            tail_bytes: 20,
1223            spill_mode: SpillMode::Memory,
1224        });
1225        let kernel = Kernel::new(config).expect("kernel creation");
1226
1227        let big = "x".repeat(200);
1228        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1229        assert_eq!(result.code, 3, "memory truncation still signals via exit 3");
1230        assert_eq!(result.original_code, Some(0), "original exit code preserved");
1231        assert!(result.text_out().contains("truncated in memory"));
1232        assert!(!result.text_out().contains("full output at"));
1233    }
1234
1235    #[tokio::test]
1236    async fn test_nolocal_kernel_forces_memory_spill() {
1237        use crate::kernel::{Kernel, KernelConfig, VfsMountMode};
1238
1239        // NoLocal mount + an explicit Disk spill mode: the kernel must override
1240        // to Memory so nothing is written to a host spill file, even though
1241        // `localfs` is compiled in.
1242        let config = KernelConfig::agent()
1243            .with_vfs_mode(VfsMountMode::NoLocal)
1244            .with_output_limit(OutputLimitConfig {
1245                max_bytes: Some(100),
1246                head_bytes: 30,
1247                tail_bytes: 20,
1248                spill_mode: SpillMode::Disk,
1249            });
1250        let kernel = Kernel::new(config).expect("kernel creation");
1251
1252        let big = "x".repeat(200);
1253        let result = kernel.execute(&format!("echo '{}'", big)).await.expect("execute");
1254        assert_eq!(result.code, 3, "still signals truncation via exit 3");
1255        assert!(result.text_out().contains("truncated in memory"), "got: {}", result.text_out());
1256        assert!(
1257            !result.text_out().contains("full output at"),
1258            "NoLocal kernel must not write a host spill file: {}",
1259            result.text_out()
1260        );
1261    }
1262
1263}