diff --git a/crates/bashkit-js/__test__/terminal.spec.ts b/crates/bashkit-js/__test__/terminal.spec.ts index 109ce8f0e..d36822671 100644 --- a/crates/bashkit-js/__test__/terminal.spec.ts +++ b/crates/bashkit-js/__test__/terminal.spec.ts @@ -47,8 +47,8 @@ test("terminal: timeout reports running command and Ctrl-C interrupts", async (t const activity = term.activity(); t.is(activity.state, "running"); t.is(activity.command, "sleep 0.5; echo done"); - // Sync send lands while the command still runs; Ctrl-C cancels at the - // next command boundary, so `echo done` never runs. + // Sync send lands while the command still runs; Ctrl-C drops it at once, + // so `echo done` never runs. term.send("\x03"); t.is(await term.runUntilIdle(5000), "idle"); const [record] = term.takeTranscript(); @@ -56,6 +56,26 @@ test("terminal: timeout reports running command and Ctrl-C interrupts", async (t t.false(record.output.includes("done")); }); +test("terminal: wait_for and screen changes", async (t) => { + const term = new Terminal(); + const out = await term.call({ + input: "for i in 1 2 3; do echo tick$i; sleep 1; done", + wait_ms: 10000, + wait_for: "tick2", + screen: "changes", + }); + t.is(out.matched, true); + t.is(out.activity, "running"); + t.is(out.screen, undefined); + t.true(out.screen_changes!.some((c) => c.text === "tick2")); + const after = await term.call({ input: "", screen: "changes" }); + t.is(after.activity, "prompt"); + t.deepEqual( + after.screen_changes!.slice(-2).map((c) => c.text), + ["^C", "$"], + ); +}); + test("terminal: exit and exitCode", async (t) => { const term = new Terminal(); const out = await term.call("exit 3"); diff --git a/crates/bashkit-js/src/terminal.rs b/crates/bashkit-js/src/terminal.rs index ccbbfa722..89cfa24b3 100644 --- a/crates/bashkit-js/src/terminal.rs +++ b/crates/bashkit-js/src/terminal.rs @@ -132,15 +132,15 @@ impl Terminal { env.spawn_future(run_until_idle(Arc::clone(&self.tool), timeout_ms)) } - /// Agent step as a JSON string; the JS wrapper parses it (see `call`). + /// Agent step: tool arguments as a JSON string in, result JSON out; the + /// JS wrapper builds and parses them (see `call`). #[napi(js_name = "__callJson", ts_return_type = "Promise")] pub fn call_json<'env>( &self, env: &'env Env, - input: Option, - wait_ms: Option, + args_json: String, ) -> napi::Result> { - env.spawn_future(call_json(Arc::clone(&self.tool), input, wait_ms)) + env.spawn_future(call_json(Arc::clone(&self.tool), args_json)) } /// Visible screen as plain text. @@ -278,15 +278,9 @@ async fn run_until_idle( } } -async fn call_json( - tool: Arc>, - input: Option, - wait_ms: Option, -) -> napi::Result { - let mut args = serde_json::json!({ "input": input.unwrap_or_default() }); - if let Some(ms) = wait_ms { - args["wait_ms"] = ms.into(); - } +async fn call_json(tool: Arc>, args_json: String) -> napi::Result { + let args: serde_json::Value = serde_json::from_str(&args_json) + .map_err(|e| napi::Error::from_reason(format!("invalid call arguments: {e}")))?; let mut guard = tool.lock().await; let out = guard .call(args) diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index ecec5fdb8..f038725dd 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -2001,9 +2001,27 @@ export interface TerminalToolDefinition { }; } +/** Arguments of {@link Terminal.call}, named as in `toolDefinition()`. */ +export interface TerminalCallArgs { + /** Keys in Vim notation (`"ls"`, `""`). */ + input?: string; + /** How long to wait for the session to need input (default 5000). */ + wait_ms?: number; + /** Regex: return as soon as command output printed during the call matches. */ + wait_for?: string; + /** `full` (default), `changes` (only changed rows) or `none`. */ + screen?: "full" | "changes" | "none"; +} + export interface TerminalCallResult { - /** Visible screen as plain text. */ - screen: string; + /** Visible screen as plain text (screen `full`). */ + screen?: string; + /** Rows changed since the previous call (screen `changes`). */ + screen_changes?: Array<{ row: number; text: string }>; + /** Cursor position, 0-based (screen `changes`). */ + cursor?: { row: number; col: number }; + /** Whether `wait_for` matched (only when it was given). */ + matched?: boolean; activity: "prompt" | "continuation" | "running" | "exited"; /** Command line still running (activity `running`). */ running_command?: string; @@ -2059,10 +2077,16 @@ export class Terminal { /** * Agent step: type `input` in Vim key notation (`"ls"`, * `"ihi:wq"`, `""`), wait up to `waitMs` (default 5000), - * and report the screen, activity and finished commands. - */ - async call(input = "", waitMs?: number): Promise { - return JSON.parse(await this.native.__callJson(input, waitMs)); + * and report the screen, activity and finished commands. Pass an object + * to forward a model's tool call as is (`wait_for`, `screen`, ...). + */ + async call( + input: string | TerminalCallArgs = "", + waitMs?: number, + ): Promise { + const args: TerminalCallArgs = + typeof input === "string" ? { input, wait_ms: waitMs } : input; + return JSON.parse(await this.native.__callJson(JSON.stringify(args))); } /** Visible screen as plain text. */ diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index ee8f8f144..deff0dfaa 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -1931,8 +1931,18 @@ class Terminal: def run_until_idle(self, timeout: float | None = None) -> Literal["idle", "exited", "timeout"]: """Run until the session needs input or exits, or ``timeout`` seconds pass.""" ... - def call(self, input: str = "", wait_ms: int | None = None) -> dict[str, Any]: - """Agent step: type ``input`` in Vim key notation, wait, report screen and commands.""" + def call( + self, + input: str = "", + wait_ms: int | None = None, + wait_for: str | None = None, + screen: Literal["full", "changes", "none"] | None = None, + ) -> dict[str, Any]: + """Agent step: type ``input`` in Vim key notation, wait, report screen and commands. + + ``wait_for`` (regex) returns as soon as command output matches. + ``screen="changes"`` returns only rows changed since the last call. + """ ... def screen_text(self) -> str: ... def history_text(self) -> str: ... diff --git a/crates/bashkit-python/src/terminal.rs b/crates/bashkit-python/src/terminal.rs index 4758c55c8..63fd44726 100644 --- a/crates/bashkit-python/src/terminal.rs +++ b/crates/bashkit-python/src/terminal.rs @@ -149,13 +149,29 @@ impl PyTerminal { /// Agent-style step: type `input` (Vim key notation such as /// `"ihello:wq"`), wait up to `wait_ms`, and return a dict - /// with `screen`, `activity`, `commands` and more. See `tool_definition()`. - #[pyo3(signature = (input="", wait_ms=None))] - fn call(&self, py: Python<'_>, input: &str, wait_ms: Option) -> PyResult> { + /// with `screen`, `activity`, `commands` and more. `wait_for` (regex) + /// returns early once command output matches; `screen` is `"full"`, + /// `"changes"` or `"none"`. Argument names match `tool_definition()`, so + /// `call(**tool_args)` forwards a model's call. + #[pyo3(signature = (input="", wait_ms=None, wait_for=None, screen=None))] + fn call( + &self, + py: Python<'_>, + input: &str, + wait_ms: Option, + wait_for: Option<&str>, + screen: Option<&str>, + ) -> PyResult> { let mut args = serde_json::json!({ "input": input }); if let Some(ms) = wait_ms { args["wait_ms"] = ms.into(); } + if let Some(p) = wait_for { + args["wait_for"] = p.into(); + } + if let Some(m) = screen { + args["screen"] = m.into(); + } let out = py.detach(|| self.with_tool(|tool| self.rt.block_on(tool.call(args)))); let value = out.map_err(|e| PyValueError::new_err(e.to_string()))?; json_to_py(py, &value) diff --git a/crates/bashkit-python/tests/test_terminal.py b/crates/bashkit-python/tests/test_terminal.py index 3625dee4c..be1660c3e 100644 --- a/crates/bashkit-python/tests/test_terminal.py +++ b/crates/bashkit-python/tests/test_terminal.py @@ -49,6 +49,23 @@ def test_timeout_reports_running_command(): assert t.take_transcript()[0]["output"] == "done\n" +def test_wait_for_and_screen_changes(): + t = Terminal() + out = t.call( + "for i in 1 2 3; do echo tick$i; sleep 1; done", + wait_ms=10000, + wait_for="tick2", + screen="changes", + ) + assert out["matched"] is True + assert out["activity"] == "running" + assert "screen" not in out + assert any(c["text"] == "tick2" for c in out["screen_changes"]) + out = t.call(**{"input": "", "screen": "changes"}) + assert out["activity"] == "prompt" + assert [c["text"] for c in out["screen_changes"]][-2:] == ["^C", "$"] + + def test_exit_and_exit_code(): t = Terminal() out = t.call("exit 3") diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f235fe360..11a0a7b52 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1828,6 +1828,12 @@ impl Interpreter { } } + /// Set `$?` from outside a command (terminal carries it across lines). + #[cfg(feature = "terminal")] + pub(crate) fn set_last_exit_code(&mut self, code: i32) { + self.last_exit_code = code; + } + /// Return a shared cancellation token. Set it to `true` from any thread /// to abort execution at the next command boundary. pub fn cancellation_token(&self) -> Arc { diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index b856d3994..eb1e08524 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -826,6 +826,27 @@ pub struct Bash { /// excluded from the execution timeout. #[cfg(feature = "terminal")] input_wait_clock: Option, + /// Set by [`terminal::Terminal`]: a notify fired on Ctrl-C. While set, + /// `exec` aborts the running command mid-way when it fires and the + /// cancellation token is set, instead of waiting for the next command + /// boundary. + #[cfg(feature = "terminal")] + interrupt: Option>, + /// `$?` for the next exec: an interactive shell keeps `$?` across lines. + #[cfg(feature = "terminal")] + carried_exit_code: Option, +} + +/// Resolve once `notify` fires while `cancel` is set. A notify permit left +/// over from an earlier command (the flag already reset) is ignored. +#[cfg(feature = "terminal")] +async fn wait_for_interrupt(notify: &tokio::sync::Notify, cancel: &std::sync::atomic::AtomicBool) { + loop { + notify.notified().await; + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + } } impl Default for Bash { @@ -954,6 +975,10 @@ impl Bash { async fn exec_impl(&mut self, script: &str, invocation: Invocation) -> Result { // THREAT[TM-ISO-005/006/007]: Reset transient state between exec() calls self.interpreter.reset_transient_state(); + #[cfg(feature = "terminal")] + if let Some(code) = self.carried_exit_code.take() { + self.interpreter.set_last_exit_code(code); + } // THREAT[TM-DOS-059]: Count every host exec() call at the boundary so // malformed or parser-expensive scripts cannot bypass session limits. @@ -1166,17 +1191,44 @@ impl Bash { // prevent sleep and pending async callbacks from bypassing the budget. let execution_timeout = self.interpreter.limits().timeout; #[cfg(feature = "terminal")] - let timed = match self.input_wait_clock.clone() { - Some(clock) => { - crate::time_compat::timeout_excluding_input_wait( - execution_timeout, - &clock, - self.interpreter.execute(&ast), - ) - .await - } - None => { - crate::time_compat::timeout(execution_timeout, self.interpreter.execute(&ast)).await + let timed = { + let clock = self.input_wait_clock.clone(); + let interrupt = self.interrupt.clone(); + let cancel = self.interpreter.cancellation_token(); + let run = async { + match clock { + Some(clock) => { + crate::time_compat::timeout_excluding_input_wait( + execution_timeout, + &clock, + self.interpreter.execute(&ast), + ) + .await + } + None => { + crate::time_compat::timeout( + execution_timeout, + self.interpreter.execute(&ast), + ) + .await + } + } + }; + // Terminal Ctrl-C: drop the running command mid-way, the same way + // the execution timeout does, then reconcile interpreter state. + let outcome = match interrupt { + Some(notify) => tokio::select! { + r = run => Some(r), + () = wait_for_interrupt(¬ify, &cancel) => None, + }, + None => Some(run.await), + }; + match outcome { + Some(r) => r, + None => { + self.interpreter.clear_cancelled_execution_state(); + Ok(Err(Error::Cancelled)) + } } }; #[cfg(not(feature = "terminal"))] @@ -1370,6 +1422,13 @@ impl Bash { .await } + /// Seed `$?` for the next exec (terminal: `$?` survives across lines, + /// and Ctrl-C reports 130). + #[cfg(feature = "terminal")] + pub(crate) fn carry_exit_code(&mut self, code: i32) { + self.carried_exit_code = Some(code); + } + /// Return a shared cancellation token. /// /// Set the token to `true` from any thread to abort execution at the next @@ -3846,6 +3905,10 @@ impl BashBuilder { host_mounts: HostMounts::default(), #[cfg(feature = "terminal")] input_wait_clock: None, + #[cfg(feature = "terminal")] + interrupt: None, + #[cfg(feature = "terminal")] + carried_exit_code: None, } } } diff --git a/crates/bashkit/src/terminal/mod.rs b/crates/bashkit/src/terminal/mod.rs index e8bc794b8..fabd08ae5 100644 --- a/crates/bashkit/src/terminal/mod.rs +++ b/crates/bashkit/src/terminal/mod.rs @@ -255,7 +255,9 @@ impl Terminal { .build(); let clock = InputWaitClock::default(); bash.input_wait_clock = Some(clock.clone()); - let tty = Tty::new(size, clock, bash.cancellation_token()); + let interrupt = Arc::new(tokio::sync::Notify::new()); + bash.interrupt = Some(Arc::clone(&interrupt)); + let tty = Tty::new(size, clock, bash.cancellation_token(), interrupt); let fs = bash.fs(); let log = SharedLog::default(); let session: Session = Box::pin(shell_loop(bash, tty.clone(), exit, log.clone())); @@ -315,6 +317,19 @@ impl Terminal { text } + /// The visible screen, one string per row (trailing blanks trimmed, + /// every row present). Row `i` is screen line `i`. + pub fn screen_rows(&self) -> Vec { + self.tty.with_screen(|s| { + let (_, cols) = s.size(); + s.rows(0, cols).map(|r| r.trim_end().to_string()).collect() + }) + } + + pub(crate) fn tty(&self) -> &Tty { + &self.tty + } + /// The screen plus up to 1000 lines of scrollback above it, as plain text: /// one line per row, oldest first, trailing blanks and trailing empty rows /// trimmed. While a full-screen program is open this covers its alternate @@ -443,6 +458,8 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc, log: .streaming(Box::new(move |stdout, stderr| { out_tty.write_cooked(stdout.as_bytes()); out_tty.write_cooked(stderr.as_bytes()); + out_tty.tap_output(stdout.as_bytes()); + out_tty.tap_output(stderr.as_bytes()); let mut cap = out_capture.lock().unwrap_or_else(PoisonError::into_inner); cap.append(stdout); cap.append(stderr); @@ -483,6 +500,7 @@ async fn shell_loop(mut bash: crate::Bash, tty: Tty, exit: Arc, log: } }; + bash.carry_exit_code(last_exit); let exited = exit.requested.load(Ordering::SeqCst); let cap = std::mem::take(&mut *capture.lock().unwrap_or_else(PoisonError::into_inner)); log.push(CommandRecord { @@ -1007,16 +1025,66 @@ mod tests { } #[tokio::test] - async fn ctrl_c_cancels_at_next_command_boundary() { + async fn ctrl_c_interrupts_a_running_command() { let mut term = Terminal::new(Bash::builder()); - term.send("sleep 0.3; echo after\r"); + term.send("sleep 30; echo after\r"); let pending = tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await; assert!(pending.is_err(), "sleep should still be running"); + let started = std::time::Instant::now(); // std-time-ok: native-only test assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "Ctrl-C must not wait for sleep to finish" + ); let text = term.screen_text(); assert!(!text.contains("\nafter"), "{text}"); assert!(text.ends_with("^C\n$"), "{text}"); + run(&mut term, "echo status=$?\r").await; + assert!( + term.screen_text().contains("\nstatus=130"), + "{}", + term.screen_text() + ); + // The session keeps working after the interrupt. + run(&mut term, "f() { echo in-f; }; f\r").await; + assert!( + term.screen_text().contains("\nin-f"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn exit_status_carries_across_lines() { + let mut term = Terminal::new(Bash::builder()); + run(&mut term, "false\r").await; + run(&mut term, "echo st=$?\r").await; + assert!( + term.screen_text().contains("\nst=1"), + "{}", + term.screen_text() + ); + run(&mut term, "echo st=$?\r").await; + assert!( + term.screen_text().contains("\nst=0"), + "{}", + term.screen_text() + ); + } + + #[tokio::test] + async fn ctrl_c_interrupts_a_busy_loop() { + let mut term = Terminal::new(Bash::builder()); + term.send("while true; do sleep 0.01; done\r"); + let _ = + tokio::time::timeout(std::time::Duration::from_millis(50), term.run_until_idle()).await; + assert_eq!(run(&mut term, "\x03").await, TerminalStatus::Idle); + assert!( + term.screen_text().ends_with("^C\n$"), + "{}", + term.screen_text() + ); } #[tokio::test] diff --git a/crates/bashkit/src/terminal/tool.rs b/crates/bashkit/src/terminal/tool.rs index b6865ae7b..54db63c0d 100644 --- a/crates/bashkit/src/terminal/tool.rs +++ b/crates/bashkit/src/terminal/tool.rs @@ -15,6 +15,13 @@ //! (default 5 s, max 60 s). A command still running is reported, not //! killed; the next call (even with no input) keeps waiting, and `` //! interrupts it. +//! - `wait_for` (regex) returns as soon as matching text appears in command +//! output printed during the call. It matches command stdout/stderr only, +//! never the echoed keystrokes or prompts, so `echo ready` with +//! `wait_for: "ready"` waits for the output line, not the typed command. +//! Polls in 50 ms slices; the session keeps running between slices. +//! - `screen: "changes"` returns only rows that differ from the previous +//! call (any mode updates the baseline), to keep long sessions cheap. use std::time::Duration; @@ -31,6 +38,11 @@ const MAX_WAIT_MS: u64 = 60_000; // THREAT[TM-DOS-119]: tool input feeds the bounded terminal queue; reject // oversized calls up front instead of silently truncating. const MAX_INPUT_BYTES: usize = 64 * 1024; +/// Longest `wait_for` pattern, and the compiled-regex size cap. +// THREAT[TM-DOS-119]: the pattern is model-supplied; bound compile cost. +const MAX_PATTERN_BYTES: usize = 1024; +const MAX_REGEX_SIZE: usize = 1 << 20; +const WAIT_SLICE: Duration = Duration::from_millis(50); const DESCRIPTION: &str = "Interactive bash terminal in a sandbox. Type keys, get the screen back. \ Use it for full-screen programs such as vi and less, or a shell session that persists between calls."; @@ -63,6 +75,16 @@ pub struct TerminalToolError(String); /// ``` pub struct TerminalTool { terminal: Terminal, + /// Screen rows at the previous report, for `screen: "changes"`. + last_rows: Option>, +} + +/// How much of the screen a call returns. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ScreenMode { + Full, + Changes, + None, } impl TerminalTool { @@ -75,6 +97,7 @@ impl TerminalTool { pub fn with_size(builder: BashBuilder, size: TerminalSize) -> Self { Self { terminal: Terminal::with_size(builder, size), + last_rows: None, } } @@ -108,7 +131,10 @@ Send keys in `input` using Vim notation: text is typed as-is, runs a lin ends input, types a literal '<'. Each call returns the screen, the activity \ (prompt, continuation, running, exited) and every command that finished with its exact \ output and exit code. vi and less work; quit vi with :wq, less with q. \ -If activity is running, call again (input may be empty) to keep waiting, or send ." +If activity is running, call again (input may be empty) to keep waiting, or send . \ +Set wait_for to a regex to return as soon as command output matches (for example a server's \ +'listening' line); matched tells whether it did. Set screen to \"changes\" to get only the rows \ +that changed since the last call, or \"none\" to skip the screen." .to_string() } @@ -126,6 +152,15 @@ If activity is running, call again (input may be empty) to keep waiting, or send "minimum": 0, "maximum": MAX_WAIT_MS, "description": "How long to wait for the session to need input (default 5000)." + }, + "wait_for": { + "type": "string", + "description": "Regex (multi-line: ^ and $ match at line ends). Return as soon as command output printed during this call matches it, even if the command is still running. Typed keys and prompts are not matched." + }, + "screen": { + "type": "string", + "enum": ["full", "changes", "none"], + "description": "full (default): whole screen. changes: only rows that differ from the previous call, as screen_changes. none: no screen." } } }) @@ -135,9 +170,27 @@ If activity is running, call again (input may be empty) to keep waiting, or send pub fn output_schema(&self) -> Value { json!({ "type": "object", - "required": ["screen", "activity", "commands", "waiting_for_input"], + "required": ["activity", "commands", "waiting_for_input"], "properties": { - "screen": {"type": "string", "description": "Visible screen as plain text"}, + "screen": {"type": "string", "description": "Visible screen as plain text (screen = full)"}, + "screen_changes": { + "type": "array", + "description": "Rows that changed since the previous call (screen = changes)", + "items": { + "type": "object", + "required": ["row", "text"], + "properties": { + "row": {"type": "integer", "description": "0-based screen row"}, + "text": {"type": "string"} + } + } + }, + "cursor": { + "type": "object", + "description": "Cursor position, 0-based (screen = changes)", + "properties": {"row": {"type": "integer"}, "col": {"type": "integer"}} + }, + "matched": {"type": "boolean", "description": "Whether wait_for matched (only when wait_for was given)"}, "activity": { "type": "string", "enum": ["prompt", "continuation", "running", "exited"], @@ -200,27 +253,77 @@ If activity is running, call again (input may be empty) to keep waiting, or send })?, } .min(MAX_WAIT_MS); + let wait_for = match args.get("wait_for") { + None | Some(Value::Null) => None, + Some(Value::String(p)) => Some(compile_pattern(p)?), + Some(_) => return Err(TerminalToolError("`wait_for` must be a string".into())), + }; + let mode = match args.get("screen") { + None | Some(Value::Null) => ScreenMode::Full, + Some(Value::String(m)) if m == "full" => ScreenMode::Full, + Some(Value::String(m)) if m == "changes" => ScreenMode::Changes, + Some(Value::String(m)) if m == "none" => ScreenMode::None, + Some(_) => { + return Err(TerminalToolError( + "`screen` must be \"full\", \"changes\" or \"none\"".into(), + )); + } + }; let bytes = parse_keys(input); + let mark = self.terminal.tty().output_mark(); if self.terminal.send(&bytes) < bytes.len() { return Err(TerminalToolError( "terminal input buffer is full; wait for the running command first".into(), )); } - let waiting = matches!( - crate::time_compat::timeout( - Duration::from_millis(wait_ms), - self.terminal.run_until_idle() - ) - .await, + let wait = Duration::from_millis(wait_ms); + let (waiting, matched) = match wait_for { + None => (self.wait_idle(wait).await, None), + Some(re) => { + let (waiting, matched) = self.wait_match(wait, mark, &re).await; + (waiting, Some(matched)) + } + }; + let mut out = self.report(waiting, mode); + if let Some(matched) = matched { + out["matched"] = json!(matched); + } + Ok(out) + } + + async fn wait_idle(&mut self, wait: Duration) -> bool { + matches!( + crate::time_compat::timeout(wait, self.terminal.run_until_idle()).await, Ok(TerminalStatus::Idle | TerminalStatus::Exited(_)) - ); - Ok(self.report(waiting)) + ) + } + + /// Run until `re` matches output written since `mark`, the session needs + /// input, or `wait` passes. Returns (waiting_for_input, matched). + async fn wait_match(&mut self, wait: Duration, mark: u64, re: ®ex::Regex) -> (bool, bool) { + let deadline = crate::time_compat::Instant::now() + wait; + loop { + if self.output_matches(mark, re) { + return (self.terminal.tty().is_idle(), true); + } + let left = deadline.saturating_duration_since(crate::time_compat::Instant::now()); + if left.is_zero() { + return (false, false); + } + if self.wait_idle(left.min(WAIT_SLICE)).await { + return (true, self.output_matches(mark, re)); + } + } + } + + fn output_matches(&self, mark: u64, re: ®ex::Regex) -> bool { + let raw = self.terminal.tty().output_since(mark); + re.is_match(&strip_ansi(&String::from_utf8_lossy(&raw))) } - fn report(&self, waiting: bool) -> Value { + fn report(&mut self, waiting: bool, mode: ScreenMode) -> Value { let mut out = json!({ - "screen": self.terminal.screen_text(), "waiting_for_input": waiting, "full_screen": self.terminal.is_alternate_screen(), "commands": self @@ -242,10 +345,77 @@ If activity is running, call again (input may be empty) to keep waiting, or send if let Some((key, value)) = extra { out[key] = value; } + let rows = self.terminal.screen_rows(); + match mode { + ScreenMode::Full => out["screen"] = json!(self.terminal.screen_text()), + ScreenMode::Changes => { + let prev = self.last_rows.as_deref().unwrap_or(&[]); + let resized = prev.len() != rows.len(); + let changes: Vec = rows + .iter() + .enumerate() + .filter(|(i, row)| resized || prev.get(*i) != Some(*row)) + .map(|(i, row)| json!({"row": i, "text": row})) + .collect(); + let (row, col) = self.terminal.cursor(); + out["screen_changes"] = json!(changes); + out["cursor"] = json!({"row": row, "col": col}); + } + ScreenMode::None => {} + } + self.last_rows = Some(rows); out } } +fn compile_pattern(pattern: &str) -> Result { + if pattern.len() > MAX_PATTERN_BYTES { + return Err(TerminalToolError(format!( + "`wait_for` is longer than {MAX_PATTERN_BYTES} bytes" + ))); + } + regex::RegexBuilder::new(pattern) + .size_limit(MAX_REGEX_SIZE) + .multi_line(true) + .build() + .map_err(|e| TerminalToolError(format!("`wait_for` is not a valid regex: {e}"))) +} + +/// Drop terminal control sequences (CSI, OSC, two-byte ESC) and `\r` so +/// `wait_for` matches the text a reader sees. +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => {} + '\x1b' => match chars.next() { + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' { + break; + } + if c == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => {} + }, + c => out.push(c), + } + } + out +} + fn record_json(r: CommandRecord) -> Value { let mut v = json!({ "command": r.command, @@ -407,6 +577,105 @@ mod tests { assert!(err.to_string().contains("smaller parts")); } + #[tokio::test] + async fn wait_for_returns_on_match_while_running() { + let mut tool = TerminalTool::new(Bash::builder()); + let started = std::time::Instant::now(); // std-time-ok: native-only test + let out = tool + .call(json!({ + "input": "for i in 1 2 3; do echo tick$i; sleep 1; done", + "wait_for": "tick[2]", + "wait_ms": 10000 + })) + .await + .unwrap(); + assert_eq!(out["matched"], true); + assert_eq!(out["activity"], "running"); + assert_eq!(out["waiting_for_input"], false); + assert!(started.elapsed() < std::time::Duration::from_millis(2500)); + assert!(out["screen"].as_str().unwrap().contains("tick2")); + let out = tool.call(json!({"input": ""})).await.unwrap(); + assert_eq!(out["activity"], "prompt"); + } + + #[tokio::test] + async fn wait_for_ignores_typed_echo() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "echo ready", "wait_for": "^ready$"})) + .await + .unwrap(); + assert_eq!(out["matched"], true); + let out = tool + .call(json!({"input": "true ready", "wait_for": "ready"})) + .await + .unwrap(); + assert_eq!(out["matched"], false); + assert_eq!(out["waiting_for_input"], true); + } + + #[tokio::test] + async fn wait_for_times_out_without_match() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "sleep 5", "wait_for": "never", "wait_ms": 100})) + .await + .unwrap(); + assert_eq!(out["matched"], false); + assert_eq!(out["activity"], "running"); + assert!(out.get("matched").is_some()); + let out = tool.call(json!({"input": ""})).await.unwrap(); + assert!(out.get("matched").is_none()); + } + + #[test] + fn wait_for_rejects_bad_patterns() { + assert!( + compile_pattern("(") + .unwrap_err() + .to_string() + .contains("not a valid regex") + ); + assert!(compile_pattern(&"a".repeat(MAX_PATTERN_BYTES + 1)).is_err()); + assert!(compile_pattern("a{1000}{1000}").is_err()); + } + + #[test] + fn strip_ansi_keeps_visible_text() { + assert_eq!(strip_ansi("\x1b[1;31mred\x1b[0m ok\r\n"), "red ok\n"); + assert_eq!(strip_ansi("\x1b]0;title\x07x\x1b]2;t\x1b\\y\x1b7z"), "xyz"); + } + + #[tokio::test] + async fn screen_changes_returns_only_changed_rows() { + let mut tool = TerminalTool::new(Bash::builder()); + let out = tool + .call(json!({"input": "echo one", "screen": "changes"})) + .await + .unwrap(); + // First call: every row is new. + assert_eq!(out["screen_changes"].as_array().unwrap().len(), 24); + assert!(out.get("screen").is_none()); + let out = tool + .call(json!({"input": "echo two", "screen": "changes"})) + .await + .unwrap(); + let changes = out["screen_changes"].as_array().unwrap(); + let texts: Vec<_> = changes + .iter() + .map(|c| c["text"].as_str().unwrap()) + .collect(); + assert_eq!(texts, ["$ echo two", "two", "$"]); + assert_eq!(changes[0]["row"], 2); + assert_eq!(out["cursor"], json!({"row": 4, "col": 2})); + let out = tool.call(json!({"screen": "none"})).await.unwrap(); + assert!(out.get("screen").is_none() && out.get("screen_changes").is_none()); + let out = tool.call(json!({"screen": "changes"})).await.unwrap(); + assert_eq!(out["screen_changes"], json!([])); + assert!(tool.call(json!({"screen": "diff"})).await.is_err()); + assert!(tool.call(json!({"wait_for": 1})).await.is_err()); + } + #[test] fn definition_matches_schema() { let tool = TerminalTool::new(Bash::builder()); diff --git a/crates/bashkit/src/terminal/tty.rs b/crates/bashkit/src/terminal/tty.rs index a15025db6..f503c7543 100644 --- a/crates/bashkit/src/terminal/tty.rs +++ b/crates/bashkit/src/terminal/tty.rs @@ -20,6 +20,8 @@ pub(crate) const MAX_PENDING_INPUT: usize = 1024 * 1024; /// Cap on raw output retained for `take_output`. Oldest bytes drop first; the /// screen model still sees every byte. pub(crate) const MAX_PENDING_OUTPUT: usize = 4 * 1024 * 1024; +/// Cap on recent command output kept for `TerminalTool`'s `wait_for` match. +pub(crate) const MAX_OUTPUT_TAP: usize = 256 * 1024; /// Lines of primary-screen history kept for `history_text`. pub(crate) const SCROLLBACK_LINES: usize = 1000; @@ -45,6 +47,10 @@ struct TtyState { closed: bool, screen: vt100::Parser, output: Vec, + /// Recent command stdout/stderr (no echo or prompts), for `wait_for`. + tap: Vec, + /// Total bytes ever appended to `tap`; marks index into it. + tap_total: u64, } pub(crate) struct TtyShared { @@ -53,6 +59,8 @@ pub(crate) struct TtyShared { idle: Notify, clock: InputWaitClock, cancel: Arc, + /// Fired with `cancel` so `exec` drops the running command mid-way. + interrupt: Arc, } /// Cloneable handle to the shared terminal device. @@ -60,7 +68,12 @@ pub(crate) struct TtyShared { pub(crate) struct Tty(Arc); impl Tty { - pub(crate) fn new(size: TerminalSize, clock: InputWaitClock, cancel: Arc) -> Self { + pub(crate) fn new( + size: TerminalSize, + clock: InputWaitClock, + cancel: Arc, + interrupt: Arc, + ) -> Self { Self(Arc::new(TtyShared { state: Mutex::new(TtyState { input: VecDeque::new(), @@ -71,11 +84,14 @@ impl Tty { closed: false, screen: vt100::Parser::new(size.rows, size.cols, SCROLLBACK_LINES), output: Vec::new(), + tap: Vec::new(), + tap_total: 0, }), input_ready: Notify::new(), idle: Notify::new(), clock, cancel, + interrupt, })) } @@ -87,8 +103,8 @@ impl Tty { /// Queue input bytes. Returns how many were accepted. /// - /// In cooked mode while a command runs, Ctrl-C (0x03) cancels the command - /// instead of being queued, like a real line discipline's ISIG. + /// In cooked mode while a command runs, Ctrl-C (0x03) interrupts the + /// command instead of being queued, like a real line discipline's ISIG. pub(crate) fn send(&self, bytes: &[u8]) -> usize { let mut accepted = 0; { @@ -96,6 +112,7 @@ impl Tty { for &b in bytes { if b == 0x03 && st.foreground && !st.raw { self.0.cancel.store(true, Ordering::Relaxed); + self.0.interrupt.notify_one(); accepted += 1; continue; } @@ -146,6 +163,20 @@ impl Tty { std::mem::take(&mut self.lock().output) } + /// Position in the command-output stream; pass to `output_since`. + pub(crate) fn output_mark(&self) -> u64 { + self.lock().tap_total + } + + /// Command output written after `mark`, at most `MAX_OUTPUT_TAP` bytes + /// (the newest ones). + pub(crate) fn output_since(&self, mark: u64) -> Vec { + let st = self.lock(); + let new = st.tap_total.saturating_sub(mark); + let keep = usize::try_from(new).unwrap_or(usize::MAX).min(st.tap.len()); + st.tap[st.tap.len() - keep..].to_vec() + } + pub(crate) fn with_screen(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R { f(self.lock().screen.screen()) } @@ -200,6 +231,20 @@ impl Tty { } } + /// Record command stdout/stderr for `output_since`. Does not draw. + pub(crate) fn tap_output(&self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + let mut st = self.lock(); + st.tap.extend_from_slice(bytes); + st.tap_total += bytes.len() as u64; + let len = st.tap.len(); + if len > MAX_OUTPUT_TAP { + st.tap.drain(..len - MAX_OUTPUT_TAP); + } + } + /// Write with output post-processing (ONLCR): `\n` becomes `\r\n`. pub(crate) fn write_cooked(&self, bytes: &[u8]) { if !bytes.contains(&b'\n') { diff --git a/docs/terminal.md b/docs/terminal.md index 9d67a69c9..42950e05b 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -191,6 +191,17 @@ call again, with empty input, to keep waiting, or send ``. One `TerminalTool` is one session, so keep it for the whole conversation. `call` takes at most 64 KiB of `input` per call. +Two optional arguments keep agent loops short: + +- `wait_for`: a regex. The call returns as soon as output printed by commands + during the call matches, even while the command keeps running, and sets + `matched`. Typed keys and prompts are not matched, so + `{"input": "./serve.sh", "wait_for": "listening on"}` returns when the + server prints its line. `^` and `$` match at line ends. +- `screen`: `"full"` (default) returns `screen`; `"changes"` returns + `screen_changes` (`[{row, text}]`, only rows that differ from the previous + call) and `cursor`; `"none"` returns no screen. + It is not a `BashTool`: that tool runs each call in a fresh shell, while a terminal keeps the shell, open programs and the screen between calls. @@ -308,8 +319,8 @@ same execution limits, no host processes. A few terminal-specific rules apply: - **Bounded buffers.** Unread input is capped at 1 MiB (`send` returns how many bytes it accepted), retained raw output at the newest 4 MiB, and the `vi` buffer at 8 MiB. -- **Ctrl-C** stops a running command at the next command boundary, so a single - builtin such as `sleep 5` finishes first. +- **Ctrl-C** stops a running command right away, even inside `sleep`, and + sets `$?` to 130. - **Command stdin is not the terminal.** `read` with no input gets end-of-file instead of waiting for typed text. Only `vi` reads keystrokes directly. diff --git a/knowledge/integrations/in-process-terminal.md b/knowledge/integrations/in-process-terminal.md index 2086f0884..bce52c15e 100644 --- a/knowledge/integrations/in-process-terminal.md +++ b/knowledge/integrations/in-process-terminal.md @@ -102,6 +102,15 @@ Keys use Vim notation (`ihi:wq`, ``, ``), unknown call waits up to `wait_ms` (default 5 s, max 60 s); an unfinished command is reported as `running`, never killed. Input per call is capped at 64 KiB. +`wait_for` (regex, multi-line, 1 KiB pattern and 1 MiB compiled cap) returns +early once command output printed during the call matches. It matches a tap of +command stdout/stderr (newest 256 KiB), not the screen: the screen also holds +the echoed keystrokes, so `echo ready` would match itself. Text is +ANSI-stripped first. The call polls `run_until_idle` in 50 ms slices; the +reply carries `matched`. `screen: "changes"` returns only rows that differ +from the previous call plus the cursor, `"none"` drops the screen; every call +updates the baseline. Long agent sessions resend a 24-row screen otherwise. + ## Decision: line discipline split - Cooked mode (prompt): the shell loop's own small line editor (echo, cursor @@ -115,8 +124,12 @@ reported as `running`, never killed. Input per call is capped at 64 KiB. - Raw mode: set by `vi` through a drop guard (restored on cancel too); bytes go to the reader untouched and Ctrl-C is not an interrupt. - While a command runs in cooked mode, a sent `0x03` sets the interpreter's - cancellation token instead of being queued (ISIG). Cancellation is - cooperative, so it lands at the next command boundary (L-TERM-003). + cancellation token and fires an interrupt `Notify` instead of being queued + (ISIG). `exec` races the command future against that notify and drops it + mid-command, the same way the execution timeout does, then runs + `clear_cancelled_execution_state` (L-TERM-003 lifted). The shell loop + prints `^C` and `$?` becomes 130. An interactive session carries `$?` + across lines (`Bash::carry_exit_code` seeds it after the per-exec reset). - Command stdin is still a value fixed before the command starts, so `read` gets EOF (L-TERM-002). Wiring fd 0 to the device needs a reader-backed stdin in the interpreter, shared with L-CLI-002. @@ -196,5 +209,5 @@ redirected (L-TERM-004). - [Interactive Shell](interactive-shell.md), the CLI REPL on a real terminal - [Builtin Commands](../foundations/builtins.md), builtin trait and execution extensions -- [Limitations](../operations/limitations.md), L-TERM-001..003 +- [Limitations](../operations/limitations.md), L-TERM-001..004 - [Threat Model](../security/threat-model.md), TM-DOS-119, TM-DOS-120 diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 7618687aa..b021a0deb 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -199,7 +199,7 @@ PTY running GNU bash and vim. |----|------------|-----|----------| | L-TERM-001 | `vi` is a subset editor: normal/insert/command-line modes, counts, common motions and operators, undo/redo, `/` search, `:s`, `:w :q :wq :x`. No visual mode, named registers, macros, splits, vimrc, or `:!` shell escape; unsupported keys are ignored | Enough to edit config and source files from an agent or a browser terminal without vendoring an editor; `:!` would re-enter the shell from inside a running command | `l_term_001_vi_is_a_subset` | | L-TERM-002 | Command stdin is not wired to the terminal: `read` and `cat` with no file get EOF instead of waiting for typed input. Only programs that read the terminal device directly (`vi`) see keystrokes | Same root as L-CLI-002, the interpreter takes stdin as a value before the command starts | `l_term_002_read_does_not_wait_for_terminal_input` | -| L-TERM-003 | Ctrl-C cancels at the next command boundary: a running builtin (`sleep 5`) finishes first, and `$?` keeps the previous value rather than 130 | Cancellation uses the interpreter's cooperative cancel token; dropping a running execution mid-command would leave interpreter state half-updated | `terminal::tests::ctrl_c_cancels_at_next_command_boundary` | +| L-TERM-003 | **Removed:** Ctrl-C now drops the running command mid-way (a running `sleep 5` stops at once) and `$?` is 130 | The exec future races an interrupt `Notify` and reuses the timeout path's `clear_cancelled_execution_state` cleanup | `terminal::tests::ctrl_c_interrupts_a_running_command` | | L-TERM-004 | Inside a terminal session, `less` and `more` page even when stdout is redirected (`less file > out` shows the pager and writes nothing to `out`). Outside a session they are always cat-like | Builtins cannot see whether their stdout is a pipe or redirect; TTY state is per-session, not per-fd-redirect | `builtins::pager::tests::pagers_are_cat_like_without_terminal` | ## Parser diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 37a4f270c..00e77de5f 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -286,7 +286,7 @@ runaway scripts without permanently breaking the session. | TM-DOS-116 | AWK `close()` recycles getline read quotas | Repeated `getline x < file; close(file)` reloads and rematerializes the same large VFS file while retained-cache accounting returns to zero | Every cache miss monotonically charges the shared aggregate-input budget and byte-proportional work before decoding/materializing lines; `close()` still releases retained cache bytes but cannot refund either charge. Regression: `execution_budget_tests::awk_getline_close_cannot_recycle_input_budget` | **MITIGATED** | | TM-DOS-117 | Newline-dense `head`/`tail` line selection | A small line request over a newline-dense file (`head -n 1` on 1 MB of newlines) built a `Vec` offset for every input line, so a bounded request allocated memory proportional to the whole input | `select_lines` scans for only the requested boundary, forward for `First`/`From` and backward for `Last`/`AllButLast`, and stores no offset collection; work is bounded by the distance to that boundary rather than by input size. Regression tests: `headtail_allocation_tests` counts allocations (13.6 MB peak before, 1.0 MB after, for `head -n 1` over 1 MB of newlines); `headtail::tests::tm_dos_117_newline_dense_selection_finds_boundaries` and `headtail_resource_tests` cover boundary correctness | **MITIGATED** | | TM-DOS-118 | Python read-only self-mount recursion | `bash.mount("/", bash.fs(), read_only=True)` wraps the live filesystem before the core pointer-identity guard, then filesystem reads recurse until stack exhaustion | `Bash.mount()` and `BashTool.mount()` compare the resolved source with the live filesystem before constructing `ReadOnlyFs`; regression tests cover both Python surfaces | **MITIGATED** | -| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns use `regex` with a 1 MiB compiled-size limit. `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | +| TM-DOS-119 | Interactive terminal resource exhaustion | A host or script floods the in-process `Terminal` (`terminal` feature): unbounded typed-ahead input, output nobody drains, or a `vi` buffer grown by pastes, `p` and `:s` until memory runs out | Unread input is capped at 1 MiB (`send` returns the accepted count), retained raw output at the newest 4 MiB, scrollback at 1000 rows, untaken transcript records at 1 MiB total (64 KiB of output per record, oldest records drop first), the cooked-mode line and `TerminalTool` input per call at 64 KiB, the `vi` buffer at 8 MiB (load, insert, put and substitute check it), and `vi` undo/redo history at 16 MiB total; `vi` patterns and `TerminalTool` `wait_for` use `regex` with a 1 MiB compiled-size limit (`wait_for` also caps the pattern at 1 KiB and matches only the newest 256 KiB of command output). `:w` goes through the session VFS, so VFS limits and read-only mounts apply. Regressions: `terminal::tests::input_backlog_is_capped`, `terminal::tests::transcript_output_is_capped_per_record`, `terminal::tests::transcript_total_size_is_bounded`, `terminal::tests::history_is_bounded`, `terminal::tool::tests::bad_arguments_are_user_facing_errors`, `terminal::tool::tests::wait_for_rejects_bad_patterns`, `builtins::vi::tests::buffer_cap_stops_inserts`, `builtins::vi::tests::readonly_vfs_write_error_keeps_editor_open` | **MITIGATED** | | TM-DOS-120 | Interactive input wait vs. execution timeout | Excluding keyboard waits from `ExecutionLimits::timeout` (so a `vi` session is not killed while a person or agent thinks) must not let busy work escape the deadline | Only time blocked in the terminal device read is excluded (`InputWaitClock`); the timer re-arms with the remaining *active* budget, so CPU work, `sleep`, and pending callbacks still time out. Only a `Terminal` installs the clock; plain `exec()` is unchanged. Regressions: `terminal::tests::waiting_for_input_does_not_count_toward_timeout`, `terminal::tests::busy_work_still_times_out` | **MITIGATED** | | TM-DOS-121 | `find` traversal amplification | `find -L` over a symlink cycle (`ln -s .. d/up`) re-enters the same directories forever; `-exec ... \;` as a predicate or `{} +` batches could also queue unbounded command lines; the old `-name` glob matcher recursed with string clones per `*` (exponential on `*a*a*...b`) | `-L`/`-H` resolve each directory to its canonical path (realpath with a 40-hop ELOOP cap) and refuse to enter one already on the current path (`File system loop detected`, exit 1); every directory listing charges `consume_work`; find's own output stays capped by `FIND_MAX_OUTPUT_BYTES`; `{} +` batches flush every 4096 paths; `fnmatch` uses a single star restore point (O(value x pattern)). Regression tests: `find_follow_detects_loops` spec case, `ls::glob::tests::many_stars_are_linear` | **MITIGATED** | | TM-DOS-122 | Background job flooding | `while :; do sleep 99 & done`, or a fork bomb `f(){ f & f & }; f`, keeps unbounded concurrent forked interpreters alive | `ExecutionLimits::max_background_jobs` (default 64, `hardened()` 16) caps live jobs; the next `&` prints bash's `fork: retry: Resource temporarily unavailable` and returns 1 without forking. Jobs are polled on the caller's task (no `tokio::spawn`), share the session's command/loop budget, timeout and cancel token, and `exec()` waits for or drops every job before returning, so nothing outlives the call. Regressions: `jobs::tests::*`, `jobs.test.sh` spec cases, `background_job_limit` | **MITIGATED** |