Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/bashkit-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
29 changes: 21 additions & 8 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
16 changes: 8 additions & 8 deletions crates/bashkit/src/scripted_tool/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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
};

Expand Down
39 changes: 25 additions & 14 deletions crates/bashkit/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 }
}
Expand Down
2 changes: 1 addition & 1 deletion knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
7 changes: 3 additions & 4 deletions knowledge/runtimes/browser-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down