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
8 changes: 4 additions & 4 deletions contracts/capability-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
},
"napi": {
"label": "NAPI JavaScript",
"supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"],
"supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite", "interactive_terminal"],
"evidence": {
"execution_limits": ["crates/bashkit-js/__test__/security.spec.ts#WB: command limit is enforced"],
"cancellation": ["crates/bashkit-js/__test__/basic.spec.ts#Bash: cancel works after reset"],
Expand All @@ -181,14 +181,14 @@
"mounts": ["crates/bashkit-js/__test__/vfs.spec.ts#real filesystem requires and enforces allowedMountPaths"],
"snapshots": ["crates/bashkit-js/__test__/snapshot-history.spec.ts#commit and checkout round-trip shell state and files"],
"runtime_python": ["crates/bashkit-js/__test__/integration.spec.ts#integration: python opt-in executes embedded Python"],
"runtime_sqlite": ["crates/bashkit-js/__test__/runtime-compat/sqlite.test.mjs#sqlite"]
"runtime_sqlite": ["crates/bashkit-js/__test__/runtime-compat/sqlite.test.mjs#sqlite"],
"interactive_terminal": ["crates/bashkit-js/__test__/terminal.spec.ts#terminal: call edits a file in vi"]
},
"unsupported": {
"stdin": "execute() accepts a command string and output callback, not top-level host stdin.",
"host_call_suspension": "NAPI custom builtins await callbacks directly; no request and resume handle is exposed.",
"transport_hooks": "NAPI exposes network policy and credentials but no JavaScript HttpTransport hook.",
"runtime_typescript": "The NAPI package does not compile or expose the TypeScript runtime.",
"interactive_terminal": "Not exposed in the NAPI package yet."
"runtime_typescript": "The NAPI package does not compile or expose the TypeScript runtime."
}
},
"browser_wasm": {
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-js/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ repository.workspace = true
crate-type = ["cdylib"]

[dependencies]
bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite"] }
bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite", "terminal"] }
bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] }
napi = { workspace = true }
napi-derive = { workspace = true }
Expand Down
112 changes: 112 additions & 0 deletions crates/bashkit-js/__test__/terminal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import test from "ava";
import { Bash, Terminal } from "../wrapper.js";

// ============================================================================
// Terminal: interactive in-process terminal (vi, less, persistent shell)
// ============================================================================

test("terminal: low-level loop runs commands and reads screen", async (t) => {
const term = new Terminal({ rows: 10, cols: 40 });
t.is(await term.runUntilIdle(), "idle");
term.send("echo hello\r");
t.is(await term.runUntilIdle(), "idle");
t.is(term.screenText(), "$ echo hello\nhello\n$");
t.is(term.activity().state, "prompt");
const [record] = term.takeTranscript();
t.deepEqual(record, {
command: "echo hello",
output: "hello\n",
exitCode: 0,
outputTruncated: false,
});
});

test("terminal: call edits a file in vi", async (t) => {
const term = new Terminal();
let out = await term.call("vi /tmp/n.txt<Enter>");
t.is(out.activity, "running");
t.is(out.running_command, "vi /tmp/n.txt");
t.true(out.full_screen);
out = await term.call("ihello<Esc>:wq<Enter>");
t.is(out.activity, "prompt");
t.is(out.commands[0].exit_code, 0);
t.is((await term.readFile("/tmp/n.txt")).toString(), "hello\n");
});

test("terminal: history keeps scrolled-off output", async (t) => {
const term = new Terminal({ rows: 5, cols: 30 });
await term.call("seq 1 40<Enter>");
t.true(term.historyText().includes("\n1\n2\n"));
t.false(term.screenText().includes("\n1\n"));
});

test("terminal: timeout reports running command and Ctrl-C interrupts", async (t) => {
const term = new Terminal();
term.send("sleep 0.5; echo done\r");
t.is(await term.runUntilIdle(30), "timeout");
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.
term.send("\x03");
t.is(await term.runUntilIdle(5000), "idle");
const [record] = term.takeTranscript();
t.is(record.exitCode, 130);
t.false(record.output.includes("done"));
});

test("terminal: exit and exitCode", async (t) => {
const term = new Terminal();
const out = await term.call("exit 3<Enter>");
t.is(out.activity, "exited");
t.is(out.exit_code, 3);
t.is(term.exitCode, 3);
t.is(await term.runUntilIdle(), "exited");
});

test("terminal: options apply to the session", async (t) => {
const term = new Terminal({
username: "ada",
cwd: "/tmp",
env: { GREETING: "hi" },
});
const out = await term.call("echo $GREETING $(whoami) $PWD<Enter>");
t.is(out.commands[0].output, "hi ada /tmp\n");
});

test("terminal: send accepts Buffer and raw output drains", async (t) => {
const term = new Terminal();
term.send(Buffer.from("printf 'a\\nb\\n'\r"));
await term.runUntilIdle();
t.true(term.takeOutput().toString().includes("a\r\nb\r\n"));
t.is(term.takeOutput().length, 0);
});

test("terminal: resize and size", async (t) => {
const term = new Terminal({ rows: 24, cols: 80 });
term.resize(30, 100);
t.deepEqual(term.size(), [30, 100]);
const out = await term.call("echo $COLUMNS $LINES<Enter>");
t.is(out.commands[0].output, "100 30\n");
});

test("terminal: tool metadata", (t) => {
const term = new Terminal();
const def = term.toolDefinition();
t.is(def.function.name, "terminal");
t.truthy(def.function.parameters.properties.input);
t.true(term.systemPrompt().startsWith("terminal:"));
});

test("terminal: oversized input is rejected", async (t) => {
const term = new Terminal();
await t.throwsAsync(() => term.call("a".repeat(64 * 1024 + 1)));
});

test("terminal: pagers stay non-interactive outside the terminal", (t) => {
const bash = new Bash();
const result = bash.executeSync("seq 1 3 | less; vi /tmp/x");
t.is(result.stdout, "1\n2\n3\n");
t.is(result.exitCode, 1);
});
2 changes: 2 additions & 0 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
//! work. This prevents CodeQL `rust/access-invalid-pointer` alerts caused by
//! holding a raw-pointer-derived `&self` across `block_on` or `.await` points.

mod terminal;

use bashkit::interop::fs::{
BashkitFsAbiHandleV1, BashkitFsAbiOwnedHandleV1, export_filesystem, import_filesystem,
};
Expand Down
Loading
Loading