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
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2

- name: Check bashkit for wasm32-unknown-unknown
run: cargo check -p bashkit --target wasm32-unknown-unknown --features scripted_tool,jq,wasm_js
run: cargo check -p bashkit --target wasm32-unknown-unknown --features scripted_tool,jq,wasm_js,terminal
env:
# getrandom needs an explicit JS backend opt-in on this target.
RUSTFLAGS: --cfg getrandom_backend="wasm_js" -D warnings
Expand All @@ -100,7 +100,7 @@ jobs:
# as the browser package; no getrandom cfg here (wasip2 has a native
# backend). Check-only: there is no WASI entry-point crate yet.
- name: Check bashkit for wasm32-wasip2
run: cargo check -p bashkit --target wasm32-wasip2 --features scripted_tool,jq
run: cargo check -p bashkit --target wasm32-wasip2 --features scripted_tool,jq,terminal
env:
RUSTFLAGS: -D warnings

Expand Down Expand Up @@ -303,6 +303,14 @@ jobs:
- name: Run realfs integration tests
run: cargo test -p bashkit --test integration --features realfs -- host_mounts_tests thirdparty_adoption_tests

# In-process terminal + vi builtin are `cfg(terminal)` (off by default),
# so the main "Run tests" step compiles them out entirely.
- name: Run terminal tests
run: |
cargo test -p bashkit --features terminal --lib -- terminal:: builtins::vi:: builtins::pager::
cargo test -p bashkit --features terminal --test integration -- terminal_tests l_term_
cargo test -p bashkit --features terminal --doc terminal

- name: Run fail-point tests (single-threaded)
run: cargo test --features failpoints --test security_failpoint_tests -- --test-threads=1

Expand Down Expand Up @@ -414,6 +422,8 @@ jobs:
cargo run --example realfs_readwrite --features realfs
cargo run --example sqlite_basic --features sqlite
cargo run --example sqlite_workflow --features sqlite
cargo run --example terminal_vi --features terminal
cargo run --example terminal_record --features terminal

# SSH tests
- name: Run ssh builtin tests (mock handler)
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
| runtimes/zapcode-runtime | Embedded TypeScript via ZapCode, VFS bridging, resource limits |
| security/request-signing | Transparent Ed25519 request signing (bot-auth) per RFC 9421 |
| integrations/interactive-shell | Interactive REPL mode with rustyline line editing |
| integrations/in-process-terminal | In-memory PTY-like `Terminal` (feature `terminal`), screen text, `vi` builtin |
| runtimes/sqlite-builtin | Embedded SQLite via Turso (MemoryIO + VfsIO backends, dot-commands) |
| runtimes/coreutils-args-port | Codegen port of uutils clap definitions + uucore modules |
| security/credential-injection | Per-host HTTP credential injection without exposing secrets |
Expand Down
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ fancy-regex = "0.19"
# embedders. Re-enabled by the bashkit `wasm_js` feature for the JS packages.
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
chrono-tz = "0.10"
# In-process terminal screen model (terminal feature)
vt100 = "0.16"

# Compression
flate2 = "1"
Expand Down
19 changes: 19 additions & 0 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fancy-regex = { workspace = true }
# Date/Time
chrono = { workspace = true }
chrono-tz = { workspace = true, optional = true }
vt100 = { workspace = true, optional = true }

# HTTP client (for curl/wget) - optional, enabled with http_client feature
reqwest = { workspace = true, optional = true }
Expand Down Expand Up @@ -195,6 +196,16 @@ wasm_js = [
"dep:send_wrapper",
"dep:wasm-bindgen-futures",
]
# In-process terminal: a PTY-like `Terminal` (keystrokes in, screen text out)
# wrapping a `Bash`, plus the `vi` builtin that needs it. Off by default.
#
# Important decision: the screen model is the pure-Rust `vt100` crate, not
# libghostty. vt100 is MIT, has three small pure-Rust deps, and builds for every
# bashkit target (wasm32 included). libghostty needs a Zig toolchain and C FFI,
# which would break the wasm, PyPI and npm builds for a feature this small.
# See knowledge/integrations/in-process-terminal.md.
# Usage: cargo build --features terminal
terminal = ["dep:vt100"]
# Enable jq builtin via embedded jaq interpreter
# Usage: cargo build --features jq
jq = [
Expand Down Expand Up @@ -324,6 +335,14 @@ name = "sqlite"
harness = false
required-features = ["sqlite"]

[[example]]
name = "terminal_vi"
required-features = ["terminal"]

[[example]]
name = "terminal_record"
required-features = ["terminal"]

[[example]]
name = "agent_tool"
required-features = ["http_client"]
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ through configurable limits.
| split file count (TM-DOS-055) | `split -l 1 bigfile` | FS `max_file_count` limit | MITIGATED |
| source self-recursion (TM-DOS-056) | Script that sources itself | Track source depth | **MITIGATED** |
| sleep bypasses timeout (TM-DOS-057) | `sleep N` ignores `ExecutionLimits::timeout` | Host-backed timeout; non-JS wasm blocking sleep is clamped to the execution deadline | **MITIGATED** |
| Interactive terminal flooding (TM-DOS-119) | Unbounded type-ahead, undrained output, or a `vi` buffer grown by pastes | Caps on unread input (1 MiB), retained output (4 MiB), line length, `vi` buffer (8 MiB) and undo history; `:w` goes through VFS limits | MITIGATED |
| Input wait vs. timeout (TM-DOS-120) | Abusing the keyboard-wait exclusion to run work past the deadline | Only time blocked reading the terminal is excluded; CPU work and `sleep` still count | MITIGATED |
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
| Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** |
| In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** |
Expand Down
103 changes: 103 additions & 0 deletions crates/bashkit/examples/terminal_record.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Record an in-process terminal session as an asciicast v2 file.
//!
//! Types a short scenario key by key (shell, `vi` edit, `less`), collects the
//! raw output stream from `Terminal::take_output()`, and writes it with
//! synthetic timestamps. Replay with `asciinema play FILE` or embed it with
//! asciinema-player.
//!
//! Run with: cargo run --example terminal_record --features terminal -- [OUT.cast]

use bashkit::Bash;
use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus};

/// Delay between typed keys, and after a step completes.
const KEY_DELAY: f64 = 0.07;
const STEP_PAUSE: f64 = 1.2;

struct Recorder {
term: Terminal,
clock: f64,
events: Vec<String>,
}

impl Recorder {
fn capture(&mut self) {
let out = self.term.take_output();
if !out.is_empty() {
let text = String::from_utf8_lossy(&out);
let data = serde_json::to_string(&text).unwrap();
self.events
.push(format!("[{:.3}, \"o\", {data}]", self.clock));
}
}

/// Type `keys` one character at a time, then pause so a viewer can read.
async fn type_keys(&mut self, keys: &str) {
for ch in keys.chars() {
self.term.send(ch.to_string());
self.term.run_until_idle().await;
self.clock += KEY_DELAY;
self.capture();
}
self.clock += STEP_PAUSE;
}
}

#[tokio::main]
async fn main() {
let out_path = std::env::args().nth(1).unwrap_or_else(|| {
std::env::temp_dir()
.join("terminal-demo.cast")
.display()
.to_string()
});
let size = TerminalSize::new(16, 72);
let mut rec = Recorder {
term: Terminal::with_size(Bash::builder(), size),
clock: 0.0,
events: Vec::new(),
};
rec.term.run_until_idle().await;
rec.capture();
rec.clock += STEP_PAUSE;

rec.type_keys("printf 'name = \"demo\"\\nport = 8080\\ndebug = true\\n' > app.toml\r")
.await;
rec.type_keys("vi app.toml\r").await;
rec.type_keys("/8080\r").await;
rec.type_keys("cw9090\x1b").await;
rec.type_keys(":%s/true/false/\r").await;
rec.type_keys("Go# edited in vi\x1b").await;
rec.type_keys(":wq\r").await;
rec.type_keys("cat app.toml\r").await;
rec.type_keys("seq 1 200 | less\r").await;
rec.type_keys(" ").await;
rec.type_keys("/150\r").await;
rec.type_keys("q").await;
rec.type_keys("exit\r").await;
assert_eq!(rec.term.run_until_idle().await, TerminalStatus::Exited(0));

let saved = rec
.term
.fs()
.read_file("/home/user/app.toml".as_ref())
.await
.unwrap();
assert_eq!(
String::from_utf8(saved).unwrap(),
"name = \"demo\"\nport = 9090\ndebug = false\n# edited in vi\n"
);

let header = format!(
"{{\"version\": 2, \"width\": {}, \"height\": {}, \"env\": {{\"TERM\": \"xterm-256color\"}}, \"title\": \"bashkit in-process terminal\"}}",
size.cols, size.rows
);
let mut cast = header;
for event in &rec.events {
cast.push('\n');
cast.push_str(event);
}
cast.push('\n');
std::fs::write(&out_path, cast).unwrap();
println!("wrote {} events to {out_path}", rec.events.len());
}
42 changes: 42 additions & 0 deletions crates/bashkit/examples/terminal_vi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Drive an in-process terminal like an agent would: type keystrokes, wait
//! until the shell needs input, read the screen as text, and edit a file in
//! `vi`.
//!
//! Run with: cargo run --example terminal_vi --features terminal

use bashkit::Bash;
use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus};

async fn show(term: &mut Terminal, keys: &str, label: &str) {
term.send(keys);
term.run_until_idle().await;
println!("--- {label} ---\n{}\n", term.screen_text());
}

#[tokio::main]
async fn main() {
let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(8, 50));
term.run_until_idle().await;

show(
&mut term,
"echo 'todo: write docs' > /tmp/notes.txt\r",
"shell",
)
.await;
show(&mut term, "vi /tmp/notes.txt\r", "vi opened").await;
show(&mut term, "A, ship it\x1bo- add tests\x1b", "vi edited").await;
show(&mut term, ":wq\r", "back at the prompt").await;
show(&mut term, "cat /tmp/notes.txt\r", "result").await;

let saved = term
.fs()
.read_file("/tmp/notes.txt".as_ref())
.await
.unwrap();
assert_eq!(saved, b"todo: write docs, ship it\n- add tests\n");

term.send("exit\r");
assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(0));
println!("ok");
}
7 changes: 6 additions & 1 deletion crates/bashkit/src/builtins/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ impl Builtin for Less {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: less [FILE]...\nView file contents (pager).\n\nIn bashkit, less behaves like cat (no interactive paging).\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
"Usage: less [FILE]...\nView file contents (pager).\n\nInside a bashkit::terminal::Terminal session, less pages interactively\n(q quit, space/b page, j/k line, g/G ends, /pattern n N search, -F).\nEverywhere else it behaves like cat.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("less (bashkit) 0.1"),
) {
return Ok(r);
}
// Interactive only with a terminal device; plain exec() stays cat-like.
#[cfg(feature = "terminal")]
if let Some(tty) = super::pager::terminal(&ctx)? {
return super::pager::page(&ctx, &tty, super::pager::Pager::Less).await;
}
// less without args reads from stdin
if ctx.args.is_empty() {
if let Some(stdin) = ctx.stdin {
Expand Down
8 changes: 8 additions & 0 deletions crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ mod mkfifo;
mod navigation;
mod nl;
mod numfmt;
#[cfg(feature = "terminal")]
mod pager;
mod parallel;
mod paste;
mod patch;
Expand Down Expand Up @@ -108,6 +110,8 @@ mod tree;
mod truncate;
mod vars;
mod verify;
#[cfg(feature = "terminal")]
mod vi;
mod wait;
mod wc;
mod yes;
Expand Down Expand Up @@ -204,6 +208,8 @@ pub use semver::Semver;
pub use seq::Seq;
pub use shuf::Shuf;

#[cfg(feature = "terminal")]
pub use pager::More;
pub use sleep::Sleep;
pub use sortuniq::{Sort, Uniq};
pub use source::Source;
Expand All @@ -220,6 +226,8 @@ pub use tree::Tree;
pub use truncate::Truncate;
pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
pub use verify::Verify;
#[cfg(feature = "terminal")]
pub use vi::Vi;
pub use wait::Wait;
pub use wc::Wc;
pub use yes::Yes;
Expand Down
Loading
Loading