From 3de24497f64adee2c2068a69bcfe4365550afc2c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 26 Jul 2026 17:38:52 -0500 Subject: [PATCH] fix(wasm): reject unenforceable timeouts --- crates/bashkit-wasm/README.md | 5 ++- .../__test__/bashkit-wasm.test.mjs | 7 ++-- crates/bashkit/src/interpreter/mod.rs | 29 ++++++++++---- crates/bashkit/src/scripted_tool/execute.rs | 16 ++++---- crates/bashkit/src/tool.rs | 39 ++++++++++++------- knowledge/operations/limitations.md | 2 +- knowledge/runtimes/browser-package.md | 7 ++-- 7 files changed, 65 insertions(+), 40 deletions(-) diff --git a/crates/bashkit-wasm/README.md b/crates/bashkit-wasm/README.md index 41c7be50..6bf4c9f8 100644 --- a/crates/bashkit-wasm/README.md +++ b/crates/bashkit-wasm/README.md @@ -151,8 +151,9 @@ custom builtin (see above) so requests go through your app's own `fetch`. ## Limitations - **No wall-clock time.** `wasm32-unknown-unknown` has no reliable timer driver, - so `sleep N` elapses instantly and neither the `timeout N` builtin nor - `timeoutMs` is enforced. Runaway scripts are instead bounded by `maxCommands`, + so `sleep N` elapses instantly. The `timeout N` builtin and tool requests with + `timeoutMs` fail closed with status 125 rather than accepting an unenforceable + deadline. Scripts without deadlines remain bounded by `maxCommands`, `maxLoopIterations`, and the parser fuel budget — a `while true` loop throws a resource-limit error rather than hanging. - **`executeSync` can't run async builtins.** The single-threaded event loop diff --git a/crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs b/crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs index c6082649..c40282ef 100644 --- a/crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs +++ b/crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs @@ -112,11 +112,12 @@ test("sleep returns immediately (no timer driver on wasm)", async () => { assert.equal(r.exitCode, 0); }); -test("timeout runs the command (no wall-clock enforcement on wasm)", async () => { +test("timeout rejects unsupported wall-clock enforcement on wasm", async () => { const bash = new Bash(); const r = await bash.execute("timeout 5 echo hi"); - assert.equal(r.stdout, "hi\n"); - assert.equal(r.exitCode, 0); + assert.equal(r.stdout, ""); + assert.match(r.stderr, /timeout is unsupported/); + assert.equal(r.exitCode, 125); }); test("background job runs and wait collects it", async () => { diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 8dfa99b0..b1828547 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -8125,18 +8125,31 @@ impl Interpreter { preserve_status, command, } => { - // Build inner command with optional stdin via here-string + // Build inner command with optional stdin via here-string. let inner_cmd = subcommand_to_command(&command); - // wasm32-unknown-unknown has no timer driver, so tokio::time::timeout - // panics ("time not implemented"). Run the command without wall-clock - // enforcement — the parser fuel budget, maxCommands and - // maxLoopIterations still bound runaway work. This matches the - // "no preemptive timeout" stance in knowledge/runtimes/browser-package.md. + // wasm32-unknown-unknown has no timer driver. Fail closed rather + // than accepting a deadline that cannot be enforced while an + // async custom builtin is pending. #[cfg(target_family = "wasm")] let outcome = { - let _ = (duration, preserve_status); - self.execute_command(&inner_cmd).await? + // The parsed command is intentionally never dispatched. + drop(inner_cmd); + // Keep the phrasing aligned with `unsupported_timeout_response` + // ("timeout is unsupported on this wasm target") so callers and + // tests can match a single message across builtin and tool paths. + ExecResult::err( + format!( + "bashkit: timeout is unsupported on this wasm target (requested {:.3}s{})\n", + duration.as_secs_f64(), + if preserve_status { + " --preserve-status" + } else { + "" + } + ), + 125, + ) }; #[cfg(not(target_family = "wasm"))] diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit/src/scripted_tool/execute.rs index 172a060d..95c52af7 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit/src/scripted_tool/execute.rs @@ -7,11 +7,11 @@ use crate::tool::{ VERSION, localized, tool_output_from_response, tool_request_from_value, tool_request_schema, tool_response_schema, }; -// timeout_response + Duration are only used on the native timeout path; wasm32 -// runs without wall-clock enforcement (no timer driver), so they are gated out -// there to keep the wasm build warning-clean (CI checks wasm with `-D warnings`). +// Timeout helpers are target-specific because wasm32 has no timer driver. #[cfg(not(target_family = "wasm"))] use crate::tool::timeout_response; +#[cfg(target_family = "wasm")] +use crate::tool::unsupported_timeout_response; use crate::tool_def::usage_from_schema; use async_trait::async_trait; use std::collections::VecDeque; @@ -161,9 +161,8 @@ impl ScriptedTool { // Keep ScriptedTool on the shared ToolRequest contract: per-call // timeouts must abort the whole orchestration, including callbacks. - // wasm32 has no timer driver (tokio::time::timeout panics), so there the - // orchestration runs without wall-clock enforcement; the timer path - // stays on native. + // wasm32 has no timer driver. Reject requested timeouts instead of + // allowing an unresolved callback to suspend orchestration forever. #[cfg(not(target_family = "wasm"))] let response = if let Some(ms) = timeout_ms { let duration = Duration::from_millis(ms); @@ -175,8 +174,9 @@ impl ScriptedTool { fut.await }; #[cfg(target_family = "wasm")] - let response = { - let _ = timeout_ms; + let response = if timeout_ms.is_some() { + unsupported_timeout_response() + } else { fut.await }; diff --git a/crates/bashkit/src/tool.rs b/crates/bashkit/src/tool.rs index ec6233d0..20082181 100644 --- a/crates/bashkit/src/tool.rs +++ b/crates/bashkit/src/tool.rs @@ -960,9 +960,8 @@ impl BashTool { } }; - // wasm32-unknown-unknown has no timer driver, so tokio::time::timeout - // panics. Run without wall-clock enforcement there (fuel / maxCommands - // still bound the work); the timer path stays on native. + // wasm32-unknown-unknown has no timer driver. Reject requested timeouts + // rather than silently running an async callback without a deadline. #[cfg(not(target_family = "wasm"))] let out = if let Some(ms) = req.timeout_ms { let duration = Duration::from_millis(ms); @@ -974,8 +973,9 @@ impl BashTool { fut.await }; #[cfg(target_family = "wasm")] - let out = { - let _ = req.timeout_ms; + let out = if req.timeout_ms.is_some() { + unsupported_timeout_response() + } else { fut.await }; out @@ -1067,7 +1067,7 @@ impl Tool for BashTool { } }; - // wasm32 has no timer driver; run without wall-clock enforcement there. + // wasm32 has no timer driver; reject rather than bypass the deadline. #[cfg(not(target_family = "wasm"))] let out = if let Some(ms) = req.timeout_ms { let dur = Duration::from_millis(ms); @@ -1079,8 +1079,9 @@ impl Tool for BashTool { fut.await }; #[cfg(target_family = "wasm")] - let out = { - let _ = req.timeout_ms; + let out = if req.timeout_ms.is_some() { + unsupported_timeout_response() + } else { fut.await }; out @@ -1145,7 +1146,7 @@ impl Tool for BashTool { response }; - // wasm32 has no timer driver; run without wall-clock enforcement there. + // wasm32 has no timer driver; reject rather than bypass the deadline. #[cfg(not(target_family = "wasm"))] let out = if let Some(ms) = timeout_ms { let dur = Duration::from_millis(ms); @@ -1157,8 +1158,9 @@ impl Tool for BashTool { fut.await }; #[cfg(target_family = "wasm")] - let out = { - let _ = timeout_ms; + let out = if timeout_ms.is_some() { + unsupported_timeout_response() + } else { fut.await }; out @@ -1182,9 +1184,7 @@ fn error_kind(e: &Error) -> String { /// Build a ToolResponse for a timed-out execution (exit code 124, like bash `timeout`). /// -/// Only the native timeout paths use this; wasm32 has no timer driver and runs -/// without wall-clock enforcement, so it is gated out there to keep the wasm -/// build warning-clean (CI checks wasm with `-D warnings`). +/// Only native timeout paths use this; wasm rejects requested timeouts. #[cfg(not(target_family = "wasm"))] pub(crate) fn timeout_response(dur: Duration) -> ToolResponse { ToolResponse { @@ -1199,6 +1199,17 @@ pub(crate) fn timeout_response(dur: Duration) -> ToolResponse { } } +/// Reject a timeout contract that wasm cannot enforce safely. +#[cfg(target_family = "wasm")] +pub(crate) fn unsupported_timeout_response() -> ToolResponse { + ToolResponse { + stderr: "bashkit: timeout is unsupported on this wasm target\n".to_string(), + exit_code: 125, + error: Some("unsupported_timeout".to_string()), + ..Default::default() + } +} + pub(crate) fn localized<'a>(locale: &str, en: &'a str, uk: &'a str) -> &'a str { if locale.starts_with("uk") { uk } else { en } } diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 6c637754..7185d64d 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -50,7 +50,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | L-NET-001 | No raw network sockets; HTTP only via `curl`/`wget`/`http` builtins | Allowlist-mediated egress is the only network surface | `l_net_001_no_raw_sockets` | | L-NET-002 | No DNS resolution; hosts must appear in the allowlist | Resolution would bypass allowlist intent | `l_net_002_default_deny_no_resolution` | | L-SIG-001 | `trap` stores INT/TERM handlers but no signal delivery in virtual mode (EXIT, ERR fire) | No host signals exist inside the sandbox | `l_sig_001_signal_traps_not_delivered` | -| L-WASM-001 | Browser build (`@everruns/bashkit-wasm`, `wasm32-unknown-unknown`) has no wall-clock time: `sleep N` elapses instantly, and neither the `timeout N` builtin nor `timeoutMs` is enforced | No reliable timer driver on the target; parser fuel + `maxCommands`/`maxLoopIterations` still bound runaway scripts | `knowledge/browser-package.md`, stance | +| L-WASM-001 | Browser build (`@everruns/bashkit-wasm`, `wasm32-unknown-unknown`) has no wall-clock time: `sleep N` elapses instantly; `timeout N` and tool requests with `timeoutMs` fail closed with status 125 | No reliable timer driver on the target; silently accepting an unenforceable deadline could let an async callback suspend forever | `knowledge/runtimes/browser-package.md`, stance | | L-WASM-002 | Browser build: `executeSync()` cannot run async custom builtins (fails with a clear message); use `execute()` | Single-threaded event loop can't settle a JS `Promise` without yielding | `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs` | | L-WASM-003 | Browser build: background jobs (`cmd &`) run synchronously and `awk` file redirects drive the VFS inline; no work runs on a separate thread | `wasm32-unknown-unknown` is single-threaded — `std::thread::spawn`/`tokio::spawn` are unavailable; safe because the in-memory VFS never suspends | `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs` | diff --git a/knowledge/runtimes/browser-package.md b/knowledge/runtimes/browser-package.md index af5e2b53..10172d90 100644 --- a/knowledge/runtimes/browser-package.md +++ b/knowledge/runtimes/browser-package.md @@ -135,11 +135,10 @@ pattern as `publish-js.yml`. Browser example smoke testing writes a file under ## Limitations (see `knowledge/limitations.md`) - No wall-clock time on `wasm32-unknown-unknown` (no timer driver). This is a - hard platform constraint, so time-based behaviour degrades rather than - enforces, and never blocks: + hard platform constraint, so unsupported time-based controls fail closed: - `sleep N` elapses **instantly** (it cannot suspend for real time). - - the `timeout N cmd` builtin and the tool-level `timeoutMs` run the command - **without** wall-clock enforcement. + - the `timeout N cmd` builtin and requests with tool-level `timeoutMs` are + rejected with an unsupported-timeout error (exit status 125). - Runaway work is still bounded by the parser fuel budget and `maxCommands` / `maxLoopIterations`, which do not depend on a clock. - Single-threaded: no OS threads (`std::thread::spawn` is unsupported) and no