From baadf11b86385e13386dcf7613ec43f730644431 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 22:01:57 +0000 Subject: [PATCH 1/2] refactor(scripted-tool)!: move ToolDef layer into bashkit-scripted-tool crate Step 2 of extracting ScriptedTool from core. - New published crate crates/bashkit-scripted-tool holds ToolDef/ToolArgs/ ToolImpl, ToolDefExtension, ToolRegistry, ScriptedTool, ScriptingToolSet and the logic-only shell, built only on core public APIs - Remove the core `scripted_tool` feature; BashBuilder::tool_registry becomes ToolRegistry::install(builder) - Core: public `bashkit::time` shim and BashBuilder::execution_profile() - Bindings and eval depend on the new crate; CI, publish.yml and release-check cover it - Docs: migration guide docs/migrating-scripted-tool.md; knowledge updated Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- .github/workflows/ci.yml | 11 +- .github/workflows/publish.yml | 29 +++- Cargo.lock | 19 +++ README.md | 9 +- contracts/capability-parity.json | 12 +- crates/bashkit-cli/Cargo.toml | 1 - crates/bashkit-eval/Cargo.toml | 3 +- crates/bashkit-eval/src/mira_study.rs | 2 +- crates/bashkit-eval/src/scripting_agent.rs | 4 +- crates/bashkit-js/Cargo.toml | 3 +- crates/bashkit-js/src/lib.rs | 4 +- crates/bashkit-python/Cargo.toml | 6 +- crates/bashkit-python/src/lib.rs | 6 +- crates/bashkit-scripted-tool/Cargo.toml | 56 ++++++++ crates/bashkit-scripted-tool/README.md | 34 +++++ .../examples/scripted_tool.rs | 7 +- .../examples/scripted_tool_clap_builtin.rs | 5 +- crates/bashkit-scripted-tool/src/lib.rs | 53 +++++++ .../src/scripted_tool/execute.rs | 50 +++---- .../src/scripted_tool/extension.rs | 8 +- .../src/scripted_tool/logic_only.rs | 6 +- .../src/scripted_tool/mod.rs | 44 +++--- .../src/scripted_tool/toolset.rs | 21 +-- .../src/tool_def.rs | 68 ++++----- .../src/tool_registry.rs | 132 ++++++++++++------ .../tests/request_lifecycle.rs | 57 ++++++++ .../tests}/tool_registry_tests.rs | 25 ++-- crates/bashkit-wasm/Cargo.toml | 5 +- crates/bashkit/Cargo.toml | 10 +- crates/bashkit/docs/python.md | 10 +- crates/bashkit/docs/threat-model.md | 2 +- crates/bashkit/docs/typescript.md | 3 +- crates/bashkit/src/lib.rs | 87 +++--------- crates/bashkit/src/time_compat/mod.rs | 17 +-- crates/bashkit/tests/integration/main.rs | 2 - .../request_lifecycle_contract_tests.rs | 65 +-------- docs/cli.md | 1 - docs/migrating-scripted-tool.md | 92 ++++++++++++ docs/scripted-tools.md | 19 ++- docs/start-rust.md | 2 +- justfile | 17 +++ knowledge/foundations/architecture.md | 4 +- .../scripted-tool-orchestration.md | 18 +-- knowledge/integrations/tool-contract.md | 2 +- knowledge/log.md | 1 + knowledge/operations/dependencies.md | 3 +- knowledge/operations/release-process.md | 18 +-- knowledge/runtimes/browser-package.md | 6 +- knowledge/runtimes/emscripten-wheels.md | 6 +- knowledge/runtimes/python-builtin.md | 2 +- knowledge/runtimes/zapcode-runtime.md | 2 +- knowledge/security/threat-model.md | 6 +- site/src/pages/docs/_meta.ts | 10 ++ skills/bashkit/references/rust.md | 2 +- 54 files changed, 702 insertions(+), 385 deletions(-) create mode 100644 crates/bashkit-scripted-tool/Cargo.toml create mode 100644 crates/bashkit-scripted-tool/README.md rename crates/{bashkit => bashkit-scripted-tool}/examples/scripted_tool.rs (98%) rename crates/{bashkit => bashkit-scripted-tool}/examples/scripted_tool_clap_builtin.rs (95%) create mode 100644 crates/bashkit-scripted-tool/src/lib.rs rename crates/{bashkit => bashkit-scripted-tool}/src/scripted_tool/execute.rs (96%) rename crates/{bashkit => bashkit-scripted-tool}/src/scripted_tool/extension.rs (99%) rename crates/{bashkit => bashkit-scripted-tool}/src/scripted_tool/logic_only.rs (96%) rename crates/{bashkit => bashkit-scripted-tool}/src/scripted_tool/mod.rs (97%) rename crates/{bashkit => bashkit-scripted-tool}/src/scripted_tool/toolset.rs (98%) rename crates/{bashkit => bashkit-scripted-tool}/src/tool_def.rs (95%) rename crates/{bashkit => bashkit-scripted-tool}/src/tool_registry.rs (85%) create mode 100644 crates/bashkit-scripted-tool/tests/request_lifecycle.rs rename crates/{bashkit/tests/integration => bashkit-scripted-tool/tests}/tool_registry_tests.rs (93%) create mode 100644 docs/migrating-scripted-tool.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4184cbbc7..c7c17ddd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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,terminal + run: cargo check -p bashkit -p bashkit-scripted-tool --target wasm32-unknown-unknown --features bashkit/jq,bashkit/wasm_js,bashkit/terminal env: # getrandom needs an explicit JS backend opt-in on this target. RUSTFLAGS: --cfg getrandom_backend="wasm_js" -D warnings @@ -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,terminal + run: cargo check -p bashkit -p bashkit-scripted-tool --target wasm32-wasip2 --features bashkit/jq,bashkit/terminal env: RUSTFLAGS: -D warnings @@ -277,6 +277,11 @@ jobs: - name: Run tests run: cargo test --workspace --lib --bins --tests --features http_client,ssh,sqlite + # The registry's Python/TypeScript surfaces sit behind the crate's own + # features, which no workspace member turns on together. + - name: Run scripted tool tests (shell, Python, TypeScript surfaces) + run: cargo test -p bashkit-scripted-tool --features python,typescript,jq + # All three differential gates (bash, sed, grep), not just bash: each is # the only thing checking that its spec expectations still describe the # real tool. `spec_tests::` + `--ignored` selects exactly the gates — @@ -411,7 +416,7 @@ jobs: cargo run --example custom_fs cargo run --example clap_builtin cargo run --example clap_builtin_subcommands - cargo run --example scripted_tool_clap_builtin --features scripted_tool + cargo run -p bashkit-scripted-tool --example scripted_tool_clap_builtin cargo run --example resource_limits cargo run --example text_processing cargo run --example live_mounts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2ba4ee533..6b727f26a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,13 +92,38 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # Publish bashkit-scripted-tool (depends on bashkit) + publish-bashkit-scripted-tool: + name: Publish bashkit-scripted-tool + runs-on: ubuntu-latest + needs: publish-bashkit + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + + - name: Wait for crates.io index update + run: sleep 30 + + - name: Verify bashkit-scripted-tool package without credentials + run: cargo publish --dry-run -p bashkit-scripted-tool + + - name: Publish bashkit-scripted-tool to crates.io + run: cargo publish --no-verify -p bashkit-scripted-tool + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # ============================================================================ # Verify all packages were published successfully # ============================================================================ verify-publish: name: Verify published versions runs-on: ubuntu-latest - needs: [publish-bashkit, publish-bashkit-cli] + needs: [publish-bashkit, publish-bashkit-cli, publish-bashkit-scripted-tool] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -111,4 +136,4 @@ jobs: run: | EXPECTED=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') echo "Expected version: $EXPECTED" - python3 scripts/verify_crates_publish.py --expected "$EXPECTED" bashkit bashkit-cli + python3 scripts/verify_crates_publish.py --expected "$EXPECTED" bashkit bashkit-cli bashkit-scripted-tool diff --git a/Cargo.lock b/Cargo.lock index 540b6a9d9..400765f37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -471,6 +471,7 @@ dependencies = [ "anyhow", "async-trait", "bashkit", + "bashkit-scripted-tool", "chrono", "mira-eval", "regex", @@ -486,6 +487,7 @@ name = "bashkit-js" version = "0.18.2" dependencies = [ "bashkit", + "bashkit-scripted-tool", "napi", "napi-build", "napi-derive", @@ -498,6 +500,7 @@ name = "bashkit-python" version = "0.18.2" dependencies = [ "bashkit", + "bashkit-scripted-tool", "num-bigint 0.4.8", "pyo3", "pyo3-async-runtimes", @@ -505,6 +508,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "bashkit-scripted-tool" +version = "0.18.2" +dependencies = [ + "anyhow", + "async-trait", + "bashkit", + "clap", + "serde", + "serde_json", + "tokio", + "tokio-test", + "tower", + "tracing", +] + [[package]] name = "bashkit-wasm" version = "0.18.2" diff --git a/README.md b/README.md index 130cefacf..0d9767e47 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Homepage: [bashkit.sh](https://bashkit.sh) - **LLM tool contract** - `BashTool` with discovery metadata, streaming output, and system prompts - **Script analysis** - Inspect commands, arguments, and file writes *before* running, to drive permission prompts ([guide](docs/script-analysis.md)) - **Snapshotting** - Serialize shell state and VFS contents for checkpoint/resume workflows -- **Scripted tool orchestration** - Compose ToolDef+callback pairs into multi-tool bash scripts (`scripted_tool` feature) +- **Scripted tool orchestration** - Compose ToolDef+callback pairs into multi-tool bash scripts ([`bashkit-scripted-tool`](crates/bashkit-scripted-tool) crate) - **Async-first** - Built on tokio - **Language bindings** - Python (PyO3) and JavaScript/TypeScript (NAPI-RS) for Node.js, Bun, and Deno - **Experimental: Git support** - Virtual git operations on the virtual filesystem (`git` feature) @@ -45,7 +45,12 @@ cargo add bashkit --features python # Embedded Python interpreter cargo add bashkit --features typescript # Embedded TypeScript interpreter cargo add bashkit --features sqlite # Embedded SQLite engine (Turso) cargo add bashkit --features realfs # Real filesystem backend -cargo add bashkit --features scripted_tool # Tool orchestration framework +``` + +Tool orchestration (`ScriptedTool`, `ToolDef`, `ToolRegistry`) is a separate crate: + +```bash +cargo add bashkit-scripted-tool ``` ## Agent Development diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index d9adcd722..12a8318a3 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -80,12 +80,12 @@ "label": "Rust ScriptedTool", "supported": ["execution_limits", "deadlines", "output_caps", "env", "stdin", "tool_callbacks"], "evidence": { - "execution_limits": ["crates/bashkit/src/scripted_tool/execute.rs#test_scripted_tool_forwards_limits_and_output_caps"], - "deadlines": ["crates/bashkit/src/scripted_tool/execute.rs#test_scripted_tool_execute_honors_timeout_ms"], - "output_caps": ["crates/bashkit/src/scripted_tool/execute.rs#test_scripted_tool_forwards_limits_and_output_caps"], - "env": ["crates/bashkit/src/scripted_tool/mod.rs#test_execute_with_env"], - "stdin": ["crates/bashkit/src/scripted_tool/mod.rs#test_execute_stdin_pipe"], - "tool_callbacks": ["crates/bashkit/src/scripted_tool/mod.rs#test_execute_single_tool"] + "execution_limits": ["crates/bashkit-scripted-tool/src/scripted_tool/execute.rs#test_scripted_tool_forwards_limits_and_output_caps"], + "deadlines": ["crates/bashkit-scripted-tool/src/scripted_tool/execute.rs#test_scripted_tool_execute_honors_timeout_ms"], + "output_caps": ["crates/bashkit-scripted-tool/src/scripted_tool/execute.rs#test_scripted_tool_forwards_limits_and_output_caps"], + "env": ["crates/bashkit-scripted-tool/src/scripted_tool/mod.rs#test_execute_with_env"], + "stdin": ["crates/bashkit-scripted-tool/src/scripted_tool/mod.rs#test_execute_stdin_pipe"], + "tool_callbacks": ["crates/bashkit-scripted-tool/src/scripted_tool/mod.rs#test_execute_single_tool"] }, "unsupported": { "runtime_env": "Each execution uses a fresh logic-only shell; env is configured before the run.", diff --git a/crates/bashkit-cli/Cargo.toml b/crates/bashkit-cli/Cargo.toml index 89fbf8220..5077506dd 100644 --- a/crates/bashkit-cli/Cargo.toml +++ b/crates/bashkit-cli/Cargo.toml @@ -27,7 +27,6 @@ default = ["python", "sqlite", "interactive"] python = ["bashkit/python"] sqlite = ["bashkit/sqlite"] realfs = ["bashkit/realfs"] -scripted_tool = ["bashkit/scripted_tool"] interactive = ["dep:rustyline", "dep:terminal_size", "dep:signal-hook"] [dependencies] diff --git a/crates/bashkit-eval/Cargo.toml b/crates/bashkit-eval/Cargo.toml index 911997120..f531425fc 100644 --- a/crates/bashkit-eval/Cargo.toml +++ b/crates/bashkit-eval/Cargo.toml @@ -24,7 +24,8 @@ path = "src/main.rs" # (our own agent loop + provider stack) so we do NOT need mira-everruns. mira-eval = { version = "0.5", features = ["macros"] } -bashkit = { path = "../bashkit", features = ["scripted_tool", "jq"] } +bashkit = { path = "../bashkit", features = ["jq"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["jq"] } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "fs"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/bashkit-eval/src/mira_study.rs b/crates/bashkit-eval/src/mira_study.rs index 3c54745ff..058e3958d 100644 --- a/crates/bashkit-eval/src/mira_study.rs +++ b/crates/bashkit-eval/src/mira_study.rs @@ -270,7 +270,7 @@ fn scripting_subject() -> impl Subject { } fn scripting_transcript(trace: ScriptingTrace) -> Transcript { - use bashkit::ScriptedCommandKind; + use bashkit_scripted_tool::ScriptedCommandKind; let tool_outputs: Vec = trace .tool_calls diff --git a/crates/bashkit-eval/src/scripting_agent.rs b/crates/bashkit-eval/src/scripting_agent.rs index b95bc3622..bc7b5d705 100644 --- a/crates/bashkit-eval/src/scripting_agent.rs +++ b/crates/bashkit-eval/src/scripting_agent.rs @@ -7,9 +7,9 @@ // one at a time via individual tool_use blocks. use anyhow::{Context, Result}; -use bashkit::{ +use bashkit::ToolRequest; +use bashkit_scripted_tool::{ ScriptedCommandInvocation, ScriptedCommandKind, ScriptingToolSet, ToolArgs, ToolDef, - ToolRequest, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/crates/bashkit-js/Cargo.toml b/crates/bashkit-js/Cargo.toml index 206adcd62..6eb1e6bec 100644 --- a/crates/bashkit-js/Cargo.toml +++ b/crates/bashkit-js/Cargo.toml @@ -14,7 +14,8 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } +bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } napi = { workspace = true } napi-derive = { workspace = true } serde_json = { workspace = true } diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index 0ffb09333..cd0fded65 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -22,14 +22,14 @@ use bashkit::{ Credential, ExecResult as RustExecResult, ExecutionLimits, ExtFunctionResult, FileSystem as BashFileSystem, FileType, InMemoryFs, Metadata, MontyObject, NetworkAllowlist, OutputCallback, PosixFs, PythonExternalFnHandler, ReadOnlyFs, RealFs, RealFsMode, - ScriptedTool as RustScriptedTool, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, - ToolDef, ToolRequest, async_trait, + SnapshotOptions as RustSnapshotOptions, Tool, ToolRequest, async_trait, }; use bashkit::{ CapabilityFingerprint as RustCapabilityFingerprint, CheckoutPolicy as RustCheckoutPolicy, CommitOptions as RustCommitOptions, ObjectId as RustObjectId, SnapshotGraph as RustSnapshotGraph, }; +use bashkit_scripted_tool::{ScriptedTool as RustScriptedTool, ToolArgs, ToolDef}; use napi::bindgen_prelude::External; use napi::{Env, JsValue, Unknown, ValueType, sys}; use napi_derive::napi; diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml index 751cb1297..cfce420f1 100644 --- a/crates/bashkit-python/Cargo.toml +++ b/crates/bashkit-python/Cargo.toml @@ -35,7 +35,8 @@ num-bigint = "^0.4.6" # none of which compile on wasm32-unknown-emscripten — see the wasm block # below and knowledge/runtimes/emscripten-wheels.md. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } +bashkit = { path = "../bashkit", features = ["python", "realfs", "jq", "interop", "http_client", "sqlite"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } tokio = { workspace = true, features = ["rt-multi-thread"] } # pyo3-async-runtimes hard-pulls tokio `rt-multi-thread` + `net` (mio), which # `compile_error!`s on wasm. Native-only; the wasm build exposes `execute_sync` @@ -49,5 +50,6 @@ pyo3-async-runtimes = { workspace = true } # wasm-safe base features (sync, macros, io-util, rt, time) — current-thread # runtime only. See knowledge/runtimes/emscripten-wheels.md. [target.'cfg(target_arch = "wasm32")'.dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "jq"] } +bashkit = { path = "../bashkit", features = ["python", "jq"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python", "jq"] } tokio = { workspace = true } diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 58c9b7fd0..f0f1bd9ba 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -26,10 +26,10 @@ use bashkit::{ ExecutionLimits, ExtFunctionResult, FileSystem, FileSystemExt, FileType as FsFileType, FsLimits, InMemoryFs, Metadata as FsMetadata, MontyException, MontyObject, NetworkAllowlist, OutputCallback as RustOutputCallback, OverlayFs, PosixFs, PythonExternalFnHandler, - PythonLimits, ReadOnlyFs, ScriptedTool as RustScriptedTool, - ShellStateView as RustShellStateView, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, - ToolDef, ToolRequest, async_trait, + PythonLimits, ReadOnlyFs, ShellStateView as RustShellStateView, + SnapshotOptions as RustSnapshotOptions, Tool, ToolRequest, async_trait, }; +use bashkit_scripted_tool::{ScriptedTool as RustScriptedTool, ToolArgs, ToolDef}; /// Typed named execution-policy selector for Python constructors. #[pyclass(name = "ExecutionProfile", eq, eq_int, from_py_object)] diff --git a/crates/bashkit-scripted-tool/Cargo.toml b/crates/bashkit-scripted-tool/Cargo.toml new file mode 100644 index 000000000..58b5f19d2 --- /dev/null +++ b/crates/bashkit-scripted-tool/Cargo.toml @@ -0,0 +1,56 @@ +# ScriptedTool and the ToolDef layer, split out of bashkit core. +# +# Important decision: everything built on `ToolDef` lives here, not in +# `bashkit`. Core only offers generic public extension points +# (`RuntimeCallContext`, `ShellFeatures`, `builtin_filter`, Tool contract +# helpers); this crate composes them. Released in lockstep with `bashkit`. +# See knowledge/integrations/scripted-tool-orchestration.md. + +[package] +name = "bashkit-scripted-tool" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "ScriptedTool for bashkit: compose ToolDef callbacks into one LLM tool driven by bash scripts" +keywords = ["bash", "llm", "tool", "agent", "everruns"] +categories.workspace = true +readme = "README.md" + +[features] +default = [] +# Expose registry tools to embedded Python (`python`/`python3` builtins). +python = ["bashkit/python"] +# Expose registry tools to embedded TypeScript (`ts`/`node` builtins). +typescript = ["bashkit/typescript"] +# Admit the optional `jq` builtin in the logic-only shell. +jq = ["bashkit/jq"] +# Debug logs for sanitized callback errors. +tracing = ["dep:tracing"] + +[dependencies] +bashkit = { path = "../bashkit", version = "0.18.2", default-features = false, features = ["bash_tool"] } +tokio = { workspace = true } +async-trait = { workspace = true } +tower = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true, optional = true } + +[dev-dependencies] +bashkit = { path = "../bashkit", features = ["bash_tool", "jq"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } +clap = { workspace = true } +anyhow = { workspace = true } +tokio-test = { workspace = true } + +[[example]] +name = "scripted_tool" + +[[example]] +name = "scripted_tool_clap_builtin" + +[[test]] +name = "tool_registry_tests" +required-features = ["python", "typescript"] diff --git a/crates/bashkit-scripted-tool/README.md b/crates/bashkit-scripted-tool/README.md new file mode 100644 index 000000000..f3f413b5c --- /dev/null +++ b/crates/bashkit-scripted-tool/README.md @@ -0,0 +1,34 @@ +# bashkit-scripted-tool + +ScriptedTool for [bashkit](https://crates.io/crates/bashkit): register +`ToolDef` + callback pairs and expose them to an LLM as one tool that accepts a +bash script. Each registered tool becomes a command in a logic-only shell (no +filesystem, no script execution), so a model can chain many calls in one turn +with pipes, variables, loops and `jq`. + +```toml +[dependencies] +bashkit = "0.18" +bashkit-scripted-tool = { version = "0.18", features = ["jq"] } +``` + +```rust,ignore +use bashkit::Tool; +use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; + +let tool = ScriptedTool::builder("api") + .tool_fn(ToolDef::new("greet", "Greet a user"), |args: &ToolArgs| { + Ok(format!("hello {}\n", args.param_str("name").unwrap_or("world"))) + }) + .build(); +``` + +Features: + +- `jq`: admit the `jq` builtin in the logic-only shell. +- `python`, `typescript`: expose a `ToolRegistry` to embedded Python and + TypeScript as host functions (`ToolRegistry::install`). +- `tracing`: debug logs for sanitized callback errors. + +This crate used to be the `scripted_tool` feature of `bashkit`. See the +[migration guide](https://github.com/everruns/bashkit/blob/main/docs/migrating-scripted-tool.md). diff --git a/crates/bashkit/examples/scripted_tool.rs b/crates/bashkit-scripted-tool/examples/scripted_tool.rs similarity index 98% rename from crates/bashkit/examples/scripted_tool.rs rename to crates/bashkit-scripted-tool/examples/scripted_tool.rs index 290cee710..4d8436a83 100644 --- a/crates/bashkit/examples/scripted_tool.rs +++ b/crates/bashkit-scripted-tool/examples/scripted_tool.rs @@ -3,12 +3,13 @@ //! Demonstrates composing multiple API-like tools (ToolDef + closures) into a //! single ScriptedTool that an LLM agent can call with bash scripts. //! -//! Run with: cargo run --example scripted_tool --features scripted_tool +//! Run with: cargo run -p bashkit-scripted-tool --example scripted_tool //! //! This example simulates an e-commerce API with tools for users, orders, and //! inventory. The ScriptedTool lets an agent compose these in one call. -use bashkit::{ScriptedTool, Tool}; +use bashkit::Tool; +use bashkit_scripted_tool::ScriptedTool; async fn run_script(tool: &ScriptedTool, commands: &str) -> anyhow::Result { let output = tool @@ -140,7 +141,7 @@ async fn main() -> anyhow::Result<()> { // ScriptedTool builder API and the demo scenarios. // --------------------------------------------------------------------------- mod fakes { - use bashkit::{ToolArgs, ToolDef}; + use bashkit_scripted_tool::{ToolArgs, ToolDef}; // -- get_user -------------------------------------------------------- diff --git a/crates/bashkit/examples/scripted_tool_clap_builtin.rs b/crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs similarity index 95% rename from crates/bashkit/examples/scripted_tool_clap_builtin.rs rename to crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs index bd7d11d49..ff5babcac 100644 --- a/crates/bashkit/examples/scripted_tool_clap_builtin.rs +++ b/crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs @@ -1,7 +1,7 @@ //! Clap-backed builtin inside a ScriptedTool. //! //! Run with: -//! cargo run --example scripted_tool_clap_builtin --features scripted_tool +//! cargo run -p bashkit-scripted-tool --example scripted_tool_clap_builtin //! //! `ToolDef` + `parse_flags` covers `--key value` argument shapes. A control-plane //! command tree that wants positionals, short flags, `--`, or required-argument @@ -11,7 +11,8 @@ use async_trait::async_trait; use bashkit::clap::Parser; -use bashkit::{BashkitContext, ClapBuiltin, ScriptedTool, Tool, ToolArgs, ToolDef}; +use bashkit::{BashkitContext, ClapBuiltin, Tool}; +use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; #[derive(Parser)] #[command(name = "get_agent", about = "Fetch one agent by id")] diff --git a/crates/bashkit-scripted-tool/src/lib.rs b/crates/bashkit-scripted-tool/src/lib.rs new file mode 100644 index 000000000..ead3a0b76 --- /dev/null +++ b/crates/bashkit-scripted-tool/src/lib.rs @@ -0,0 +1,53 @@ +//! ScriptedTool and the `ToolDef` layer for [bashkit](https://docs.rs/bashkit). +//! +//! Compose [`ToolDef`] + callback pairs into one LLM [`Tool`](bashkit::Tool) +//! that accepts a bash script. Each registered tool becomes a command inside a +//! logic-only shell, so a model can orchestrate many calls in one turn with +//! pipes, variables, loops and `jq`. +//! +//! - [`ScriptedTool`]: the LLM tool. Build with [`ScriptedTool::builder`]. +//! - [`ScriptingToolSet`]: ScriptedTool plus optional discovery tool. +//! - [`ToolRegistry`]: one set of tool definitions shared by the shell, +//! embedded Python and embedded TypeScript. Install into any +//! [`BashBuilder`](bashkit::BashBuilder) with [`ToolRegistry::install`]. +//! - [`ToolDefExtension`]: the shell surface alone, as a bashkit +//! [`Extension`](bashkit::Extension). +//! +//! ```rust +//! use bashkit::Tool; +//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; +//! +//! # tokio_test::block_on(async { +//! let tool = ScriptedTool::builder("api") +//! .tool_fn( +//! ToolDef::new("greet", "Greet a user"), +//! |args: &ToolArgs| Ok(format!("hello {}\n", args.param_str("name").unwrap_or("world"))), +//! ) +//! .build(); +//! let output = tool +//! .execution(serde_json::json!({"commands": "greet --name Alice"})) +//! .unwrap() +//! .execute() +//! .await +//! .unwrap(); +//! assert_eq!(output.result["stdout"], "hello Alice\n"); +//! # }); +//! ``` +//! +//! Moved out of the `bashkit` crate (formerly feature `scripted_tool`). See +//! `docs/migrating-scripted-tool.md` in the repository for the migration. + +mod scripted_tool; +mod tool_def; +mod tool_registry; + +pub use scripted_tool::{ + AsyncToolCallback, AsyncToolExec, CallbackKind, DiscoverTool, DiscoveryMode, + ScriptedCommandInvocation, ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, + ScriptedToolBuilder, ScriptingToolSet, ScriptingToolSetBuilder, SyncToolExec, ToolArgs, + ToolCallback, ToolDef, ToolDefExtension, ToolDefExtensionBuilder, ToolDefInvocationTrace, + ToolImpl, +}; +pub use tool_registry::{ + ToolCall, ToolCallDecision, ToolCallRequest, ToolCallSurface, ToolRegistry, ToolRegistryBuilder, +}; diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit-scripted-tool/src/scripted_tool/execute.rs similarity index 96% rename from crates/bashkit/src/scripted_tool/execute.rs rename to crates/bashkit-scripted-tool/src/scripted_tool/execute.rs index 1c74f4269..519879df4 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit-scripted-tool/src/scripted_tool/execute.rs @@ -1,17 +1,17 @@ //! ScriptedTool execution: Tool impl and documentation helpers. use super::{ScriptedExecutionTrace, ScriptedTool, ToolDefExtension, extension::InvocationLog}; -use crate::Bash; -use crate::builtins::{Builtin, Context, ExecutionPlan}; -use crate::tool::timeout_response; -use crate::tool::{ +use crate::tool_def::usage_from_schema; +use async_trait::async_trait; +use bashkit::Bash; +use bashkit::tool::timeout_response; +use bashkit::tool::{ Tool, ToolError, ToolExecution, ToolOutputChunk, ToolRequest, ToolResponse, ToolStatus, VERSION, localized, tool_output_from_response, tool_request_from_value, tool_request_schema, tool_response_schema, }; -use crate::tool_def::usage_from_schema; -use crate::{Error, ExecResult}; -use async_trait::async_trait; +use bashkit::{Builtin, BuiltinContext as Context, ExecutionPlan}; +use bashkit::{Error, ExecResult}; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -27,7 +27,7 @@ struct ScriptedBuiltin { } impl ScriptedBuiltin { - fn sanitize(&self, result: crate::Result) -> crate::Result { + fn sanitize(&self, result: bashkit::Result) -> bashkit::Result { match result { Err(err) if self.sanitize_errors @@ -42,12 +42,12 @@ impl ScriptedBuiltin { #[async_trait] impl Builtin for ScriptedBuiltin { - async fn execute(&self, ctx: Context<'_>) -> crate::Result { + async fn execute(&self, ctx: Context<'_>) -> bashkit::Result { let result = self.inner.execute(ctx).await; self.sanitize(result) } - async fn execution_plan(&self, ctx: &Context<'_>) -> crate::Result> { + async fn execution_plan(&self, ctx: &Context<'_>) -> bashkit::Result> { let result = self.inner.execution_plan(ctx).await; self.sanitize(result) } @@ -188,7 +188,8 @@ impl ScriptedTool { let fut = async { let result = if let Some(sender) = stream_sender { let output_cb = Box::new( - move |stdout_chunk: &crate::StreamData, stderr_chunk: &crate::StreamData| { + move |stdout_chunk: &bashkit::StreamData, + stderr_chunk: &bashkit::StreamData| { if !stdout_chunk.is_empty() { let _ = sender.send(ToolOutputChunk { data: serde_json::json!(stdout_chunk), @@ -224,7 +225,7 @@ impl ScriptedTool { // timeouts must abort the whole orchestration, including callbacks. let response = if let Some(ms) = timeout_ms { let duration = Duration::from_millis(ms); - match crate::time_compat::timeout(duration, fut).await { + match bashkit::time::timeout(duration, fut).await { Ok(response) => response, Err(_) => timeout_response(duration), } @@ -293,7 +294,7 @@ impl Tool for ScriptedTool { let req = tool_request_from_value(self.locale(), args)?; let tool = self.clone(); Ok(ToolExecution::new(move |stream_sender| async move { - let start = crate::time_compat::Instant::now(); + let start = bashkit::time::Instant::now(); let response = tool.run_request_with_stream(req, stream_sender).await; tool_output_from_response(response, start.elapsed()) })) @@ -333,10 +334,10 @@ impl Tool for ScriptedTool { #[cfg(test)] mod tests { use super::*; - use crate::ExecutionLimits; use crate::ToolArgs; use crate::ToolDef; use crate::tool_def::parse_flags; + use bashkit::ExecutionLimits; #[test] fn test_parse_flags_key_value() { @@ -468,7 +469,7 @@ mod tests { }, ) .build(); - let start = crate::time_compat::Instant::now(); + let start = bashkit::time::Instant::now(); let resp = tool .execute(ToolRequest { @@ -511,7 +512,7 @@ mod tests { }, ) .build(); - let start = crate::time_compat::Instant::now(); + let start = bashkit::time::Instant::now(); let resp = tool .execute_with_status( @@ -654,7 +655,7 @@ mod tests { async fn test_error_uses_display_not_debug() { use super::ScriptedTool; use crate::ToolDef; - use crate::tool::Tool; + use bashkit::tool::Tool; let tool = ScriptedTool::builder("test") .short_description("test") @@ -908,11 +909,10 @@ mod tests { #[cfg(test)] mod custom_builtin_tests { - use crate::builtins::{Builtin, Context}; - use crate::{ - BashkitContext, ClapBuiltin, Error, ExecResult, ScriptedTool, Tool, ToolArgs, ToolDef, - }; + use crate::{ScriptedTool, ToolArgs, ToolDef}; use async_trait::async_trait; + use bashkit::{BashkitContext, ClapBuiltin, Error, ExecResult, Tool}; + use bashkit::{Builtin, BuiltinContext as Context}; use clap::Parser; /// Echoes raw argv so tests can assert the builtin saw it unparsed. @@ -920,7 +920,7 @@ mod custom_builtin_tests { #[async_trait] impl Builtin for RawArgv { - async fn execute(&self, ctx: Context<'_>) -> crate::Result { + async fn execute(&self, ctx: Context<'_>) -> bashkit::Result { Ok(ExecResult::ok(format!("argv:[{}]\n", ctx.args.join("|")))) } } @@ -930,7 +930,7 @@ mod custom_builtin_tests { #[async_trait] impl Builtin for ReadsFile { - async fn execute(&self, ctx: Context<'_>) -> crate::Result { + async fn execute(&self, ctx: Context<'_>) -> bashkit::Result { match ctx.fs.read_file(std::path::Path::new("/etc/passwd")).await { Ok(_) => Ok(ExecResult::ok("read ok\n")), Err(e) => Ok(ExecResult::err(format!("denied: {e}\n"), 1)), @@ -942,7 +942,7 @@ mod custom_builtin_tests { #[async_trait] impl Builtin for LeakyBuiltin { - async fn execute(&self, _ctx: Context<'_>) -> crate::Result { + async fn execute(&self, _ctx: Context<'_>) -> bashkit::Result { Err(Error::Network( "postgres://admin:secret@internal-db/prod".into(), )) @@ -963,7 +963,7 @@ mod custom_builtin_tests { &self, _args: Self::Args, _ctx: &mut BashkitContext<'_>, - ) -> crate::Result<()> { + ) -> bashkit::Result<()> { Err(Error::Execution( "SDK diagnostic: postgres://admin:secret@internal-db/prod".into(), )) diff --git a/crates/bashkit/src/scripted_tool/extension.rs b/crates/bashkit-scripted-tool/src/scripted_tool/extension.rs similarity index 99% rename from crates/bashkit/src/scripted_tool/extension.rs rename to crates/bashkit-scripted-tool/src/scripted_tool/extension.rs index 30f610553..d1a60c0cd 100644 --- a/crates/bashkit/src/scripted_tool/extension.rs +++ b/crates/bashkit-scripted-tool/src/scripted_tool/extension.rs @@ -5,12 +5,12 @@ use super::{ CallbackKind, RegisteredTool, ScriptedCommandInvocation, ScriptedCommandKind, ToolArgs, ToolDef, ToolImpl, }; -use crate::builtins::{Builtin, Context, Extension}; -use crate::error::Result; -use crate::interpreter::ExecResult; use crate::tool_def::{parse_flags, usage_from_schema}; use crate::{ToolCallSurface, ToolRegistry}; use async_trait::async_trait; +use bashkit::ExecResult; +use bashkit::Result; +use bashkit::{Builtin, BuiltinContext as Context, Extension}; use std::collections::VecDeque; use std::future::Future; use std::sync::{Arc, Mutex}; @@ -454,7 +454,7 @@ impl Builtin for ToolBuiltinAdapter { if let Some(budget) = ctx.execution_budget() { budget .try_with(|budget| budget.check()) - .map_err(|_| crate::Error::Cancelled)??; + .map_err(|_| bashkit::Error::Cancelled)??; } ExecResult { stdout: output.stdout.into(), diff --git a/crates/bashkit/src/scripted_tool/logic_only.rs b/crates/bashkit-scripted-tool/src/scripted_tool/logic_only.rs similarity index 96% rename from crates/bashkit/src/scripted_tool/logic_only.rs rename to crates/bashkit-scripted-tool/src/scripted_tool/logic_only.rs index e466cfd39..e6bbe49ad 100644 --- a/crates/bashkit/src/scripted_tool/logic_only.rs +++ b/crates/bashkit-scripted-tool/src/scripted_tool/logic_only.rs @@ -6,12 +6,12 @@ //! call. Bash stays a control-flow and data-transformation language; //! registered tool commands and custom builtins are the only way out. -use crate::{BashBuilder, DirEntry, FileSystem, FileSystemExt, Metadata, Result, ShellFeatures}; +use bashkit::{BashBuilder, DirEntry, FileSystem, FileSystemExt, Metadata, Result, ShellFeatures}; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; -type SystemTime = crate::time_compat::SystemTime; +type SystemTime = bashkit::time::SystemTime; /// Restrict `builder` to the logic-only shell. pub(crate) fn apply(builder: BashBuilder) -> BashBuilder { @@ -117,7 +117,7 @@ pub(crate) fn builtin_allowed(name: &str) -> bool { /// real operation fails, and scripts cannot use a VFS as storage or input. pub(crate) struct DisabledFs; -fn disabled_fs_error() -> crate::Error { +fn disabled_fs_error() -> bashkit::Error { IoError::new(ErrorKind::PermissionDenied, "filesystem access disabled").into() } diff --git a/crates/bashkit/src/scripted_tool/mod.rs b/crates/bashkit-scripted-tool/src/scripted_tool/mod.rs similarity index 97% rename from crates/bashkit/src/scripted_tool/mod.rs rename to crates/bashkit-scripted-tool/src/scripted_tool/mod.rs index 2b009b014..803b1e52c 100644 --- a/crates/bashkit/src/scripted_tool/mod.rs +++ b/crates/bashkit-scripted-tool/src/scripted_tool/mod.rs @@ -5,11 +5,11 @@ //! so an LLM can orchestrate many operations in one call using pipes, variables, //! loops, and conditionals. //! -//! This module follows the same contract surface as [`crate::tool`]: +//! This module follows the same contract surface as [`bashkit::tool`]: //! //! - [`ScriptedToolBuilder::build`] -> immutable metadata object //! - [`ScriptedToolBuilder::build_service`] -> `tower::Service` -//! - [`Tool::execution`] -> validated, single-use [`crate::ToolExecution`] +//! - [`Tool::execution`] -> validated, single-use [`bashkit::ToolExecution`] //! - [`Tool::help`] -> Markdown docs //! - [`Tool::system_prompt`] -> terse plain-text instructions //! @@ -31,7 +31,8 @@ //! # Example //! //! ```rust -//! use bashkit::{ScriptedTool, Tool, ToolArgs, ToolDef}; +//! use bashkit::Tool; +//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; //! //! # tokio_test::block_on(async { //! let tool = ScriptedTool::builder("api") @@ -66,7 +67,7 @@ //! config), use the standard Rust closure-capture pattern with `Arc`: //! //! ```rust -//! use bashkit::{ScriptedTool, ToolArgs, ToolDef}; +//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; //! use std::sync::Arc; //! //! let api_key = Arc::new("sk-secret-key".to_string()); @@ -100,7 +101,7 @@ //! For mutable shared state, use `Arc>`: //! //! ```rust -//! use bashkit::{ScriptedTool, ToolArgs, ToolDef}; +//! use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; //! use std::sync::{Arc, Mutex}; //! //! let call_count = Arc::new(Mutex::new(0u64)); @@ -140,8 +141,8 @@ pub use crate::tool_def::{ AsyncToolCallback, AsyncToolExec, SyncToolExec, ToolArgs, ToolCallback, ToolDef, ToolImpl, }; -use crate::builtins::Builtin; -use crate::{ExecutionLimits, ExecutionProfile, Tool, ToolService}; +use bashkit::Builtin; +use bashkit::{ExecutionLimits, ExecutionProfile, Tool, ToolService}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; @@ -221,7 +222,7 @@ impl RegisteredTool { /// Builder for [`ScriptedTool`]. /// /// ```rust -/// use bashkit::{ScriptedTool, ToolArgs, ToolDef}; +/// use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; /// /// let tool = ScriptedTool::builder("net") /// .short_description("Network tools") @@ -298,7 +299,7 @@ impl ScriptedToolBuilder { /// positionals, short flags, `--`, or parse-time checking of required /// arguments gets none of that. Registering it here hands the builtin the /// raw `ctx.args` instead, so it can parse them however it likes — - /// including with [`ClapBuiltin`](crate::ClapBuiltin), the house parser for + /// including with [`ClapBuiltin`](bashkit::ClapBuiltin), the house parser for /// `Bash` builtins. /// /// The builtin runs in the same logic-only shell as the `ToolDef` commands @@ -312,7 +313,8 @@ impl ScriptedToolBuilder { /// /// ```rust /// # use async_trait::async_trait; - /// # use bashkit::{Builtin, BuiltinContext, ExecResult, ScriptedTool}; + /// # use bashkit::{Builtin, BuiltinContext, ExecResult}; + /// # use bashkit_scripted_tool::ScriptedTool; /// struct Ping; /// /// #[async_trait] @@ -448,7 +450,7 @@ impl ScriptedToolBuilder { short_desc, description: format!( "{}: {}", - super::tool::localized( + bashkit::tool::localized( self.locale.as_str(), "Compose tool callbacks through bash scripts", "Компонує виклики інструментів через bash-скрипти", @@ -494,12 +496,12 @@ impl ScriptedToolBuilder { /// Build the input schema without constructing the full tool. pub fn build_input_schema(&self) -> serde_json::Value { - crate::tool::tool_request_schema() + bashkit::tool::tool_request_schema() } /// Build the output schema for `ToolOutput::result`. pub fn build_output_schema(&self) -> serde_json::Value { - crate::tool::tool_response_schema() + bashkit::tool::tool_response_schema() } } @@ -566,7 +568,7 @@ impl ScriptedTool { #[cfg(test)] mod tests { use super::*; - use crate::tool::{Tool, ToolRequest, VERSION}; + use bashkit::tool::{Tool, ToolRequest, VERSION}; fn build_test_tool() -> ScriptedTool { ScriptedTool::builder("test_api") @@ -1517,7 +1519,7 @@ mod tests { ) .build(); - let mut bash = crate::Bash::builder().extension(extension).build(); + let mut bash = bashkit::Bash::builder().extension(extension).build(); let result = bash .exec("discover --category users\nhelp get_user\nget_user --id 7") .await @@ -1542,8 +1544,8 @@ mod tests { let handle_a = ext_a.invocation_trace(); let handle_b = ext_b.invocation_trace(); - let mut bash_a = crate::Bash::builder().extension(ext_a).build(); - let mut bash_b = crate::Bash::builder().extension(ext_b).build(); + let mut bash_a = bashkit::Bash::builder().extension(ext_a).build(); + let mut bash_b = bashkit::Bash::builder().extension(ext_b).build(); bash_a .exec("echo_arg --msg alpha") .await @@ -1580,8 +1582,8 @@ mod tests { let extension_trace = extension.invocation_trace(); let clone_trace = clone.invocation_trace(); - let mut bash_a = crate::Bash::builder().extension(extension).build(); - let mut bash_b = crate::Bash::builder().extension(clone).build(); + let mut bash_a = bashkit::Bash::builder().extension(extension).build(); + let mut bash_b = bashkit::Bash::builder().extension(clone).build(); bash_a .exec("echo_arg --msg gamma") .await @@ -1613,7 +1615,7 @@ mod tests { }) .build(); let handle = extension.invocation_trace(); - let mut bash = crate::Bash::builder().extension(extension).build(); + let mut bash = bashkit::Bash::builder().extension(extension).build(); for _ in 0..300 { let cmd = format!("noop --msg {}", "x".repeat(1500)); @@ -1639,7 +1641,7 @@ mod tests { }) .build(); let handle = extension.invocation_trace(); - let mut bash = crate::Bash::builder().extension(extension).build(); + let mut bash = bashkit::Bash::builder().extension(extension).build(); let big = "\u{1F600}".repeat(400); let cmd = format!("noop --msg {}", big); diff --git a/crates/bashkit/src/scripted_tool/toolset.rs b/crates/bashkit-scripted-tool/src/scripted_tool/toolset.rs similarity index 98% rename from crates/bashkit/src/scripted_tool/toolset.rs rename to crates/bashkit-scripted-tool/src/scripted_tool/toolset.rs index 52f3ae58d..3985b60c3 100644 --- a/crates/bashkit/src/scripted_tool/toolset.rs +++ b/crates/bashkit-scripted-tool/src/scripted_tool/toolset.rs @@ -8,11 +8,11 @@ use super::{ CallbackKind, RegisteredTool, ScriptedExecutionTrace, ScriptedTool, ToolArgs, ToolDef, ToolImpl, }; -use crate::tool::{ +use async_trait::async_trait; +use bashkit::tool::{ Tool, ToolError, ToolRequest, ToolResponse, ToolStatus, VERSION, tool_response_schema, }; -use crate::{ExecutionLimits, ExecutionProfile}; -use async_trait::async_trait; +use bashkit::{ExecutionLimits, ExecutionProfile}; use std::sync::Arc; // ============================================================================ @@ -42,7 +42,8 @@ pub enum DiscoveryMode { /// scripts for the main tool. /// /// ```rust -/// use bashkit::{ScriptingToolSet, ToolArgs, ToolDef, Tool}; +/// use bashkit::Tool; +/// use bashkit_scripted_tool::{ScriptingToolSet, ToolArgs, ToolDef}; /// /// # tokio_test::block_on(async { /// let toolset = ScriptingToolSet::builder("api") @@ -217,7 +218,7 @@ impl Tool for DiscoverTool { fn execution( &self, args: serde_json::Value, - ) -> Result { + ) -> Result { let req = Self::resolve_request(&args).map_err(ToolError::UserFacing)?; if let Err(msg) = Self::validate_commands(&req.commands) { return Err(ToolError::UserFacing(msg)); @@ -252,7 +253,7 @@ impl Tool for DiscoverTool { /// Builder for [`ScriptingToolSet`]. /// /// ```rust -/// use bashkit::{ScriptingToolSet, ToolArgs, ToolDef}; +/// use bashkit_scripted_tool::{ScriptingToolSet, ToolArgs, ToolDef}; /// /// let toolset = ScriptingToolSet::builder("api") /// .short_description("Example API") @@ -430,7 +431,8 @@ impl ScriptingToolSetBuilder { /// via `discover` and `help` builtins. /// /// ```rust -/// use bashkit::{ScriptingToolSet, ToolArgs, ToolDef, Tool, ToolRequest}; +/// use bashkit::{Tool, ToolRequest}; +/// use bashkit_scripted_tool::{ScriptingToolSet, ToolArgs, ToolDef}; /// /// # tokio_test::block_on(async { /// // Exclusive mode (default): one tool with full schemas @@ -458,7 +460,8 @@ impl ScriptingToolSetBuilder { /// ``` /// /// ```rust -/// use bashkit::{ScriptingToolSet, ToolArgs, ToolDef, Tool}; +/// use bashkit::Tool; +/// use bashkit_scripted_tool::{ScriptingToolSet, ToolArgs, ToolDef}; /// /// // Discovery mode: two tools /// let toolset = ScriptingToolSet::builder("api") @@ -528,7 +531,7 @@ impl ScriptingToolSet { #[cfg(test)] mod tests { use super::*; - use crate::tool::ToolRequest; + use bashkit::tool::ToolRequest; fn make_tools() -> ScriptingToolSetBuilder { ScriptingToolSet::builder("test_api") diff --git a/crates/bashkit/src/tool_def.rs b/crates/bashkit-scripted-tool/src/tool_def.rs similarity index 95% rename from crates/bashkit/src/tool_def.rs rename to crates/bashkit-scripted-tool/src/tool_def.rs index c76c91709..9e0ae386e 100644 --- a/crates/bashkit/src/tool_def.rs +++ b/crates/bashkit-scripted-tool/src/tool_def.rs @@ -5,10 +5,10 @@ // // Dependency direction: builtins → tool_def → {lib.rs, scripted_tool, tool.rs} -use crate::builtins::{Builtin, Context}; -use crate::error::Result; -use crate::interpreter::ExecResult; use async_trait::async_trait; +use bashkit::ExecResult; +use bashkit::Result; +use bashkit::{Builtin, BuiltinContext as Context}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -86,7 +86,7 @@ pub struct ToolArgs { #[derive(Default)] struct ToolArgsContext { - capability: Option>, + capability: Option>, } #[derive(Clone)] @@ -120,7 +120,7 @@ impl ToolArgs { pub(crate) fn with_context( params: serde_json::Value, stdin: Option, - capability: crate::ExecutionCapability, + capability: bashkit::ExecutionCapability, ) -> Self { Self { params, @@ -134,7 +134,7 @@ impl ToolArgs { /// Request tenant while the originating execution remains active. pub fn tenant_id( &self, - ) -> std::result::Result, crate::ExecutionCapabilityError> { + ) -> std::result::Result, bashkit::ExecutionCapabilityError> { self.context .capability .as_ref() @@ -148,7 +148,7 @@ impl ToolArgs { &self, ) -> std::result::Result< Option, - crate::ExecutionCapabilityError, + bashkit::ExecutionCapabilityError, > { self.context .capability @@ -217,7 +217,7 @@ pub type AsyncToolCallback = AsyncToolExec; /// # Example /// /// ```rust -/// use bashkit::{ToolDef, ToolImpl}; +/// use bashkit_scripted_tool::{ToolDef, ToolImpl}; /// /// let tool = ToolImpl::new( /// ToolDef::new("greet", "Greet a user") @@ -295,7 +295,7 @@ impl ToolImpl { impl Builtin for ToolImpl { async fn execute(&self, ctx: Context<'_>) -> Result { let params = parse_flags(ctx.args, &self.def.input_schema) - .map_err(|e| crate::error::Error::Execution(format!("{}: {e}", self.def.name)))?; + .map_err(|e| bashkit::Error::Execution(format!("{}: {e}", self.def.name)))?; let context = ToolArgsContextData::new(None, crate::tool_registry::ToolCallSurface::Shell); let stdin = ctx.stdin.map(String::from); let tool_args = match ctx.execution_capability(context) { @@ -312,17 +312,17 @@ impl Builtin for ToolImpl { if let Some(budget) = ctx.execution_budget() { budget .try_with(|budget| budget.check()) - .map_err(|_| crate::Error::Cancelled)??; + .map_err(|_| bashkit::Error::Cancelled)??; } let result = (cb)(&tool_args); if let Some(budget) = ctx.execution_budget() { budget .try_with(|budget| budget.check()) - .map_err(|_| crate::Error::Cancelled)??; + .map_err(|_| bashkit::Error::Cancelled)??; } result } else { - return Err(crate::error::Error::Execution(format!( + return Err(bashkit::Error::Execution(format!( "{}: no exec defined", self.def.name ))); @@ -1549,14 +1549,14 @@ mod tests { assert_eq!(tool.def.name, "greet"); } - fn tool_test_context<'a>( - args: &'a [String], - env: &'a std::collections::HashMap, - vars: &'a mut std::collections::HashMap, - cwd: &'a mut std::path::PathBuf, - ) -> Context<'a> { - let fs = Arc::new(crate::fs::InMemoryFs::new()); - Context::new_for_test(args, env, vars, cwd, fs, None) + /// Run `tool` as a registered builtin. Uses the public `Bash` API: the + /// interpreter's `BuiltinContext` test constructor is core-internal. + async fn run_builtin(tool: ToolImpl, script: &str) -> Result { + let name = tool.def.name.clone(); + let mut bash = bashkit::Bash::builder() + .builtin(&name, Box::new(tool)) + .build(); + bash.exec(script).await } #[tokio::test] @@ -1573,12 +1573,7 @@ mod tests { }); // Verify it works as a Builtin - let args = vec!["--name".to_string(), "Alice".to_string()]; - let env = std::collections::HashMap::new(); - let mut vars = std::collections::HashMap::new(); - let mut cwd = std::path::PathBuf::from("/"); - let ctx = tool_test_context(&args, &env, &mut vars, &mut cwd); - let result = tool.execute(ctx).await.unwrap(); + let result = run_builtin(tool, "greet --name Alice").await.unwrap(); assert_eq!(result.stdout, "hello Alice\n"); assert_eq!(result.exit_code, 0); } @@ -1593,12 +1588,7 @@ mod tests { )) }); - let args = vec![]; - let env = std::collections::HashMap::new(); - let mut vars = std::collections::HashMap::new(); - let mut cwd = std::path::PathBuf::from("/"); - let ctx = tool_test_context(&args, &env, &mut vars, &mut cwd); - let result = tool.execute(ctx).await.unwrap(); + let result = run_builtin(tool, "lookup").await.unwrap(); assert_eq!(result.exit_code, 1); assert_eq!(result.stderr, "lookup: callback failed\n"); @@ -1613,12 +1603,7 @@ mod tests { .with_exec_sync(|_| Err("raw diagnostic".to_string())) .sanitize_errors(false); - let args = vec![]; - let env = std::collections::HashMap::new(); - let mut vars = std::collections::HashMap::new(); - let mut cwd = std::path::PathBuf::from("/"); - let ctx = tool_test_context(&args, &env, &mut vars, &mut cwd); - let result = tool.execute(ctx).await.unwrap(); + let result = run_builtin(tool, "lookup").await.unwrap(); assert_eq!(result.exit_code, 1); assert_eq!(result.stderr, "raw diagnostic"); @@ -1640,12 +1625,7 @@ mod tests { async fn test_tool_impl_no_exec_errors() { let tool = ToolImpl::new(ToolDef::new("empty", "No exec")); - let args = vec![]; - let env = std::collections::HashMap::new(); - let mut vars = std::collections::HashMap::new(); - let mut cwd = std::path::PathBuf::from("/"); - let ctx = tool_test_context(&args, &env, &mut vars, &mut cwd); - let result = tool.execute(ctx).await; + let result = run_builtin(tool, "empty").await; assert!(result.is_err()); } } diff --git a/crates/bashkit/src/tool_registry.rs b/crates/bashkit-scripted-tool/src/tool_registry.rs similarity index 85% rename from crates/bashkit/src/tool_registry.rs rename to crates/bashkit-scripted-tool/src/tool_registry.rs index 7a3dc99cb..2dd88eab1 100644 --- a/crates/bashkit/src/tool_registry.rs +++ b/crates/bashkit-scripted-tool/src/tool_registry.rs @@ -2,11 +2,11 @@ // Surface adapters only translate arguments/results; policy, validation, // deadlines, sanitization, callback identity, and tracing stay here. -use crate::BuiltinContext as Context; use crate::scripted_tool::{ CallbackKind, RegisteredTool, ScriptedCommandKind, ToolDefInvocationTrace, }; use crate::{ToolArgs, ToolDef, ToolImpl}; +use bashkit::BuiltinContext as Context; use serde_json::Value; use std::future::Future; use std::sync::Arc; @@ -54,7 +54,7 @@ impl ToolCall<'_> { type PolicyHook = Arc) -> ToolCallDecision + Send + Sync>; -/// Per-`exec*` registry context carried through [`crate::ExecutionExtensions`]. +/// Per-`exec*` registry context carried through [`bashkit::ExecutionExtensions`]. /// /// Each request owns a trace sink, preventing cross-tenant trace mixing even /// when tenants share one immutable registry and callback set. @@ -87,11 +87,11 @@ impl ToolCallRequest { // registry needs no interpreter internals. #[derive(Clone)] struct ToolCallScope { - request: Option>, - budget: Option, - lease: Option>, + request: Option>, + budget: Option, + lease: Option>, #[cfg(not(target_family = "wasm"))] - deadline: Option, + deadline: Option, } impl ToolCallScope { @@ -106,7 +106,7 @@ impl ToolCallScope { #[cfg(any(feature = "python", feature = "typescript"))] fn from_runtime() -> Self { - let ctx = crate::RuntimeCallContext::current(); + let ctx = bashkit::RuntimeCallContext::current(); Self::new( ctx.execution_extension::(), ctx.execution_budget(), @@ -116,9 +116,9 @@ impl ToolCallScope { } fn new( - request: Option>, - budget: Option>, - lease: Option>, + request: Option>, + budget: Option>, + lease: Option>, #[cfg_attr(target_family = "wasm", allow(unused_variables))] remaining: Option< std::time::Duration, >, @@ -129,18 +129,18 @@ impl ToolCallScope { lease, #[cfg(not(target_family = "wasm"))] deadline: remaining - .and_then(|remaining| crate::time_compat::Instant::now().checked_add(remaining)), + .and_then(|remaining| bashkit::time::Instant::now().checked_add(remaining)), } } - fn tenant_id(&self) -> Result, crate::ExecutionCapabilityError> { + fn tenant_id(&self) -> Result, bashkit::ExecutionCapabilityError> { self.request .as_ref() .map(|request| request.try_with(|request| request.tenant_id().to_string())) .transpose() } - fn trace(&self) -> Result, crate::ExecutionCapabilityError> { + fn trace(&self) -> Result, bashkit::ExecutionCapabilityError> { self.request .as_ref() .map(|request| request.try_with(ToolCallRequest::trace)) @@ -151,7 +151,7 @@ impl ToolCallScope { fn remaining(&self) -> Option { self.deadline.map(|deadline| { deadline - .checked_duration_since(crate::time_compat::Instant::now()) + .checked_duration_since(bashkit::time::Instant::now()) .unwrap_or_default() }) } @@ -259,6 +259,60 @@ impl ToolRegistry { ToolRegistryBuilder::default() } + /// Install this registry across shell, embedded Python, and embedded + /// TypeScript on `builder`. Runtime surfaces are included when this + /// crate's `python` / `typescript` features are enabled and share the + /// registry's callback and policy `Arc`s. Python and TypeScript use the + /// limits of the builder's current [`ExecutionProfile`](bashkit::ExecutionProfile), + /// so set the profile first. + pub fn install(&self, builder: bashkit::BashBuilder) -> bashkit::BashBuilder { + #[cfg_attr( + not(any(feature = "python", feature = "typescript")), + allow(unused_mut) + )] + let mut builder = builder.extension(crate::ToolDefExtension::from_registry(self.clone())); + #[cfg(feature = "python")] + { + let limits = builder.execution_profile().python_limits().clone(); + let names = vec!["__bashkit_tool_call".to_string()]; + let handler = self.python_handler(); + let prelude = self.python_prelude(); + builder = builder + .builtin( + "python", + Box::new( + bashkit::Python::with_limits(limits.clone()) + .with_external_handler_and_prelude( + names.clone(), + handler.clone(), + prelude.clone(), + ), + ), + ) + .builtin( + "python3", + Box::new( + bashkit::Python::with_limits(limits) + .with_external_handler_and_prelude(names, handler, prelude), + ), + ); + } + #[cfg(feature = "typescript")] + { + let limits = builder.execution_profile().typescript_limits().clone(); + builder = builder.extension( + bashkit::TypeScriptExtension::with_external_handler_and_prelude( + limits, + self.typescript_external_names(), + self.typescript_handler(), + self.typescript_prelude(), + self.typescript_rewrites(), + ), + ); + } + builder + } + pub(crate) fn from_registered( tools: Vec, sanitize_errors: bool, @@ -505,13 +559,13 @@ impl ToolRegistry { } #[cfg(feature = "typescript")] - pub(crate) fn typescript_handler(&self) -> crate::TypeScriptExternalFnHandler { + pub(crate) fn typescript_handler(&self) -> bashkit::TypeScriptExternalFnHandler { let registry = self.clone(); Arc::new(move |external_name, args| { let registry = registry.clone(); Box::pin(async move { let params = match args.first() { - Some(zapcode_core::Value::String(json)) => serde_json::from_str(json) + Some(bashkit::ZapcodeValue::String(json)) => serde_json::from_str(json) .map_err(|_| "tool input must be valid JSON".to_string())?, Some(value) => zapcode_to_json(value)?, None => serde_json::json!({}), @@ -541,8 +595,8 @@ impl ToolRegistry { } #[cfg(feature = "python")] - pub(crate) fn python_handler(&self) -> crate::PythonExternalFnHandler { - use monty_types::{ExcType, ExtFunctionResult, MontyException, MontyObject}; + pub(crate) fn python_handler(&self) -> bashkit::PythonExternalFnHandler { + use bashkit::{ExcType, ExtFunctionResult, MontyException, MontyObject}; let registry = self.clone(); Arc::new(move |_external_name, args, _kwargs| { let registry = registry.clone(); @@ -837,22 +891,22 @@ fn python_tree( } #[cfg(feature = "typescript")] -fn zapcode_to_json(value: &zapcode_core::Value) -> Result { +fn zapcode_to_json(value: &bashkit::ZapcodeValue) -> Result { Ok(match value { - zapcode_core::Value::Undefined | zapcode_core::Value::Null => Value::Null, - zapcode_core::Value::Bool(value) => Value::Bool(*value), - zapcode_core::Value::Int(value) => Value::from(*value), - zapcode_core::Value::Float(value) => serde_json::Number::from_f64(*value) + bashkit::ZapcodeValue::Undefined | bashkit::ZapcodeValue::Null => Value::Null, + bashkit::ZapcodeValue::Bool(value) => Value::Bool(*value), + bashkit::ZapcodeValue::Int(value) => Value::from(*value), + bashkit::ZapcodeValue::Float(value) => serde_json::Number::from_f64(*value) .map(Value::Number) .ok_or_else(|| "non-finite number is not valid tool input".to_string())?, - zapcode_core::Value::String(value) => Value::String(value.to_string()), - zapcode_core::Value::Array(values) => Value::Array( + bashkit::ZapcodeValue::String(value) => Value::String(value.to_string()), + bashkit::ZapcodeValue::Array(values) => Value::Array( values .iter() .map(zapcode_to_json) .collect::, _>>()?, ), - zapcode_core::Value::Object(values) => Value::Object( + bashkit::ZapcodeValue::Object(values) => Value::Object( values .iter() .map(|(key, value)| Ok((key.to_string(), zapcode_to_json(value)?))) @@ -863,20 +917,20 @@ fn zapcode_to_json(value: &zapcode_core::Value) -> Result { } #[cfg(feature = "typescript")] -fn json_to_zapcode(value: Value) -> zapcode_core::Value { +fn json_to_zapcode(value: Value) -> bashkit::ZapcodeValue { match value { - Value::Null => zapcode_core::Value::Null, - Value::Bool(value) => zapcode_core::Value::Bool(value), + Value::Null => bashkit::ZapcodeValue::Null, + Value::Bool(value) => bashkit::ZapcodeValue::Bool(value), Value::Number(value) => value .as_i64() - .map(zapcode_core::Value::Int) - .or_else(|| value.as_f64().map(zapcode_core::Value::Float)) - .unwrap_or(zapcode_core::Value::Null), - Value::String(value) => zapcode_core::Value::String(Arc::from(value)), + .map(bashkit::ZapcodeValue::Int) + .or_else(|| value.as_f64().map(bashkit::ZapcodeValue::Float)) + .unwrap_or(bashkit::ZapcodeValue::Null), + Value::String(value) => bashkit::ZapcodeValue::String(Arc::from(value)), Value::Array(values) => { - zapcode_core::Value::Array(values.into_iter().map(json_to_zapcode).collect()) + bashkit::ZapcodeValue::Array(values.into_iter().map(json_to_zapcode).collect()) } - Value::Object(values) => zapcode_core::Value::Object( + Value::Object(values) => bashkit::ZapcodeValue::Object( values .into_iter() .map(|(key, value)| (Arc::from(key), json_to_zapcode(value))) @@ -886,8 +940,8 @@ fn json_to_zapcode(value: Value) -> zapcode_core::Value { } #[cfg(feature = "python")] -fn monty_to_json(value: &monty_types::MontyObject) -> Result { - use monty_types::MontyObject; +fn monty_to_json(value: &bashkit::MontyObject) -> Result { + use bashkit::MontyObject; Ok(match value { MontyObject::None => Value::Null, MontyObject::Bool(value) => Value::Bool(*value), @@ -918,8 +972,8 @@ fn monty_to_json(value: &monty_types::MontyObject) -> Result { } #[cfg(feature = "python")] -fn json_to_monty(value: Value) -> monty_types::MontyObject { - use monty_types::MontyObject; +fn json_to_monty(value: Value) -> bashkit::MontyObject { + use bashkit::MontyObject; match value { Value::Null => MontyObject::None, Value::Bool(value) => MontyObject::Bool(value), diff --git a/crates/bashkit-scripted-tool/tests/request_lifecycle.rs b/crates/bashkit-scripted-tool/tests/request_lifecycle.rs new file mode 100644 index 000000000..5dbe9dc29 --- /dev/null +++ b/crates/bashkit-scripted-tool/tests/request_lifecycle.rs @@ -0,0 +1,57 @@ +//! A registry callback is a request-owned async boundary: cancelling the +//! request must drop the pending callback and leave the shell reusable. + +use bashkit::{Bash, Error}; +use bashkit_scripted_tool::{ToolArgs, ToolDef, ToolRegistry}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use tokio::sync::Notify; + +struct ReleaseProbe(Arc); + +impl Drop for ReleaseProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_tool_callback_obeys_request_lifecycle() { + let started = Arc::new(Notify::new()); + let released = Arc::new(AtomicBool::new(false)); + let callback_started = started.clone(); + let callback_released = released.clone(); + let registry = ToolRegistry::builder() + .async_tool_fn( + ToolDef::new("pending_tool", "wait forever"), + move |_args: ToolArgs| { + let started = callback_started.clone(); + let released = callback_released.clone(); + async move { + let _probe = ReleaseProbe(released); + started.notify_one(); + std::future::pending::>().await + } + }, + ) + .build(); + let mut bash = registry.install(Bash::builder()).build(); + + let cancellation = bash.cancellation_token(); + let cancel = cancellation.clone(); + let canceller = tokio::spawn(async move { + started.notified().await; + cancel.store(true, Ordering::SeqCst); + }); + + let result = bash.exec("pending_tool").await; + canceller.await.unwrap(); + assert!(matches!(result, Err(Error::Cancelled)), "{result:?}"); + assert!(released.load(Ordering::SeqCst), "boundary resource leaked"); + + cancellation.store(false, Ordering::SeqCst); + assert_eq!( + bash.exec("echo reusable").await.unwrap().stdout, + "reusable\n" + ); +} diff --git a/crates/bashkit/tests/integration/tool_registry_tests.rs b/crates/bashkit-scripted-tool/tests/tool_registry_tests.rs similarity index 93% rename from crates/bashkit/tests/integration/tool_registry_tests.rs rename to crates/bashkit-scripted-tool/tests/tool_registry_tests.rs index 8bb0735cd..cd3c35dcd 100644 --- a/crates/bashkit/tests/integration/tool_registry_tests.rs +++ b/crates/bashkit-scripted-tool/tests/tool_registry_tests.rs @@ -1,7 +1,5 @@ -use bashkit::{ - Bash, ExecOptions, ExecutionCapabilityError, ExecutionExtensions, ExecutionLimits, ToolArgs, - ToolCallDecision, ToolCallRequest, ToolDef, ToolRegistry, -}; +use bashkit::{Bash, ExecOptions, ExecutionCapabilityError, ExecutionExtensions, ExecutionLimits}; +use bashkit_scripted_tool::{ToolArgs, ToolCallDecision, ToolCallRequest, ToolDef, ToolRegistry}; use serde_json::json; use std::sync::{ Arc, Mutex, @@ -35,7 +33,7 @@ async fn tool_callback_context_is_revoked_when_the_request_completes() { } }) .build(); - let mut bash = Bash::builder().tool_registry(registry.clone()).build(); + let mut bash = registry.install(Bash::builder()).build(); let result = bash .exec_with_options("orders.list --customer x", request("tenant-a", ®istry)) @@ -73,8 +71,8 @@ async fn one_registry_dispatches_shell_python_and_typescript_with_shared_policy_ ToolCallDecision::Allow }) .build(); - let mut bash = Bash::builder() - .tool_registry(registry.clone()) + let mut bash = registry + .install(Bash::builder()) .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") .build(); @@ -123,8 +121,8 @@ async fn registry_enforces_schema_denial_sanitization_discovery_and_request_trac .build(); let allowed = ToolCallRequest::new("allowed", registry.trace()); let allowed_trace = allowed.trace(); - let mut bash = Bash::builder() - .tool_registry(registry.clone()) + let mut bash = registry + .install(Bash::builder()) .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") .build(); @@ -185,7 +183,7 @@ async fn registry_resolves_local_schema_references_before_callback() { Ok("unexpected".into()) }) .build(); - let mut bash = Bash::builder().tool_registry(registry).build(); + let mut bash = registry.install(Bash::builder()).build(); let result = bash.exec("customers.get --unknown attacker").await.unwrap(); @@ -216,9 +214,8 @@ async fn registry_deadline_cancels_callback_and_tenants_do_not_share_context_or_ }) .build(); let limits = ExecutionLimits::new().timeout(Duration::from_millis(25)); - let mut bash = Bash::builder() - .limits(limits) - .tool_registry(registry.clone()) + let mut bash = registry + .install(Bash::builder().limits(limits)) .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") .build(); let timeout = bash @@ -241,7 +238,7 @@ async fn registry_deadline_cancels_callback_and_tenants_do_not_share_context_or_ let a_trace = a.trace(); let b = ToolCallRequest::new("b", registry.trace()); let b_trace = b.trace(); - let mut bash = Bash::builder().tool_registry(registry).build(); + let mut bash = registry.install(Bash::builder()).build(); let first = bash .exec_with_options( "orders.list --customer x", diff --git a/crates/bashkit-wasm/Cargo.toml b/crates/bashkit-wasm/Cargo.toml index 08b65b06f..792c77fea 100644 --- a/crates/bashkit-wasm/Cargo.toml +++ b/crates/bashkit-wasm/Cargo.toml @@ -7,7 +7,7 @@ # unlike the wasm32-wasip1-threads example. That makes it drop-in for any web # app, including embedded/iframe contexts. See knowledge/runtimes/browser-package.md. # -# Feature surface mirrors the `wasm` CI job (scripted_tool + jq). http_client, +# Feature surface mirrors the `wasm` CI job (jq). http_client, # ssh, sqlite, python, realfs, and interop are intentionally excluded: they need # sockets, threads, or a host filesystem that the browser sandbox lacks. # @@ -36,7 +36,6 @@ crate-type = ["cdylib", "rlib"] # a named `TZ=` zone here rather than silently formatting in UTC; see the # feature comment in ../bashkit/Cargo.toml. bashkit = { path = "../bashkit", default-features = false, features = [ - "scripted_tool", "jq", # JS host: getrandom goes through crypto.getRandomValues. "wasm_js", @@ -50,7 +49,7 @@ serde-wasm-bindgen = "0.6" futures-util = { workspace = true } send_wrapper = { version = "0.6", features = ["futures"] } # Same clock shim bashkit uses internally, so `Metadata` timestamps built here -# unify with `bashkit::time_compat::SystemTime` on wasm32. +# unify with `bashkit::time::SystemTime` on wasm32. web-time = { workspace = true } console_error_panic_hook = "0.1" # getrandom's browser backend (matches the workspace pin). `wasm_js` here and diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index e5de10221..a53823537 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -18,7 +18,7 @@ readme = "../../README.md" tokio = { workspace = true } async-trait = { workspace = true } # futures-core / tower are only pulled in by the LLM `BashTool` wrapper -# (and the `scripted_tool` layer on top of it). Optional, gated under `bash_tool`. +# (and the `bashkit-scripted-tool` crate on top of it). Optional, gated under `bash_tool`. futures-core = { workspace = true, optional = true } futures-util = { workspace = true } tower = { workspace = true, optional = true } @@ -253,10 +253,6 @@ git = [] # Enable ssh/scp/sftp builtins for remote command execution and file transfer # Usage: cargo build --features ssh ssh = ["russh"] -# Enable ScriptedTool: compose ToolDef+callback pairs into a single Tool -# Builds on the `BashTool` contract, so it pulls in the `bash_tool` feature. -# Usage: cargo build --features scripted_tool -scripted_tool = ["bash_tool"] # Enable python/python3 builtins via embedded Monty interpreter # Usage: cargo build --features python python = ["dep:monty", "dep:monty-types", "dep:get-size2"] @@ -359,10 +355,6 @@ required-features = ["ssh"] name = "show_tool_output" required-features = ["bash_tool"] -[[example]] -name = "scripted_tool" -required-features = ["scripted_tool"] - [[example]] name = "python_scripts" required-features = ["python"] diff --git a/crates/bashkit/docs/python.md b/crates/bashkit/docs/python.md index 6b67dc70e..4742b74f9 100644 --- a/crates/bashkit/docs/python.md +++ b/crates/bashkit/docs/python.md @@ -38,18 +38,20 @@ assert_eq!(result.stdout, "hello from Monty\n"); ### Shared Tool Registry -With `scripted_tool`, one `ToolRegistry` can expose the same definitions and +With the [`bashkit-scripted-tool`](https://docs.rs/bashkit-scripted-tool) crate +(feature `python`), one `ToolRegistry` can expose the same definitions and callbacks as shell commands and Python functions: -```rust,no_run -use bashkit::{Bash, ToolArgs, ToolDef, ToolRegistry}; +```rust,ignore +use bashkit::Bash; +use bashkit_scripted_tool::{ToolArgs, ToolDef, ToolRegistry}; let registry = ToolRegistry::builder() .tool_fn(ToolDef::new("orders.list", "List orders"), |_args: &ToolArgs| { Ok(r#"[{"id": 1}]"#.to_string()) }) .build(); -let bash = Bash::builder().tool_registry(registry).build(); +let bash = registry.install(Bash::builder()).build(); ``` ```python diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index 9487a1132..03db3c1e3 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -944,7 +944,7 @@ builtin silently fails. - Python bindings (bashkit-python): PyO3 `String` extraction, no manual byte/char ops - eval harness: `chars().take()`, `from_utf8_lossy()`, all safe patterns - curl/bc/export/date/comm/echo/archive/base64: All `.find()` use ASCII delimiters only -- scripted_tool: No byte/char patterns +- bashkit-scripted-tool: No byte/char patterns **Path Validation:** diff --git a/crates/bashkit/docs/typescript.md b/crates/bashkit/docs/typescript.md index 31d8a39ad..ddac0ed91 100644 --- a/crates/bashkit/docs/typescript.md +++ b/crates/bashkit/docs/typescript.md @@ -46,7 +46,8 @@ let bash = Bash::builder() ### Shared Tool Registry -With `scripted_tool`, one `ToolRegistry` exposes dot-separated definitions as +With the [`bashkit-scripted-tool`](https://docs.rs/bashkit-scripted-tool) crate +(feature `typescript`), one `ToolRegistry` exposes dot-separated definitions as shell commands and ergonomic TypeScript namespaces: ```typescript diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index c39a9dd63..fc82b6ded 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -454,10 +454,6 @@ pub mod parser; mod profile; #[cfg(any(feature = "python", feature = "typescript"))] mod runtime_call; -/// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts. -/// Requires the `scripted_tool` feature. -#[cfg(feature = "scripted_tool")] -pub mod scripted_tool; mod snapshot; mod stream; #[cfg(feature = "terminal")] @@ -468,16 +464,21 @@ pub mod terminal; /// invariants enforced (TM-INF-013, TM-INF-016, TM-INF-022). #[doc(hidden)] pub mod testing; +/// Clock and timer that match the interpreter's target. +/// +/// Native targets use tokio's clock (it follows paused/advanced test time), +/// JS-host wasm uses `Performance.now()`/`setTimeout`, and non-JS wasm uses the +/// embedder-supplied host clock. Code that layers on bashkit (tool registries, +/// custom builtins) should read time and race deadlines through these so it +/// agrees with the interpreter's own deadlines on every target. +pub mod time { + pub use crate::time_compat::{Instant, SystemTime, TimeoutElapsed, UNIX_EPOCH, sleep, timeout}; +} mod time_compat; /// Tool contract for LLM integration. /// Requires the `bash_tool` feature (enabled by default). #[cfg(feature = "bash_tool")] pub mod tool; -/// Reusable tool primitives: ToolDef, ToolArgs, ToolImpl, exec types. -#[cfg(feature = "scripted_tool")] -pub(crate) mod tool_def; -#[cfg(feature = "scripted_tool")] -mod tool_registry; /// Structured execution trace events. pub mod trace; pub use stream::StreamData; @@ -540,20 +541,6 @@ pub use trace::{ TraceCallback, TraceCollector, TraceEvent, TraceEventDetails, TraceEventKind, TraceMode, }; -#[cfg(feature = "scripted_tool")] -pub use scripted_tool::{ - AsyncToolCallback, CallbackKind, DiscoverTool, DiscoveryMode, ScriptedCommandInvocation, - ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder, - ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef, ToolDefExtension, - ToolDefExtensionBuilder, ToolDefInvocationTrace, -}; -#[cfg(feature = "scripted_tool")] -pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl}; -#[cfg(feature = "scripted_tool")] -pub use tool_registry::{ - ToolCall, ToolCallDecision, ToolCallRequest, ToolCallSurface, ToolRegistry, ToolRegistryBuilder, -}; - #[cfg(feature = "http_client")] pub use network::HttpClient; @@ -1982,54 +1969,12 @@ impl BashBuilder { self } - /// Install one ToolDef-backed registry across shell, embedded Python, and - /// embedded TypeScript. Runtime surfaces are included when their cargo - /// features are enabled and share the registry's callback and policy Arcs. - #[cfg(feature = "scripted_tool")] - pub fn tool_registry(mut self, registry: ToolRegistry) -> Self { - self = self.extension(scripted_tool::ToolDefExtension::from_registry( - registry.clone(), - )); - #[cfg(feature = "python")] - { - let limits = self.profile.python_limits().clone(); - let names = vec!["__bashkit_tool_call".to_string()]; - let handler = registry.python_handler(); - let prelude = registry.python_prelude(); - self = self - .builtin( - "python", - Box::new( - builtins::Python::with_limits(limits.clone()) - .with_external_handler_and_prelude( - names.clone(), - handler.clone(), - prelude.clone(), - ), - ), - ) - .builtin( - "python3", - Box::new( - builtins::Python::with_limits(limits) - .with_external_handler_and_prelude(names, handler, prelude), - ), - ); - } - #[cfg(feature = "typescript")] - { - let limits = self.profile.typescript_limits().clone(); - self = self.extension( - builtins::TypeScriptExtension::with_external_handler_and_prelude( - limits, - registry.typescript_external_names(), - registry.typescript_handler(), - registry.typescript_prelude(), - registry.typescript_rewrites(), - ), - ); - } - self + /// Execution profile this builder currently carries. + /// + /// Lets builder add-ons (for example a tool registry installing its + /// Python/TypeScript surfaces) reuse the configured runtime limits. + pub fn execution_profile(&self) -> &ExecutionProfile { + &self.profile } /// Set a custom filesystem. diff --git a/crates/bashkit/src/time_compat/mod.rs b/crates/bashkit/src/time_compat/mod.rs index b270fe36e..79b20eefe 100644 --- a/crates/bashkit/src/time_compat/mod.rs +++ b/crates/bashkit/src/time_compat/mod.rs @@ -30,28 +30,28 @@ pub mod host_clock; target_os = "unknown", not(feature = "wasm_js") ))] -pub(crate) use host_clock::{Instant, SystemTime, UNIX_EPOCH}; +pub use host_clock::{Instant, SystemTime, UNIX_EPOCH}; #[cfg(all(target_arch = "wasm32", target_os = "unknown", feature = "wasm_js"))] -pub(crate) use web_time::{Instant, SystemTime, UNIX_EPOCH}; +pub use web_time::{Instant, SystemTime, UNIX_EPOCH}; // WASI targets (wasip1, wasip2, wasip1-threads) have real clocks in std, and // no tokio timer driver worth routing `Instant` through. #[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))] -pub(crate) use std::time::{Instant, SystemTime, UNIX_EPOCH}; +pub use std::time::{Instant, SystemTime, UNIX_EPOCH}; #[cfg(not(target_arch = "wasm32"))] -pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; +pub use std::time::{SystemTime, UNIX_EPOCH}; // Tokio's instant follows the runtime's paused/advanced monotonic clock. This // makes shell deadlines and `time` deterministic under virtual-time runtimes. #[cfg(not(target_arch = "wasm32"))] -pub(crate) use tokio::time::Instant; +pub use tokio::time::Instant; /// Portable timer future. JS-host wasm uses `setTimeout`; native targets keep /// tokio's runtime timer. The wasm future is wrapped as `Send` because the /// target is deliberately single-threaded (the same invariant as JS builtins). -pub(crate) async fn sleep(duration: Duration) { +pub async fn sleep(duration: Duration) { #[cfg(all( target_arch = "wasm32", target_os = "unknown", @@ -77,10 +77,11 @@ pub(crate) async fn sleep(duration: Duration) { tokio::time::sleep(duration).await; } -pub(crate) struct TimeoutElapsed; +#[derive(Debug)] +pub struct TimeoutElapsed; /// Race a future against a host wall-clock deadline on every supported target. -pub(crate) async fn timeout( +pub async fn timeout( duration: Duration, future: F, ) -> Result { diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 5453b2b1e..99e474761 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -138,8 +138,6 @@ pub mod threat_model_doc_tests; pub mod threat_model_tests; pub mod time_command_tests; pub mod time_compat_scan_tests; -#[cfg(all(feature = "scripted_tool", feature = "python", feature = "typescript"))] -pub mod tool_registry_tests; pub mod tty_tests; pub mod typescript_integration_tests; pub mod typescript_security_tests; diff --git a/crates/bashkit/tests/integration/request_lifecycle_contract_tests.rs b/crates/bashkit/tests/integration/request_lifecycle_contract_tests.rs index 51880b11c..c2407a5fe 100644 --- a/crates/bashkit/tests/integration/request_lifecycle_contract_tests.rs +++ b/crates/bashkit/tests/integration/request_lifecycle_contract_tests.rs @@ -1,7 +1,6 @@ //! Adversarial contract for request-owned async execution boundaries. #[cfg(any( - feature = "scripted_tool", feature = "http_client", feature = "python", feature = "typescript", @@ -9,54 +8,28 @@ ))] use bashkit::{Bash, Error}; #[cfg(any( - feature = "scripted_tool", feature = "http_client", feature = "python", feature = "typescript", feature = "sqlite" ))] use std::sync::atomic::Ordering; -#[cfg(any( - feature = "scripted_tool", - feature = "http_client", - feature = "python", - feature = "typescript" -))] +#[cfg(any(feature = "http_client", feature = "python", feature = "typescript"))] use std::sync::{Arc, atomic::AtomicBool}; -#[cfg(any( - feature = "scripted_tool", - feature = "http_client", - feature = "python", - feature = "typescript" -))] +#[cfg(any(feature = "http_client", feature = "python", feature = "typescript"))] use tokio::sync::Notify; -#[cfg(any( - feature = "scripted_tool", - feature = "http_client", - feature = "python", - feature = "typescript" -))] +#[cfg(any(feature = "http_client", feature = "python", feature = "typescript"))] struct ReleaseProbe(Arc); -#[cfg(any( - feature = "scripted_tool", - feature = "http_client", - feature = "python", - feature = "typescript" -))] +#[cfg(any(feature = "http_client", feature = "python", feature = "typescript"))] impl Drop for ReleaseProbe { fn drop(&mut self) { self.0.store(true, Ordering::SeqCst); } } -#[cfg(any( - feature = "scripted_tool", - feature = "http_client", - feature = "python", - feature = "typescript" -))] +#[cfg(any(feature = "http_client", feature = "python", feature = "typescript"))] async fn assert_cancel_drops_boundary( mut bash: Bash, script: &str, @@ -82,34 +55,6 @@ async fn assert_cancel_drops_boundary( ); } -#[cfg(feature = "scripted_tool")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runtime_tool_callback_obeys_request_lifecycle() { - use bashkit::{ToolArgs, ToolDef, ToolRegistry}; - - let started = Arc::new(Notify::new()); - let released = Arc::new(AtomicBool::new(false)); - let callback_started = started.clone(); - let callback_released = released.clone(); - let registry = ToolRegistry::builder() - .async_tool_fn( - ToolDef::new("pending_tool", "wait forever"), - move |_args: ToolArgs| { - let started = callback_started.clone(); - let released = callback_released.clone(); - async move { - let _probe = ReleaseProbe(released); - started.notify_one(); - std::future::pending::>().await - } - }, - ) - .build(); - let bash = Bash::builder().tool_registry(registry).build(); - - assert_cancel_drops_boundary(bash, "pending_tool", started, released).await; -} - #[cfg(feature = "http_client")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn http_transport_obeys_request_lifecycle() { diff --git a/docs/cli.md b/docs/cli.md index a67afae65..572196d11 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,7 +62,6 @@ cargo binstall bashkit-cli | `python` | on | `python`/`python3` builtin via Monty | | `sqlite` | on | `sqlite`/`sqlite3` builtin via Turso (BETA upstream) | | `realfs` | off | `--mount-ro` / `--mount-rw` host filesystem mounts | -| `scripted_tool` | off | Scripted tool orchestration | Build without interactive (library-only deps): diff --git a/docs/migrating-scripted-tool.md b/docs/migrating-scripted-tool.md new file mode 100644 index 000000000..de5ff5a20 --- /dev/null +++ b/docs/migrating-scripted-tool.md @@ -0,0 +1,92 @@ +# Migrating to bashkit-scripted-tool + +`ScriptedTool`, `ToolDef`, `ToolRegistry`, and everything built on them moved +out of the `bashkit` crate into **`bashkit-scripted-tool`**. It is published to +crates.io and released in lockstep with `bashkit` (same version number). +`bashkit` keeps the interpreter and the [`BashTool`](llm-tools.md) contract. + +The `scripted_tool` feature of `bashkit` is gone (removed after 0.18.2). Behavior is unchanged: same +types, same builders, same logic-only shell, same security defaults. + +## Rust + +### 1. Cargo.toml + +Before: + +```toml +bashkit = { version = "0.18.2", features = ["scripted_tool", "python", "typescript", "jq"] } +``` + +After: + +```toml +bashkit = { version = "0.18.2", features = ["python", "typescript", "jq"] } +bashkit-scripted-tool = { version = "0.18.2", features = ["python", "typescript", "jq"] } +``` + +Keep `bashkit` only if you use it directly (`Bash`, `BashTool`, the `Tool` +trait). `bashkit-scripted-tool` features: + +| Feature | Effect | +|---------|--------| +| `python` | `tools.*` in embedded Python for `ToolRegistry::install` (enables `bashkit/python`) | +| `typescript` | `tools.*` in embedded TypeScript for `ToolRegistry::install` (enables `bashkit/typescript`) | +| `jq` | `jq` available inside the logic-only shell (enables `bashkit/jq`) | +| `tracing` | Debug logs for sanitized callback errors | + +### 2. Imports + +Moved types now come from `bashkit_scripted_tool`. Core types (`Bash`, +`BashTool`, `Tool`, `ExecutionLimits`, ...) stay in `bashkit`. + +Before: + +```rust,ignore +use bashkit::{ScriptedTool, Tool, ToolArgs, ToolDef, ToolRegistry}; +``` + +After: + +```rust,ignore +use bashkit::Tool; +use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef, ToolRegistry}; +``` + +Moved: `ScriptedTool`, `ScriptedToolBuilder`, `ScriptingToolSet`, +`ScriptingToolSetBuilder`, `DiscoveryMode`, `DiscoverTool`, `ToolDef`, +`ToolArgs`, `ToolImpl`, `ToolCallback`, `AsyncToolCallback`, `SyncToolExec`, +`AsyncToolExec`, `CallbackKind`, `ToolDefExtension`, `ToolDefExtensionBuilder`, +`ToolDefInvocationTrace`, `ScriptedExecutionTrace`, `ScriptedCommandInvocation`, +`ScriptedCommandKind`, `ToolRegistry`, `ToolRegistryBuilder`, `ToolCall`, +`ToolCallDecision`, `ToolCallRequest`, `ToolCallSurface`. + +### 3. Registry installation + +`BashBuilder::tool_registry` is replaced by `ToolRegistry::install`, which takes +and returns the builder. + +Before: + +```rust,ignore +let bash = Bash::builder().limits(limits).tool_registry(registry).build(); +``` + +After: + +```rust,ignore +let bash = registry.install(Bash::builder().limits(limits)).build(); +``` + +Configure limits on the builder before `install`: Python and TypeScript tool +bridges read the builder's execution profile at install time. + +## Python and JavaScript + +No change in this release: the bindings re-export the same classes from the +same import paths. + +## See also + +- [Scripted tool orchestration](scripted-tools.md) +- [LLM tools](llm-tools.md) diff --git a/docs/scripted-tools.md b/docs/scripted-tools.md index 7c5bc2bb8..6fb9ecac4 100644 --- a/docs/scripted-tools.md +++ b/docs/scripted-tools.md @@ -43,13 +43,26 @@ tool calls. +## Install + +`ScriptedTool` ships in its own crate, released in lockstep with `bashkit`: + +```bash +cargo add bashkit-scripted-tool +cargo add bashkit-scripted-tool --features python,typescript # tools.* in python / ts +``` + +Coming from the old `scripted_tool` feature of `bashkit`? See +[Migrating to bashkit-scripted-tool](migrating-scripted-tool.md). + ## Building one A tool is a `ToolDef` (name, description, JSON-Schema input) paired with a callback that returns stdout on success or an error string on failure: ```rust,no_run -use bashkit::{ScriptedTool, ToolArgs, ToolDef, Tool}; +use bashkit::Tool; +use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; fn get_user(args: &ToolArgs) -> Result { let id = args.param_i64("id").ok_or("missing --id")?; @@ -83,7 +96,7 @@ Flags parse from the schema: `--id 1` becomes `{"id": 1}` (coerced per the schema's property types). Use `.async_tool_fn(def, cb)` for async callbacks, sync and async tools mix freely in one `ScriptedTool`. The full e-commerce demo lives in -[`examples/scripted_tool.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/scripted_tool.rs). +[`examples/scripted_tool.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-scripted-tool/examples/scripted_tool.rs). ## Code mode, not a file shell @@ -123,7 +136,7 @@ a real usage message. The builtin runs in the same logic-only shell and gets the same disabled filesystem, so this does not widen what a script can touch. Tool commands win on a name collision, so a builtin cannot shadow one, nor `help` or `discover`. Full example: -[`scripted_tool_clap_builtin.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/scripted_tool_clap_builtin.rs). +[`scripted_tool_clap_builtin.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs). For schema-parsed commands, adding `"additionalProperties": false` turns an unrecognised flag into an error listing the valid ones, instead of silently diff --git a/docs/start-rust.md b/docs/start-rust.md index 54685a7d9..6cdeb3f50 100644 --- a/docs/start-rust.md +++ b/docs/start-rust.md @@ -19,7 +19,7 @@ cargo add bashkit --features python cargo add bashkit --features typescript cargo add bashkit --features sqlite cargo add bashkit --features realfs -cargo add bashkit --features scripted_tool +cargo add bashkit-scripted-tool # ScriptedTool / ToolDef / ToolRegistry ``` `http_client` enables `curl`/`wget` and the network allowlist; it uses rustls diff --git a/justfile b/justfile index 3da924f90..77d6482a6 100644 --- a/justfile +++ b/justfile @@ -419,6 +419,23 @@ release-check: --allow-dirty \ --no-verify + echo "" + echo "Dry-run publish bashkit-scripted-tool..." + # Same structural proxy as the CLI: point the copy's core pin at the latest + # published bashkit. Every core feature it forwards (bash_tool, jq, python, + # typescript) is already on crates.io. + ST_TOML="$CLI_VERIFY_ROOT/workspace/crates/bashkit-scripted-tool/Cargo.toml" + sed -i.bak \ + "s/version = \"$WORKSPACE_VERSION\"/version = \"$LATEST_CORE\"/" \ + "$ST_TOML" + rm "$ST_TOML.bak" + cargo publish \ + --manifest-path "$CLI_VERIFY_ROOT/workspace/Cargo.toml" \ + -p bashkit-scripted-tool \ + --dry-run \ + --allow-dirty \ + --no-verify + echo "" echo "All release checks passed!" diff --git a/knowledge/foundations/architecture.md b/knowledge/foundations/architecture.md index f3fab0817..a0417592a 100644 --- a/knowledge/foundations/architecture.md +++ b/knowledge/foundations/architecture.md @@ -22,6 +22,7 @@ Bashkit uses a Cargo workspace with multiple crates: | Crate | Purpose | |-------|---------| | `crates/bashkit/` | Core library (parser, interpreter, VFS, builtins, tool contract) | +| `crates/bashkit-scripted-tool/` | `ScriptedTool`, `ToolDef`, `ToolRegistry` (published, lockstep with core) | | `crates/bashkit-cli/` | CLI binary | | `crates/bashkit-python/` | Python bindings (PyO3) | | `crates/bashkit-js/` | JavaScript bindings (NAPI-RS) | @@ -29,7 +30,8 @@ Bashkit uses a Cargo workspace with multiple crates: | `crates/bashkit-eval/` | LLM eval study (mira framework) | Core library modules: `parser/`, `interpreter/`, `fs/`, `builtins/`, -`network/`, `git/`, `ssh/`, `scripted_tool/`. See source, structure evolves. +`network/`, `git/`, `ssh/`. `ScriptedTool` / `ToolDef` / `ToolRegistry` live in +the separate `crates/bashkit-scripted-tool/` crate. See source, structure evolves. ### Public API diff --git a/knowledge/integrations/scripted-tool-orchestration.md b/knowledge/integrations/scripted-tool-orchestration.md index bf5281947..8467bff87 100644 --- a/knowledge/integrations/scripted-tool-orchestration.md +++ b/knowledge/integrations/scripted-tool-orchestration.md @@ -22,9 +22,9 @@ Compose tool definitions (`ToolDef`) + execution callbacks into a single `Script `ScriptedToolBuilder` and `ScriptingToolSetBuilder` also implement the shared toolkit-library contract from [the tool contract](tool-contract.md): locale-aware metadata, `build_service()`, `build_tool_definition()`, `build_input_schema()`, `build_output_schema()`, single-use `ToolExecution`. -## Feature flag +## Crate -`scripted_tool`, entire module gated behind `#[cfg(feature = "scripted_tool")]`. +Separate crate `bashkit-scripted-tool` (lib `bashkit_scripted_tool`), published to crates.io in lockstep with `bashkit` (was the `scripted_tool` feature of core until 0.18.x). Decision: core stays the sandboxed interpreter + `BashTool`; everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`, the logic-only shell profile) lives here, built only on core public APIs. Features: `python`, `typescript`, `jq` (forward to core), `tracing`. `ToolRegistry::install(builder)` replaces the old `BashBuilder::tool_registry`; it reads `BashBuilder::execution_profile()` for Python/TypeScript limits and registers `python`/`python3` + the TypeScript extension with the registry's external handler + prelude. Core extension points it depends on: `RuntimeCallContext`, `ShellFeatures` + `builtin_filter`, `Python::with_external_handler_and_prelude`, `TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `bashkit::time` (wasm-safe `Instant`/`timeout`/`sleep`). Migration guide: `docs/migrating-scripted-tool.md`. ## Motivation @@ -152,7 +152,7 @@ Enables framework patterns like LangGraph's `get_stream_writer()` and FastAPI re ### Flag parsing -Implementation and per-syntax precedence: `parse_flags` in `crates/bashkit/src/tool_def.rs`. Rules: +Implementation and per-syntax precedence: `parse_flags` in `crates/bashkit-scripted-tool/src/tool_def.rs`. Rules: - `--key value` and `--key=value` parse into a JSON object. Type coercion follows `input_schema` property types (`integer`, `number`, `boolean`, `string`, `array`, `object`); bare `--flag` is `true` when schema says boolean. Unknown flags (not in schema) stay strings, **unless** the schema sets `"additionalProperties": false`, in which case an unknown flag is an error naming the flags the schema does define. That is the same rule `ToolRegistry` applies to structured calls, so one schema describes one command on both surfaces. Decision: an open schema stays permissive because a `ToolDef` without `additionalProperties` is not asserting a closed argument set; a closed one means a typo (`--limti 10`) is a mistake, not an extra string property to pass through and silently drop. - Bounded before callback execution: parsed flag value bytes capped at 64 KiB per command invocation; array-typed flags capped at 4096 items after JSON parsing, comma splitting, and repeated-invocation appends. Oversized input fails before allocating the full `ToolArgs.params`. @@ -165,11 +165,11 @@ Implementation and per-syntax precedence: `parse_flags` in `crates/bashkit/src/t ### ScriptedToolBuilder -Two arguments per tool: definition + callback. `.tool_fn()` sync, `.async_tool_fn()` async; plus `.locale()`, `.short_description()`, `.env()`, `.limits()`, `.compact_prompt()`. Full example: `crates/bashkit/examples/scripted_tool.rs`. +Two arguments per tool: definition + callback. `.tool_fn()` sync, `.async_tool_fn()` async; plus `.locale()`, `.short_description()`, `.env()`, `.limits()`, `.compact_prompt()`. Full example: `crates/bashkit-scripted-tool/examples/scripted_tool.rs`. `.builtin(name, Box)` registers a raw-argv builtin alongside the `ToolDef` tools, mirroring `BashBuilder::builtin` and `BashToolBuilder::builtin`. Stored as `Arc` so one instance is reused by the fresh shell each `execute()` builds. -Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which was not possible while the logic-only switch was `pub(crate)` (core now exposes the generic `BashBuilder::shell_features` / `builtin_filter` switches it is built from). Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit/examples/scripted_tool_clap_builtin.rs`. +Why it exists: `parse_flags` is schema-driven and only understands `--key value`. Commands that need positionals, short flags, `--`, or parse-time required-argument checking cannot be expressed as a `ToolDef`, and before this the only workaround was to rebuild the shell on `Bash::builder()` downstream, which was not possible while the logic-only switch was `pub(crate)` (core now exposes the generic `BashBuilder::shell_features` / `builtin_filter` switches it is built from). Registering the builtin gives it `ctx.args` unparsed, so it can use [`ClapBuiltin`](../foundations/builtins.md) — the house parser for `Bash` builtins — inside a `ScriptedTool`. Example: `crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs`. Security posture is unchanged. The builtin runs in the same logic-only shell and gets the same rejecting filesystem, so it cannot reach a real file; the logic-only decision stays enforced by the profile, not by the builtin allowlist. Registration order is custom builtins first, `ToolDefExtension` second: `BashBuilder` keys custom builtins by name and later registrations win, so a custom builtin can never shadow a registered tool command, `help`, or `discover`. A custom builtin is otherwise ordinary trusted host code — whatever it reaches, the script reaches. Its fatal `Err(Error)` diagnostics cross the same `sanitize_errors` boundary as `ToolDef` callback errors: generic by default, raw only after explicit opt-out. Cancellation and resource-limit errors are preserved because callers need their categories for control flow. @@ -207,7 +207,7 @@ restricted shell the same way; the interpreter only enforces the switches. ### LLM integration -`system_prompt()` generates markdown with available tool commands, input schemas (when present), and usage tips, see the `Tool` impl in `crates/bashkit/src/scripted_tool/execute.rs`. +`system_prompt()` generates markdown with available tool commands, input schemas (when present), and usage tips, see the `Tool` impl in `crates/bashkit-scripted-tool/src/scripted_tool/execute.rs`. ### Shared context across callbacks @@ -234,15 +234,15 @@ Wraps `ScriptedTool`; `tools()` returns one or two tools by `DiscoveryMode`: ## Module location -`crates/bashkit/src/tool_def.rs` (ToolDef, ToolArgs, ToolImpl, exec types, `parse_flags`), `crates/bashkit/src/tool_registry.rs` (cross-runtime registry), and `crates/bashkit/src/scripted_tool/` (builder, extension + builtins, `Tool` impl, toolset). Public exports are gated by the `scripted_tool` feature in `lib.rs`. +`crates/bashkit-scripted-tool/src/tool_def.rs` (ToolDef, ToolArgs, ToolImpl, exec types, `parse_flags`), `crates/bashkit-scripted-tool/src/tool_registry.rs` (cross-runtime registry, `install`), and `crates/bashkit-scripted-tool/src/scripted_tool/` (builder, extension + builtins, logic-only profile, `Tool` impl, toolset). Re-exports in the crate's `lib.rs`. ## Example -`crates/bashkit/examples/scripted_tool.rs`, e-commerce API demo using `ToolDef` + closures (no trait impls). Run: `cargo run --example scripted_tool --features scripted_tool`. +`crates/bashkit-scripted-tool/examples/scripted_tool.rs`, e-commerce API demo using `ToolDef` + closures (no trait impls). Run: `cargo run -p bashkit-scripted-tool --example scripted_tool`. ## Test coverage -Unit tests in the module cover builder configuration, help/discover introspection, flag parsing and coercion, pipelines, multi-step orchestration, error handling/fallback, stdin piping, loops, env vars, Arc reuse and fresh-interpreter isolation across `execute()` calls, and shared `Arc`/`Arc>` context. Cross-runtime integration tests cover registry success, schema rejection, policy denial, sanitized callback failure, timeout cancellation, discovery, callback identity, and tenant/trace isolation. +Unit tests in the module cover builder configuration, help/discover introspection, flag parsing and coercion, pipelines, multi-step orchestration, error handling/fallback, stdin piping, loops, env vars, Arc reuse and fresh-interpreter isolation across `execute()` calls, and shared `Arc`/`Arc>` context. Cross-runtime integration tests (`crates/bashkit-scripted-tool/tests/`, CI: `cargo test -p bashkit-scripted-tool --features python,typescript,jq`) cover registry success, schema rejection, policy denial, sanitized callback failure, timeout cancellation, discovery, callback identity, and tenant/trace isolation. ## Security diff --git a/knowledge/integrations/tool-contract.md b/knowledge/integrations/tool-contract.md index ee0972c6c..34d4da838 100644 --- a/knowledge/integrations/tool-contract.md +++ b/knowledge/integrations/tool-contract.md @@ -36,7 +36,7 @@ The entire tool layer (`tool` module: `Tool` trait, `BashTool*`, `--no-default-features` drops the module and its exclusive dependencies (`tower`, `futures-core`), leaving just the embeddable `Bash` interpreter. -- `scripted_tool` builds on this layer and so enables `bash_tool`. +- The `bashkit-scripted-tool` crate builds on this layer and depends on core with `bash_tool`. - Consumers that only drive `Bash` directly (e.g. `bashkit-cli`) set `default-features = false` to avoid pulling in the tool dependencies. diff --git a/knowledge/log.md b/knowledge/log.md index 2d43c45df..0ff298557 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-06 +* **Decision**: Step 2 of the extraction landed. `crates/bashkit-scripted-tool` (published, lockstep version) now holds `ToolDef`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet` and the logic-only shell; the core `scripted_tool` feature is removed (it was an alias for `bash_tool`, so the core dependency graph is unchanged). `BashBuilder::tool_registry` became `ToolRegistry::install(builder)`. Two more generic core public APIs made that possible without crate-private access: `bashkit::time` (the wasm-safe `Instant`/`SystemTime`/`timeout`/`sleep` shim) and `BashBuilder::execution_profile()` (read the Python/TypeScript limits a builder carries). Publish order is now `bashkit` → `bashkit-scripted-tool` + `bashkit-cli`; `just release-check` dry-runs the new crate against the latest published core. Release prep owes a CHANGELOG Breaking Changes note linking `docs/migrating-scripted-tool.md`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Release Process](operations/release-process.md), [Architecture](foundations/architecture.md). * **Decision**: Everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolDefExtension`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`) leaves the core crate for a published `bashkit-scripted-tool` crate; Python/JS move the class to `bashkit.scripted` / `@everruns/bashkit/scripted`, with a public migration guide. Step 1 adds only generic public extension points to core: `RuntimeCallContext` (request extensions, budget, lease and deadline for Python/TypeScript host functions, replacing the registry's private task-local), public `Python`/`TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `BuiltinContext::{run_budgeted, remaining_deadline}`, generic restricted-shell switches `BashBuilder::shell_features(ShellFeatures)` (file redirects, process substitution, script execution) and `BashBuilder::builtin_filter`, which replace the interpreter-level `ShellProfile::LogicOnly`; the logic-only shell itself (allowlist + rejecting filesystem) now lives beside `ScriptedTool` and moves with it, public `ExecutionPlan`/`SubCommand` re-exports, and the `Tool` contract helpers. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Python Builtin](runtimes/python-builtin.md), [ZapCode Runtime](runtimes/zapcode-runtime.md), [Tool Contract](integrations/tool-contract.md). * **Feature**: [In-Process Terminal](integrations/in-process-terminal.md) behind the `terminal` feature (off by default): `Terminal` drives a `Bash` session from raw keystrokes, reports `Idle` when the session waits for input, and exposes the screen as text through the `vt100` crate. libghostty was considered and rejected because it needs Zig and C FFI, which would break the wasm and binding builds. The new `vi` builtin reads the device in raw mode and edits VFS files. Time blocked on terminal input is excluded from the execution timeout so an editor session is not killed while an agent thinks; busy work still times out (TM-DOS-120). Buffers are bounded (TM-DOS-119). Gaps recorded as L-TERM-001..003: vi is a subset, `read` does not wait for typed input, Ctrl-C lands at the next command boundary. `less` and `more` page interactively only inside a `Terminal` and stay cat-like for `exec()`/`BashTool`/CLI callers, so no existing caller can block on a pager (L-TERM-004 covers redirected stdout inside a session). diff --git a/knowledge/operations/dependencies.md b/knowledge/operations/dependencies.md index b2c005847..bb37605fd 100644 --- a/knowledge/operations/dependencies.md +++ b/knowledge/operations/dependencies.md @@ -23,7 +23,8 @@ not move together. ## What the graph actually costs Measured on `bashkit` at the browser feature set -(`--no-default-features --features scripted_tool,jq`): +(`--no-default-features --features bash_tool,jq`; the former `scripted_tool` +feature was an alias for `bash_tool`, so the numbers are unchanged): | Configuration | Crates | |---|---| diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index 41752e70c..a9beda306 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -48,10 +48,11 @@ silently failed. 1. **Determine version**: human-specified, or suggest from changes. 2. **Update CHANGELOG.md** (format below). 3. **Update version across all manifests** (must match the workspace version): - workspace `Cargo.toml`, `crates/bashkit-cli/Cargo.toml` path-dep pin on - `bashkit`, `crates/bashkit-js/package.json`, + workspace `Cargo.toml`, the `bashkit` path-dep pins in + `crates/bashkit-cli/Cargo.toml` and `crates/bashkit-scripted-tool/Cargo.toml`, + `crates/bashkit-js/package.json`, `crates/bashkit-wasm/package.json`, and `Cargo.lock` - (`cargo update -p bashkit -p bashkit-cli ...`). + (`cargo update -p bashkit -p bashkit-cli -p bashkit-scripted-tool ...`). - Refresh the **auxiliary lockfiles** too. `crates/bashkit/fuzz`, `examples/hyperlight`, and `examples/hyperlight/host` are separate workspaces whose lockfiles pin `bashkit` through a path dependency, so a @@ -80,7 +81,7 @@ silently failed. 5. **Verify publish-readiness** (catches what local tests don't, the `cargo publish` packaging step, missing files, version drift): - `cargo publish --dry-run -p bashkit` must succeed. Package - `bashkit-cli` in a disposable copy against the latest published + `bashkit-cli` and `bashkit-scripted-tool` in a disposable copy against the latest published registry core version (remove the local path in the copy) as a structural proxy; Cargo cannot resolve the CLI's new registry dependency until the core crate is live. The proxy may only ask @@ -114,7 +115,7 @@ silently failed. `publish-wasm.yml`, `cli-binaries.yml`, and `c-api-binaries.yml` to completion, run the post-release verification commands, and only declare "shipped" when all - published artifacts (`bashkit` + `bashkit-cli` on crates.io, `bashkit` + published artifacts (`bashkit`, `bashkit-scripted-tool` + `bashkit-cli` on crates.io, `bashkit` on PyPI, both npm packages, Homebrew, and the C ABI archives) report the new version. If one fails, open a hotfix PR rather than leaving the release half-shipped. @@ -177,7 +178,7 @@ a Windows runner. Confirm each target (workflow → check): - GitHub Release (`release.yml`): `gh release view vX.Y.Z` -- crates.io (`publish.yml`): `cargo search bashkit` / `cargo search bashkit-cli` +- crates.io (`publish.yml`): `cargo search bashkit` / `cargo search bashkit-scripted-tool` / `cargo search bashkit-cli` - PyPI (`publish-python.yml`): `pip index versions bashkit` - npm Node (`publish-js.yml`): `npm view @everruns/bashkit version`; `npm dist-tags ls @everruns/bashkit` ("latest" points at it) @@ -214,6 +215,7 @@ Use the latest entries in `CHANGELOG.md` as the template. Rules: ## Package Names and Registries - `bashkit` on crates.io (core library) +- `bashkit-scripted-tool` on crates.io (`ScriptedTool` / `ToolDef` / `ToolRegistry`) - `bashkit-cli` on crates.io (CLI tool) - `bashkit` on PyPI (pre-built wheels) - `@everruns/bashkit` on npm (native NAPI-RS bindings) @@ -223,7 +225,7 @@ Use the latest entries in `CHANGELOG.md` as the template. Rules: ## Publishing Order Crates publish in dependency order: `bashkit` (no internal deps) then -`bashkit-cli` (depends on bashkit). Python wheels (native matrix + the +`bashkit-scripted-tool` and `bashkit-cli` (each depends only on bashkit). Python wheels (native matrix + the reduced-feature Pyodide/Emscripten wheel, see [Emscripten Wheels](../runtimes/emscripten-wheels.md)) and both npm packages publish independently (no crates.io dependency). CI workflows handle ordering automatically on GitHub Release. @@ -254,7 +256,7 @@ Trigger: Release published. Publishes to crates.io in dependency order, then verifies published versions. Each crate first passes `cargo publish --dry-run` without registry credentials. The following `cargo publish --no-verify` upload receives the step-scoped `CARGO_REGISTRY_TOKEN`, so dependency compilation never -inherits that credential. The CLI verification runs after the core is published. +inherits that credential. The CLI and `bashkit-scripted-tool` dry-runs run after the core is published. CLI and C ABI release validation resolve the fetched tag once, validate the manifest at that immutable commit, and pass its SHA to build checkouts. The tag diff --git a/knowledge/runtimes/browser-package.md b/knowledge/runtimes/browser-package.md index 80b554c78..cf4063a5e 100644 --- a/knowledge/runtimes/browser-package.md +++ b/knowledge/runtimes/browser-package.md @@ -66,8 +66,8 @@ requirement into browser and edge bundles. ## Feature surface -Mirrors the `wasm` CI job: `scripted_tool` + `jq` on top of the default -interpreter. Present: full bash syntax, the text-tool builtins (`grep`, `sed`, +Mirrors the `wasm` CI job: `jq` on top of the default +interpreter (that job also checks `bashkit-scripted-tool` for wasm). Present: full bash syntax, the text-tool builtins (`grep`, `sed`, `awk`, `find`, `jq`, …), a binary-safe virtual filesystem, resource limits, JS custom builtins (sync + async), streaming output, cancellation, static script analysis, and content-addressed commit/checkout persistence. @@ -243,7 +243,7 @@ an empty snapshot, so deleting the root still clears persisted files. The `bashkit-wasm` crate is JS-host-only (wasm-bindgen glue). The **library** itself, however, compiles for the WASI targets with the same reduced feature -surface (`scripted_tool,jq`), which is what a WASI runtime (`wasmtime`, +surface (`jq`, plus the `bashkit-scripted-tool` crate), which is what a WASI runtime (`wasmtime`, `wasmer`) needs. CI's `wasm` job checks `wasm32-wasip2` alongside `wasm32-unknown-unknown`. diff --git a/knowledge/runtimes/emscripten-wheels.md b/knowledge/runtimes/emscripten-wheels.md index daf126323..1bd6dd4e7 100644 --- a/knowledge/runtimes/emscripten-wheels.md +++ b/knowledge/runtimes/emscripten-wheels.md @@ -64,12 +64,14 @@ Per-target dependency split: ```toml [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool","python","realfs","jq","interop","http_client","sqlite"] } +bashkit = { path = "../bashkit", features = ["python","realfs","jq","interop","http_client","sqlite"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python","jq"] } tokio = { workspace = true, features = ["rt-multi-thread"] } pyo3-async-runtimes = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -bashkit = { path = "../bashkit", features = ["scripted_tool","python","jq"] } +bashkit = { path = "../bashkit", features = ["python","jq"] } +bashkit-scripted-tool = { path = "../bashkit-scripted-tool", features = ["python","jq"] } tokio = { workspace = true } # wasm-safe base features only ``` diff --git a/knowledge/runtimes/python-builtin.md b/knowledge/runtimes/python-builtin.md index dd2e97333..2281fe9a8 100644 --- a/knowledge/runtimes/python-builtin.md +++ b/knowledge/runtimes/python-builtin.md @@ -234,7 +234,7 @@ catastrophic-backtracking DoS risk in untrusted code execution. provides *code*, not data, matches real python's no-arg behavior), command substitution, and conditionals. -With the `scripted_tool` feature, `BashBuilder::tool_registry` generates an +With the `bashkit-scripted-tool` crate (feature `python`), `ToolRegistry::install(builder)` generates an explicit `tools` namespace from dot-separated `ToolDef` names. Calls such as `tools.orders.list({"customer": "acme"})` use Monty's existing external-function suspend/resume bridge and dispatch through the registry's shared schema, policy, diff --git a/knowledge/runtimes/zapcode-runtime.md b/knowledge/runtimes/zapcode-runtime.md index aa3a2f6e0..933d690fc 100644 --- a/knowledge/runtimes/zapcode-runtime.md +++ b/knowledge/runtimes/zapcode-runtime.md @@ -144,7 +144,7 @@ for the full threat analysis. ### Tool registry access -With `scripted_tool`, `BashBuilder::tool_registry` exposes dot-separated +With the `bashkit-scripted-tool` crate (feature `typescript`), `ToolRegistry::install(builder)` exposes dot-separated `ToolDef` names as `await tools.orders.list({customer: "acme"})` and provides `tools.discover({...})`. Calls reuse the existing external-function suspend/resume bridge and the registry's one schema, policy, deadline, sanitizer, diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 870482444..9711f1dc1 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -445,7 +445,7 @@ execute permission (mode & 0o111); exit 127 missing / 126 non-executable; sheban | TM-INF-026 | Fork-PR secret exfiltration via approved CI run | GitHub's first-time-contributor gate runs the workflow **from the PR head**, and approved contributors auto-run thereafter. The `examples` job in `ci.yml` set `DOPPLER_TOKEN` at job level and `ANTHROPIC_API_KEY` at step level, so a fork PR could edit any `examples/*.rs`/`build.rs`/script the job runs and exfiltrate. `DOPPLER_TOKEN` is the master key, it fetches every other secret in the Doppler config, so one leak compromises all. Every `doppler run --` also injected the entire config into the step env | Trusted-run gates exclude PR execution from secret-backed examples. Dedicated credential-fetch steps receive `DOPPLER_TOKEN`, request one API key, mask it, and exit before build/example processes run. Execution steps receive only the requested API key through step output/env; Docker inherits it by name, without putting its value in argv. No broad token remains in the execution shell or a live Doppler parent. This is process-lifetime isolation on a trusted runner, not a sandbox against compromised dependencies persisting across steps. Regression tests execute the workflow scripts and inspect child/parent environments: `scripts/tests/test_maintenance_security.py`. CI has contents-read permission only and checkout credentials are not persisted anywhere they could reach untrusted code (TM-INF-034). Release examples install the committed dependency lockfile with lifecycle scripts disabled before linking the built artifact; `scripts/tests/test_ci_supply_chain.py` enforces both boundaries | **FIXED** | | TM-INF-027 | Package publish from unverified release refs | A user with Actions/write privileges could dispatch release/publish workflows from a branch or unprotected `vX.Y.Z` tag; `release.yml` dispatches downstream workflows with `--ref "$TAG"`, and `publish-js.yml` treated stable `package.json` semver as `latest` even on non-tag refs, an attacker-controlled ref could source official crates.io/npm/PyPI/CLI/Homebrew artifacts | Release dispatches require full ref `refs/heads/main`; `release.yml` verifies the source is reachable from `origin/main` and the created tag resolves to the current `GITHUB_SHA` before dispatching; `publish.yml` rejects manual runs unless `GITHUB_REF` is a stable release tag matching `Cargo.toml` on a main-reachable commit; `publish-js.yml` requires main-reachable commit, assigns npm `latest` only for stable `vX.Y.Z` tag refs matching `package.json`; `publish-python.yml` applies the same main-reachable stable-tag + Cargo version gates; `cli-binaries.yml` accepts only stable `vX.Y.Z` inputs and verifies workflow ref = tag = `Cargo.toml` version on a main-reachable commit before building/uploading or updating Homebrew; CLI and C ABI validation resolve the fetched tag to an immutable commit, read its checked-out manifest, and export that SHA to all build jobs, preventing tag movement from substituting unvalidated source. Real Git tag-movement regressions: `scripts/tests/test_release_security.py` | **FIXED** | | TM-INF-028 | JS callback errors expose host stack traces | When a caller-supplied output or custom-builtin callback throws, propagating `error.stack` or an unsanitized message can expose host file paths and internal function names across the sandbox boundary | Native and wasm bindings propagate `error.message` only, strip absolute-path and `file://` segments, and cap diagnostics at 256 characters. Regressions: `onOutput error does not leak stack trace or host paths` in `crates/bashkit-js/__test__/streaming-output.spec.ts` and `callback errors do not leak host paths or stacks` in `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs`; mitigations annotated `THREAT[TM-INF-028]` in both binding implementations | **FIXED** | -| TM-INF-030 | Raw callback or custom-builtin errors leak host internals through ScriptedTool/ToolImpl/ToolRegistry | Tool callbacks and operator-provided ScriptedTool builtins may return errors containing API keys, connection strings, or stack traces; output is attacker-influenced and agent-visible | `sanitize_errors` defaults to true on `ScriptedTool`, `ToolImpl`, and the cross-runtime `ToolRegistry`; ScriptedTool wraps custom builtins so fatal errors use the same generic message while cancellation/resource-limit errors remain actionable; every shell/Python/TypeScript registry surface applies the boundary (`scripted_tool/execute.rs`, `scripted_tool/mod.rs`, `tool_def.rs`, `tool_registry.rs`) | **MITIGATED** | +| TM-INF-030 | Raw callback or custom-builtin errors leak host internals through ScriptedTool/ToolImpl/ToolRegistry | Tool callbacks and operator-provided ScriptedTool builtins may return errors containing API keys, connection strings, or stack traces; output is attacker-influenced and agent-visible | `sanitize_errors` defaults to true on `ScriptedTool`, `ToolImpl`, and the cross-runtime `ToolRegistry`; ScriptedTool wraps custom builtins so fatal errors use the same generic message while cancellation/resource-limit errors remain actionable; every shell/Python/TypeScript registry surface applies the boundary (`bashkit-scripted-tool`: `scripted_tool/execute.rs`, `scripted_tool/mod.rs`, `tool_def.rs`, `tool_registry.rs`) | **MITIGATED** | | TM-INF-031 | `final_env` capture bypasses output filtering and size caps | When `capture_final_env` is enabled, env contents are a user-visible output channel that could leak internal markers or exceed output limits | Visibility filtering + output-byte cap applied when building `final_env` (`interpreter/mod.rs`) | **MITIGATED** | | TM-INF-032 | Competitor fixture import executes untrusted upstream code in CI | Automatically downloading or mechanically copying third-party regression scripts into a host-shell oracle would let a compromised upstream run arbitrary commands with CI permissions; a source commit hash proves provenance, not trust | Competitor corpora are manually reviewed, data-only JSON checked into the repository with full commit provenance; CI performs no fetch; Bashkit cases run in the VFS with normal resource limits; only cases explicitly marked `real_bash` run under the host oracle, with a cleared environment and temporary cwd. The schema/classification gate is `competitor_corpus_has_complete_provenance_and_classification`; process contract is [Testing Strategy](../operations/testing.md) | **MITIGATED** | | TM-INF-033 | `time` becomes a high-resolution host timing oracle or exposes host-process metrics | Untrusted scripts repeatedly measure small operations or request GNU CPU/RSS fields to infer co-tenant and host behavior | Monotonic elapsed time follows Bashkit's portable/virtual clock; the Hardened profile floors elapsed values into 100 ms buckets; CPU, RSS, and other host-process fields deterministically report `unavailable`; verbose output includes only Bashkit-owned counters | **MITIGATED** | @@ -948,7 +948,7 @@ Only exact domain matches are allowed (TM-NET-017). | TM-ISO-023 | `set -e` leaks across `exec()` calls | `set` options (`-e`, `-x`, etc.) persist across `exec()` calls, causing unexpected abort behavior | `reset_transient_state()` clears `SET_OPTION_VARS` | **FIXED** | | TM-ISO-024 | `$?` leaks into VFS subprocess | Parent `last_exit_code` visible inside VFS script subprocess, causing false `set -e` failures | `execute_script_content()` sets `last_exit_code = 0`, clears `nounset_error`, and clears `traps` for the child | **MITIGATED** | | TM-ISO-025 | Wrapper rebuild silently drops or restores revoked capabilities | A binding's `reset()` or fresh-execution path rebuilds `Bash` without limits, policy files, callbacks, network policy, or other host configuration, or replays an explicitly revoked mount, changing the sandbox contract after the first call | The canonical capability manifest requires executable evidence for every supported surface cell. NAPI `SharedState` retains constructor `files`, and `build_bash_from_state` is the single rebuild path used by construction and reset. Host mutations applied *after* construction are recorded on the same path: NAPI `runtime_env`/`runtime_mounts` and the Python equivalents replay `set_env()` and runtime mounts on every rebuild. Runtime mount replay keys use the VFS-normalized mount point, so `unmount()` retracts the record even when mount and unmount callers use equivalent paths containing `.` or `..`; a rebuild cannot resurrect the removed mount. Script-set env is not recorded, so `reset()` still discards it. Regressions: `Bash: reset restores configured files`, `Bash: reset preserves setEnv values`, `Bash: reset preserves a runtime FileSystem mount`, `Bash: unmount retracts the replay, so reset does not resurrect it`, `Bash: unmount retracts an equivalent normalized mount path`, `test_reset_preserves_set_env`, `test_unmount_retracts_replay`, `test_unmount_retracts_equivalent_normalized_mount_path` | **MITIGATED** | -| TM-ISO-026 | Shared ToolRegistry leaks tenant identity or traces across requests/runtimes | One immutable registry and callback set may serve concurrent tenants through shell, Python, and TypeScript; registry-global mutable request state would cross-contaminate authorization and telemetry | `ToolCallRequest` carries tenant + a fresh bounded trace through `ExecutionExtensions`; runtime bridges expose it through the core `RuntimeCallContext` task-local only for the current run; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`tool_registry.rs`) | **MITIGATED** | +| TM-ISO-026 | Shared ToolRegistry leaks tenant identity or traces across requests/runtimes | One immutable registry and callback set may serve concurrent tenants through shell, Python, and TypeScript; registry-global mutable request state would cross-contaminate authorization and telemetry | `ToolCallRequest` carries tenant + a fresh bounded trace through `ExecutionExtensions`; runtime bridges expose it through the core `RuntimeCallContext` task-local only for the current run; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`bashkit-scripted-tool/src/tool_registry.rs`) | **MITIGATED** | | TM-ISO-027 | Request-owned execution authority or retained facility handles survive completion | A retained runtime, transport, callback, budget, extension, or custom-builtin VFS handle emits late output, charges a reused request, or accesses facilities after completion/cancellation | One `ExecutionBudget` and one capability lease are created before initialization and closed/revoked on every exit. The budget gates work and async results; `ExecutionCapability`, scoped VFS wrappers, host-call brokers, and `ToolArgs` context gate retained facilities. Cleanup is bounded/idempotent and reports only sanitized counts. `BuiltinRegistry::insert_trusted` explicitly models the intentional unscoped host escape hatch. Shared lifecycle evidence is defined in [Request Execution Lifecycle](request-lifecycle.md); Rust and Python capability regressions cover late/cross-request/cancellation use and cleanup failure. | **MITIGATED** | **TM-ISO-004**: Fixed, see table; env wiring in `builtins/jq/compat.rs` and `builtins/jq/mod.rs`. @@ -2251,7 +2251,7 @@ jq (jaq crate); sort/uniq (comparison-based, no byte indexing); logging (`loggin `is_char_boundary()`); python builtin + Python bindings (PyO3 UTF-8 handling, only ASCII `find('\n')`); eval harness (`Iterator::find`, `chars().take()`, `from_utf8_lossy()`); curl/bc/export/date/comm/echo/archive/base64 (`.find()`/slicing only on single-byte ASCII -delimiters); scripted_tool (no byte/char patterns). +delimiters); bashkit-scripted-tool (no byte/char patterns). ### Unicode Security Summary diff --git a/site/src/pages/docs/_meta.ts b/site/src/pages/docs/_meta.ts index caa1d061c..8982a3fed 100644 --- a/site/src/pages/docs/_meta.ts +++ b/site/src/pages/docs/_meta.ts @@ -138,6 +138,16 @@ export const DOC_META: DocMeta[] = [ section: "LLM tools", editPath: "docs/scripted-tools.md", }, + { + slug: "migrating-scripted-tool", + title: "Migrating to bashkit-scripted-tool", + summary: "Move ScriptedTool, ToolDef, and ToolRegistry code to the new crate.", + seoTitle: "Migrate from the bashkit scripted_tool feature to bashkit-scripted-tool", + seoDescription: + "Cargo.toml, import, and ToolRegistry::install changes for code that used the scripted_tool feature of bashkit.", + section: "LLM tools", + editPath: "docs/migrating-scripted-tool.md", + }, { slug: "configuration", title: "Sandbox configuration & limits", diff --git a/skills/bashkit/references/rust.md b/skills/bashkit/references/rust.md index 06a48386b..29d2f0794 100644 --- a/skills/bashkit/references/rust.md +++ b/skills/bashkit/references/rust.md @@ -20,7 +20,7 @@ cargo add bashkit --features python cargo add bashkit --features typescript cargo add bashkit --features sqlite cargo add bashkit --features realfs -cargo add bashkit --features scripted_tool +cargo add bashkit-scripted-tool # ScriptedTool / ToolDef / ToolRegistry ``` ## Minimal Execution From ba226237117a2f3a52f65f4b9e55f558e3b8c05b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 6 Oct 2026 22:04:31 +0000 Subject: [PATCH 2/2] fix(site): route migration guide link and shorten its SEO title Claude-Session: https://claude.ai/code/session_01LSQwMLncQmLPG9CYatpbVk --- site/astro.config.mjs | 1 + site/src/pages/docs/_meta.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/site/astro.config.mjs b/site/astro.config.mjs index a8a453c7c..bfbc923f0 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -29,6 +29,7 @@ const DOC_LINKS = new Map([ ["live-mounts.md", "/docs/live-mounts/"], ["llm-tools.md", "/docs/llm-tools/"], ["logging.md", "/docs/logging/"], + ["migrating-scripted-tool.md", "/docs/migrating-scripted-tool/"], ["networking.md", "/docs/networking/"], ["python.md", "/docs/python/"], ["request-signing.md", "/docs/request-signing/"], diff --git a/site/src/pages/docs/_meta.ts b/site/src/pages/docs/_meta.ts index 8982a3fed..02f3b85e5 100644 --- a/site/src/pages/docs/_meta.ts +++ b/site/src/pages/docs/_meta.ts @@ -142,7 +142,7 @@ export const DOC_META: DocMeta[] = [ slug: "migrating-scripted-tool", title: "Migrating to bashkit-scripted-tool", summary: "Move ScriptedTool, ToolDef, and ToolRegistry code to the new crate.", - seoTitle: "Migrate from the bashkit scripted_tool feature to bashkit-scripted-tool", + seoTitle: "Migrate from scripted_tool to bashkit-scripted-tool", seoDescription: "Cargo.toml, import, and ToolRegistry::install changes for code that used the scripted_tool feature of bashkit.", section: "LLM tools",