diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index cb42b60c6..a376b8acd 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -7,6 +7,7 @@ "output_caps": "Bounded stdout and stderr with truncation reporting", "cwd": "Initial virtual working directory", "env": "Initial virtual environment", + "runtime_env": "Host environment applied after construction", "stdin": "Host-provided stdin for an execution", "custom_builtins": "Host callbacks registered as shell builtins", "host_call_suspension": "Host-visible process-local builtin suspension and resume", @@ -23,7 +24,7 @@ "surfaces": { "rust_bash": { "label": "Rust BashBuilder", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "stdin", "custom_builtins", "host_call_suspension", "network_policy", "transport_hooks", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_typescript", "runtime_sqlite"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "stdin", "custom_builtins", "host_call_suspension", "network_policy", "transport_hooks", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_typescript", "runtime_sqlite"], "evidence": { "execution_limits": ["crates/bashkit/tests/integration/blackbox_security_tests.rs#max_commands_resets_per_exec"], "cancellation": ["crates/bashkit/tests/integration/cancellation_tests.rs#cancel_aborts_execution"], @@ -31,6 +32,7 @@ "output_caps": ["crates/bashkit/tests/integration/output_truncation_tests.rs#stdout_truncation"], "cwd": ["crates/bashkit/tests/integration/snapshot_tests.rs#snapshot_preserves_cwd"], "env": ["crates/bashkit/tests/integration/snapshot_tests.rs#snapshot_preserves_env"], + "runtime_env": ["crates/bashkit/tests/integration/runtime_env_tests.rs#set_env_is_visible_to_scripts"], "stdin": ["crates/bashkit/tests/integration/exec_options_tests.rs#stdin_feeds_cat"], "custom_builtins": ["crates/bashkit/tests/integration/custom_builtins_tests.rs#test_custom_builtin_simple"], "host_call_suspension": ["crates/bashkit/tests/integration/host_call_execution_tests.rs#host_call_suspends_and_resumes_the_live_execution"], @@ -66,6 +68,7 @@ "runtime_sqlite": ["crates/bashkit/src/tool.rs#test_configure_forwards_sqlite_runtime_opt_in"] }, "unsupported": { + "runtime_env": "BashTool builds a fresh Bash per execution, so there is no live instance to mutate; env is configured on the builder.", "cancellation": "ToolExecution has no host cancellation handle; callers can drop the future or set a deadline.", "stdin": "ToolRequest accepts commands and timeout_ms only; scripts can still create internal pipelines.", "host_call_suspension": "BashTool drives each fresh Bash execution to completion and exposes no event handle.", @@ -85,6 +88,7 @@ "tool_callbacks": ["crates/bashkit/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.", "cancellation": "No cancellation handle is exposed; per-call timeout_ms is the abort boundary.", "cwd": "ScriptedTool intentionally uses a logic-only shell without filesystem-backed cwd configuration.", "custom_builtins": "Only registered ToolDef callbacks become commands; arbitrary Bash builtins are not accepted.", @@ -114,6 +118,7 @@ "runtime_sqlite": ["crates/bashkit-cli/tests/cli_oneshot.rs#sqlite_is_enabled_by_default"] }, "unsupported": { + "runtime_env": "The CLI owns the shell lifecycle end to end and exposes no post-construction host API.", "cancellation": "No programmatic cancellation API exists at the process boundary.", "cwd": "The CLI exposes no initial virtual-cwd flag.", "env": "The CLI deliberately does not inherit or expose host environment injection flags.", @@ -127,7 +132,7 @@ }, "python": { "label": "Python", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"], "evidence": { "execution_limits": ["crates/bashkit-python/tests/_bashkit_categories.py#test_max_commands_limits_execution"], "cancellation": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_cancel_after_reset_still_works"], @@ -135,6 +140,7 @@ "output_caps": ["crates/bashkit-python/tests/_bashkit_categories.py#test_output_is_capped_and_reports_truncation"], "cwd": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_cwd_option_sets_starting_directory"], "env": ["crates/bashkit-python/tests/_bashkit_categories.py#test_bash_env_option_exposes_variables"], + "runtime_env": ["crates/bashkit-python/tests/test_runtime_extension.py#test_set_env_is_visible_to_scripts"], "custom_builtins": ["crates/bashkit-python/tests/test_registered_tools.py#test_custom_builtins_persist_vfs_across_calls"], "tool_callbacks": ["crates/bashkit-python/tests/_bashkit_categories.py#test_scripted_tool_stdin_pipe"], "network_policy": ["crates/bashkit-python/tests/test_network_config.py#test_bash_from_snapshot_with_network"], @@ -153,7 +159,7 @@ }, "napi": { "label": "NAPI JavaScript", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "runtime_env", "custom_builtins", "tool_callbacks", "network_policy", "virtual_filesystem", "mounts", "snapshots", "runtime_python", "runtime_sqlite"], "evidence": { "execution_limits": ["crates/bashkit-js/__test__/security.spec.ts#WB: command limit is enforced"], "cancellation": ["crates/bashkit-js/__test__/basic.spec.ts#Bash: cancel works after reset"], @@ -161,6 +167,7 @@ "output_caps": ["crates/bashkit-js/__test__/security.spec.ts#WB: stdout truncation flag on large output"], "cwd": ["crates/bashkit-js/__test__/cwd-env-options.spec.ts#cwd option sets the starting working directory"], "env": ["crates/bashkit-js/__test__/cwd-env-options.spec.ts#env option exposes variables without an export prelude"], + "runtime_env": ["crates/bashkit-js/__test__/runtime-extension.spec.ts#Bash: setEnv is visible to scripts"], "custom_builtins": ["crates/bashkit-js/__test__/custom-builtins.spec.ts#reset() preserves host-registered builtins"], "tool_callbacks": ["crates/bashkit-js/__test__/integration.spec.ts#ScriptedTool"], "network_policy": ["crates/bashkit-js/__test__/network.spec.ts#curl to a URL outside the allowlist is denied"], @@ -192,6 +199,7 @@ "snapshots": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#commit and checkout restore persistent state"] }, "unsupported": { + "runtime_env": "The slim wasm package exposes no post-construction host mutation API; env is construction-only.", "stdin": "execute() accepts only a command string; pipelines still provide builtin stdin.", "host_call_suspension": "Browser custom builtins await JavaScript callbacks directly; no request and resume handle is exposed.", "tool_callbacks": "The slim browser package exposes custom builtins but no ScriptedTool class.", @@ -215,6 +223,7 @@ "virtual_filesystem": ["crates/bashkit-capi/tests/abi.rs#reads_and_writes_binary_vfs_content"] }, "unsupported": { + "runtime_env": "The C ABI exposes no post-construction configuration surface.", "cancellation": "ABI v1 exposes synchronous execute with no cancellation handle.", "stdin": "ABI v1 execute accepts script bytes only.", "custom_builtins": "ABI v1 has no callback registration table.", diff --git a/crates/bashkit-js/__test__/runtime-extension.spec.ts b/crates/bashkit-js/__test__/runtime-extension.spec.ts new file mode 100644 index 000000000..7aa63ad31 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-extension.spec.ts @@ -0,0 +1,188 @@ +// Runtime host mutations — setEnv + mounts applied after construction, and +// their survival across reset() (issue #2291). +// +// The motivating shape is an "extension": a reusable function that mounts a +// filesystem, sets env, and registers a builtin on an existing instance. All +// three parts must behave the same way across reset(), or the extension is +// half-installed after a rebuild. + +import test from "ava"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Bash, BashTool, FileSystem } from "../wrapper.js"; + +// ---------------------------------------------------------------------------- +// setEnv — live application +// ---------------------------------------------------------------------------- + +test("Bash: setEnv is visible to scripts", (t) => { + const bash = new Bash(); + bash.setEnv("SKILL_PATH", "/skills/my-skill"); + t.is(bash.executeSync("echo $SKILL_PATH").stdout, "/skills/my-skill\n"); +}); + +test("Bash: setEnv is exported", (t) => { + const bash = new Bash(); + bash.setEnv("SKILL_PATH", "/skills/my-skill"); + t.is( + bash.executeSync("env | grep '^SKILL_PATH='").stdout, + "SKILL_PATH=/skills/my-skill\n", + ); +}); + +test("Bash: setEnv preserves existing shell state", (t) => { + const bash = new Bash(); + bash.executeSync("export EXISTING=kept"); + bash.setEnv("ADDED", "new"); + t.is(bash.executeSync("echo $EXISTING $ADDED").stdout, "kept new\n"); +}); + +test("Bash: setEnv overrides constructor env", (t) => { + const bash = new Bash({ env: { SKILL_PATH: "/from-options" } }); + bash.setEnv("SKILL_PATH", "/from-setenv"); + t.is(bash.executeSync("echo $SKILL_PATH").stdout, "/from-setenv\n"); +}); + +test("BashTool: setEnv is visible to scripts", (t) => { + const tool = new BashTool(); + tool.setEnv("SKILL_PATH", "/skills/my-skill"); + t.is(tool.executeSync("echo $SKILL_PATH").stdout, "/skills/my-skill\n"); +}); + +// ---------------------------------------------------------------------------- +// reset() replay — env +// ---------------------------------------------------------------------------- + +test("Bash: reset preserves setEnv values", (t) => { + const bash = new Bash(); + bash.setEnv("SKILL_PATH", "/skills/my-skill"); + bash.reset(); + t.is(bash.executeSync("echo $SKILL_PATH").stdout, "/skills/my-skill\n"); +}); + +test("Bash: reset keeps last setEnv value for a key", (t) => { + const bash = new Bash(); + bash.setEnv("SKILL_PATH", "/first"); + bash.setEnv("SKILL_PATH", "/second"); + bash.reset(); + t.is(bash.executeSync("echo $SKILL_PATH").stdout, "/second\n"); +}); + +test("Bash: reset discards script-set env", (t) => { + const bash = new Bash(); + bash.executeSync("export SCRIPT_ONLY=transient"); + bash.reset(); + t.is(bash.executeSync("echo [$SCRIPT_ONLY]").stdout, "[]\n"); +}); + +test("Bash: reset keeps setEnv override of constructor env", (t) => { + const bash = new Bash({ env: { SKILL_PATH: "/from-options" } }); + bash.setEnv("SKILL_PATH", "/from-setenv"); + bash.reset(); + t.is(bash.executeSync("echo $SKILL_PATH").stdout, "/from-setenv\n"); +}); + +test("BashTool: reset preserves setEnv values", (t) => { + const tool = new BashTool(); + tool.setEnv("SKILL_PATH", "/skills/my-skill"); + tool.reset(); + t.is(tool.executeSync("echo $SKILL_PATH").stdout, "/skills/my-skill\n"); +}); + +// ---------------------------------------------------------------------------- +// reset() replay — filesystem mounts +// ---------------------------------------------------------------------------- + +test("Bash: reset preserves a runtime FileSystem mount", (t) => { + const data = new FileSystem(); + data.writeFile("/SKILL.md", "# my-skill\n"); + + const bash = new Bash(); + bash.mount("/skills/my-skill", data); + t.is( + bash.executeSync("cat /skills/my-skill/SKILL.md").stdout, + "# my-skill\n", + ); + + bash.reset(); + t.is( + bash.executeSync("cat /skills/my-skill/SKILL.md").stdout, + "# my-skill\n", + ); +}); + +test("Bash: unmount retracts the replay, so reset does not resurrect it", (t) => { + const data = new FileSystem(); + data.writeFile("/SKILL.md", "# my-skill\n"); + + const bash = new Bash(); + bash.mount("/skills/my-skill", data); + bash.unmount("/skills/my-skill"); + bash.reset(); + + t.not(bash.executeSync("cat /skills/my-skill/SKILL.md 2>&1").exitCode, 0); +}); + +test("Bash: reset preserves a runtime host directory mount", (t) => { + const dir = mkdtempSync(path.join(tmpdir(), "bashkit-ext-")); + try { + writeFileSync(path.join(dir, "SKILL.md"), "# host skill\n"); + + const bash = new Bash({ allowedMountPaths: [dir] }); + bash.mount(dir, "/skills/host-skill"); + t.is( + bash.executeSync("cat /skills/host-skill/SKILL.md").stdout, + "# host skill\n", + ); + + bash.reset(); + t.is( + bash.executeSync("cat /skills/host-skill/SKILL.md").stdout, + "# host skill\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("BashTool: reset preserves a runtime FileSystem mount", (t) => { + const data = new FileSystem(); + data.writeFile("/SKILL.md", "# my-skill\n"); + + const tool = new BashTool(); + tool.mount("/skills/my-skill", data); + tool.reset(); + + t.is( + tool.executeSync("cat /skills/my-skill/SKILL.md").stdout, + "# my-skill\n", + ); +}); + +// ---------------------------------------------------------------------------- +// The whole extension shape, end to end +// ---------------------------------------------------------------------------- + +test("Bash: a mount+env+builtin bundle survives reset intact", async (t) => { + const SKILL_PATH = "/skills/my-skill"; + const data = new FileSystem(); + data.writeFile("/SKILL.md", "# my-skill\n"); + + const installSkill = (bash: Bash): void => { + bash.mount(SKILL_PATH, data); + bash.setEnv("SKILL_PATH", SKILL_PATH); + bash.addBuiltin("my-skill", (ctx) => + ctx.fs.readFile(`${SKILL_PATH}/SKILL.md`), + ); + }; + + const bash = new Bash(); + installSkill(bash); + bash.reset(); + + // Custom builtins need the event loop, so this path is async. + const result = await bash.execute('my-skill; cat "$SKILL_PATH/SKILL.md"'); + t.is(result.exitCode, 0); + t.is(result.stdout, "# my-skill\n# my-skill\n"); +}); diff --git a/crates/bashkit-js/src/lib.rs b/crates/bashkit-js/src/lib.rs index b9b98e428..2e5bef497 100644 --- a/crates/bashkit-js/src/lib.rs +++ b/crates/bashkit-js/src/lib.rs @@ -1574,6 +1574,80 @@ struct SharedState { /// `addBuiltin()` calls insert into the live interpreter without rebuilding /// it (and without disturbing the VFS). host_registry: BuiltinRegistry, + /// Host mutations applied *after* construction — `setEnv()` and the + /// runtime `mount*()` APIs. + /// + /// THREAT[TM-ISO-025]: `reset()` must not silently drop host capabilities. + /// Constructor options are replayed from the fields above; recording the + /// runtime equivalents here puts them on the same rebuild path, so a bundle + /// of setup installed on a live instance (mount + env + builtins, the + /// extension shape from issue #2291) is whole after a reset instead of + /// half-applied. Script-set env is deliberately *not* recorded — only what + /// the host asked for survives. + runtime_env: RuntimeEnvLog, + runtime_mounts: RuntimeMountLog, +} + +/// Ordered log of host `setEnv()` calls, replayed on rebuild. +/// +/// A `Vec` rather than a map so replay follows call order. One entry per key: +/// re-setting a key replaces its entry, which keeps last-write-wins semantics +/// and bounds the log by distinct key count — a host that calls `setEnv()` per +/// request must not accumulate an entry per call. +type RuntimeEnvLog = Arc>>; + +/// Ordered log of host runtime mounts, replayed on rebuild. +type RuntimeMountLog = Arc>>; + +/// A mount applied to a live instance, retained so `reset()` can replay it. +#[derive(Clone)] +enum RuntimeMount { + /// Host directory mount (`mount(hostPath, vfsPath, writable)`). Replayed + /// through the builder, the same path constructor mounts take, so the + /// allowlist check applies identically on every rebuild. + Real { + host_path: String, + vfs_path: String, + writable: bool, + }, + /// Filesystem-handle mount (`mount(vfsPath, fs)`). The `Arc` is retained, + /// so a replayed mount is the *same* filesystem, not a copy — writes made + /// through it before the reset are still there afterwards. + Fs { + vfs_path: String, + fs: Arc, + }, +} + +impl RuntimeMount { + fn vfs_path(&self) -> &str { + match self { + RuntimeMount::Real { vfs_path, .. } | RuntimeMount::Fs { vfs_path, .. } => vfs_path, + } + } +} + +/// Record a host `setEnv()` call for replay on the next rebuild. +fn record_runtime_env(log: &RuntimeEnvLog, key: &str, value: &str) { + let mut entries = log.lock().expect("runtime env log poisoned"); + entries.retain(|(existing, _)| existing != key); + entries.push((key.to_string(), value.to_string())); +} + +/// Record a host runtime mount for replay on the next rebuild. +/// +/// A mount at an already-recorded path replaces that record: the live VFS keeps +/// one filesystem per mount point, so the replay must too. +fn record_runtime_mount(log: &RuntimeMountLog, mount: RuntimeMount) { + let mut mounts = log.lock().expect("runtime mount log poisoned"); + mounts.retain(|existing| existing.vfs_path() != mount.vfs_path()); + mounts.push(mount); +} + +/// Retract a recorded mount so `reset()` does not resurrect it after `unmount()`. +fn forget_runtime_mount(log: &RuntimeMountLog, vfs_path: &str) { + let mut mounts = log.lock().expect("runtime mount log poisoned"); + mounts.retain(|existing| existing.vfs_path() != vfs_path); } /// Wrapper for the external handler that can be stored and cloned. @@ -2325,6 +2399,11 @@ impl Bash { &host_path, "Bash.mount", )?; + let recorded = RuntimeMount::Real { + host_path: host_path.clone(), + vfs_path: vfs_path.clone(), + writable: is_writable, + }; block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; let mode = if is_writable { @@ -2337,7 +2416,9 @@ impl Bash { .map_err(|e| napi::Error::from_reason(e.to_string()))?; let fs: Arc = Arc::new(PosixFs::new(real_backend)); bash.mount(Path::new(&vfs_path), fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + record_runtime_mount(&s.runtime_mounts, recorded); + Ok(()) }) } @@ -2347,8 +2428,16 @@ impl Bash { let mounted_fs = import_external_file_system(fs)?; block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) + bash.mount(Path::new(&vfs_path), Arc::clone(&mounted_fs)) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + record_runtime_mount( + &s.runtime_mounts, + RuntimeMount::Fs { + vfs_path, + fs: mounted_fs, + }, + ); + Ok(()) }) } @@ -2358,7 +2447,25 @@ impl Bash { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; bash.unmount(Path::new(&vfs_path)) - .map_err(|e| napi::Error::from_reason(e.to_string())) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + forget_runtime_mount(&s.runtime_mounts, &vfs_path); + Ok(()) + }) + } + + /// Set an exported environment variable on the live interpreter. + /// + /// The env counterpart to runtime `mount()`: usable after construction, so + /// a reusable setup bundle can apply mounts, env, and builtins to an + /// existing instance instead of only through `BashOptions`. Survives + /// `reset()` — like `customBuiltins`, and unlike env a *script* exported. + #[napi] + pub fn set_env(&self, key: String, value: String) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let mut bash = s.inner.lock().await; + bash.set_env(&key, &value); + record_runtime_env(&s.runtime_env, &key, &value); + Ok(()) }) } @@ -2971,6 +3078,11 @@ impl BashTool { &host_path, "BashTool.mount", )?; + let recorded = RuntimeMount::Real { + host_path: host_path.clone(), + vfs_path: vfs_path.clone(), + writable: is_writable, + }; block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; let mode = if is_writable { @@ -2983,7 +3095,9 @@ impl BashTool { .map_err(|e| napi::Error::from_reason(e.to_string()))?; let fs: Arc = Arc::new(PosixFs::new(real_backend)); bash.mount(Path::new(&vfs_path), fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + record_runtime_mount(&s.runtime_mounts, recorded); + Ok(()) }) } @@ -2993,8 +3107,16 @@ impl BashTool { let mounted_fs = import_external_file_system(fs)?; block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) - .map_err(|e| napi::Error::from_reason(e.to_string())) + bash.mount(Path::new(&vfs_path), Arc::clone(&mounted_fs)) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + record_runtime_mount( + &s.runtime_mounts, + RuntimeMount::Fs { + vfs_path, + fs: mounted_fs, + }, + ); + Ok(()) }) } @@ -3004,7 +3126,22 @@ impl BashTool { block_on_with(&self.state, |s| async move { let bash = s.inner.lock().await; bash.unmount(Path::new(&vfs_path)) - .map_err(|e| napi::Error::from_reason(e.to_string())) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + forget_runtime_mount(&s.runtime_mounts, &vfs_path); + Ok(()) + }) + } + + /// Set an exported environment variable on the live interpreter. + /// + /// See [`Bash::set_env`]. Survives `reset()`. + #[napi] + pub fn set_env(&self, key: String, value: String) -> napi::Result<()> { + block_on_with(&self.state, |s| async move { + let mut bash = s.inner.lock().await; + bash.set_env(&key, &value); + record_runtime_env(&s.runtime_env, &key, &value); + Ok(()) }) } @@ -3547,6 +3684,29 @@ fn build_bash_from_state(state: &SharedState) -> RustBash { } } + // Replay host directory mounts that were applied at runtime. They ride the + // builder alongside constructor mounts so the allowlist check and host-path + // bookkeeping are identical on every rebuild. + let runtime_mounts = state + .runtime_mounts + .lock() + .expect("runtime mount log poisoned") + .clone(); + for mount in &runtime_mounts { + if let RuntimeMount::Real { + host_path, + vfs_path, + writable, + } = mount + { + builder = if *writable { + builder.mount_real_readwrite_at(host_path, vfs_path) + } else { + builder.mount_real_readonly_at(host_path, vfs_path) + }; + } + } + // Enable Python/Monty. Passing `python: true` from JS is the explicit // opt-in that must also flip the in-process Python env gate. if state.python { @@ -3585,7 +3745,32 @@ fn build_bash_from_state(state: &SharedState) -> RustBash { // `addBuiltin()` are visible to the running interpreter without rebuilding. builder = builder.builtin_registry(state.host_registry.clone()); - builder.build() + let mut bash = builder.build(); + + // Replay filesystem-handle mounts, which have no builder equivalent — they + // carry a live `Arc`, so they attach to the instance after build. + for mount in &runtime_mounts { + if let RuntimeMount::Fs { vfs_path, fs } = mount { + // A recorded mount was accepted once on a live instance; a failure + // here means the path is no longer mountable (e.g. shadowed by a + // constructor mount). Skipping keeps reset infallible, and the + // absent mount is observable, unlike a panic mid-rebuild. + let _ = bash.mount(Path::new(vfs_path), Arc::clone(fs)); + } + } + + // Replay host env last so it wins over constructor `env` for the same key, + // matching what happened on the live instance before the rebuild. + for (key, value) in state + .runtime_env + .lock() + .expect("runtime env log poisoned") + .iter() + { + bash.set_env(key, value); + } + + bash } /// Build a `SharedState` from `BashOptions`, wiring up all config + interpreter. @@ -3605,6 +3790,10 @@ fn shared_state_from_opts( // A single registry handle threaded through the tmp + final SharedState // *and* the built interpreter — all observe the same underlying storage. let host_registry = BuiltinRegistry::new(); + // Same sharing rule for the runtime mutation logs: `reset()` rebuilds + // through the tmp-state path, so both handles must see the same entries. + let runtime_env: RuntimeEnvLog = Arc::new(std::sync::Mutex::new(Vec::new())); + let runtime_mounts: RuntimeMountLog = Arc::new(std::sync::Mutex::new(Vec::new())); // Build a temporary SharedState to pass to build_bash_from_state let tmp = SharedState { @@ -3646,6 +3835,8 @@ fn shared_state_from_opts( external_functions: ext_fns.clone(), external_handler: external_handler.clone(), host_registry: host_registry.clone(), + runtime_env: runtime_env.clone(), + runtime_mounts: runtime_mounts.clone(), }; if let Some(ref mounts) = mounts { @@ -3700,6 +3891,8 @@ fn shared_state_from_opts( external_functions: ext_fns, external_handler, host_registry, + runtime_env, + runtime_mounts, }) } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 08b3f12bb..194adb494 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -900,6 +900,30 @@ export class Bash { this.native.removeBuiltin(name); } + /** + * Set an exported environment variable on the live interpreter. + * + * The env counterpart to runtime {@link Bash.mount} — usable after + * construction, so a reusable setup bundle (mount + env + builtins) can be + * applied to an existing instance instead of only through + * {@link BashOptions}. Scripts see it as `$NAME`; child contexts see it in + * `env`. A later script assignment wins. + * + * Survives `reset()`, like `customBuiltins` and unlike env a *script* + * exported: only host-set values are replayed on rebuild. + * + * @example + * ```typescript + * const bash = new Bash(); + * bash.mount("/skills/my-skill", skillFs); + * bash.setEnv("SKILL_PATH", "/skills/my-skill"); + * await bash.execute('cat "$SKILL_PATH/SKILL.md"'); + * ``` + */ + setEnv(key: string, value: string): void { + this.native.setEnv(key, value); + } + /** * Execute bash commands synchronously and return the result. * @@ -1409,6 +1433,11 @@ export class BashTool { this.native.removeBuiltin(name); } + /** Set an exported environment variable. See {@link Bash.setEnv}. */ + setEnv(key: string, value: string): void { + this.native.setEnv(key, value); + } + /** * Execute bash commands synchronously and return the result. * diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index aba4e5d6d..c9a7fa797 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -934,6 +934,31 @@ class Bash: """ ... + def set_env(self, key: str, value: str) -> None: + """Set an exported environment variable on the live interpreter. + + The env counterpart to :meth:`mount` — usable after construction, so a + reusable setup bundle (mount + env + builtins) can be applied to an + existing instance instead of only through the constructor. Scripts see + it as ``$NAME``; child contexts see it in ``env``. A later script + assignment wins. + + Survives :meth:`reset`, like custom builtins and unlike env a *script* + exported: only host-set values are replayed on rebuild. + + Args: + key: Variable name. + value: Variable value. + + Example:: + + >>> bash = Bash() + >>> bash.set_env("SKILL_PATH", "/skills/my-skill") + >>> bash.execute_sync("echo $SKILL_PATH").stdout + '/skills/my-skill\\n' + """ + ... + class AnalyzedCommand: """One simple command found by :meth:`Bash.analyze`. @@ -1500,6 +1525,20 @@ class BashTool: """ ... + def set_env(self, key: str, value: str) -> None: + """Set an exported environment variable. See :meth:`Bash.set_env`. + + Survives :meth:`reset`. + + Example:: + + >>> tool = BashTool() + >>> tool.set_env("SKILL_PATH", "/skills/my-skill") + >>> tool.execute_sync("echo $SKILL_PATH").stdout + '/skills/my-skill\\n' + """ + ... + class ScriptedTool: """Compose Python callbacks as bash builtins for multi-tool orchestration. diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index b44590905..6f7736740 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -4070,20 +4070,96 @@ fn capture_custom_builtin_session( .map(Some) } +/// Ordered log of host `set_env()` calls, replayed on rebuild. +/// +/// A `Vec` rather than a map so replay follows call order. One entry per key: +/// re-setting a key replaces its entry, which keeps last-write-wins semantics +/// and bounds the log by distinct key count — a host that calls `set_env()` per +/// request must not accumulate an entry per call. +type RuntimeEnvLog = Arc>>; + +/// Ordered log of host runtime mounts (vfs path + live filesystem), replayed on +/// rebuild. The `Arc` is retained, so a replayed mount is the *same* filesystem, +/// not a copy — writes made through it before the reset are still there after. +type RuntimeMountLog = Arc)>>>; + +/// Record a host `set_env()` call for replay on the next rebuild. +/// +/// A poisoned log is surfaced rather than swallowed: silently skipping the +/// record would leave the caller believing a value survives `reset()` when it +/// would not. +fn record_runtime_env(log: &RuntimeEnvLog, key: &str, value: &str) -> PyResult<()> { + let mut entries = log + .lock() + .map_err(|_| PyRuntimeError::new_err("runtime env log poisoned"))?; + entries.retain(|(existing, _)| existing != key); + entries.push((key.to_string(), value.to_string())); + Ok(()) +} + +/// Record a host runtime mount for replay on the next rebuild. +/// +/// A mount at an already-recorded path replaces that record: the live VFS keeps +/// one filesystem per mount point, so the replay must too. +fn record_runtime_mount( + log: &RuntimeMountLog, + vfs_path: &str, + fs: Arc, +) -> PyResult<()> { + let mut mounts = log + .lock() + .map_err(|_| PyRuntimeError::new_err("runtime mount log poisoned"))?; + mounts.retain(|(path, _)| path != vfs_path); + mounts.push((vfs_path.to_string(), fs)); + Ok(()) +} + +/// Retract a recorded mount so `reset()` does not resurrect it after `unmount()`. +fn forget_runtime_mount(log: &RuntimeMountLog, vfs_path: &str) -> PyResult<()> { + let mut mounts = log + .lock() + .map_err(|_| PyRuntimeError::new_err("runtime mount log poisoned"))?; + mounts.retain(|(path, _)| path != vfs_path); + Ok(()) +} + fn replace_live_bash_with_builder( py: Python<'_>, rt: &Arc, inner: &Arc>, cancelled: &Arc>>, builder: bashkit::BashBuilder, + runtime_env: &RuntimeEnvLog, + runtime_mounts: &RuntimeMountLog, ) -> PyResult<()> { let rt = rt.clone(); let inner = inner.clone(); let cancelled = cancelled.clone(); + let replay_env = runtime_env + .lock() + .map(|entries| entries.clone()) + .unwrap_or_default(); + let replay_mounts = runtime_mounts + .lock() + .map(|mounts| mounts.clone()) + .unwrap_or_default(); py.detach(|| { rt.block_on(async move { let mut bash = inner.lock().await; - let rebuilt = builder.build(); + let mut rebuilt = builder.build(); + // Replay host mounts. A recorded mount was accepted once on a live + // instance; a failure here means the path is no longer mountable + // (e.g. shadowed by a constructor mount). Skipping keeps reset + // infallible, and the absent mount is observable, unlike a panic + // mid-rebuild. + for (vfs_path, fs) in &replay_mounts { + let _ = rebuilt.mount(Path::new(vfs_path), Arc::clone(fs)); + } + // Host env goes last so it wins over constructor `env` for the same + // key, matching what happened on the live instance. + for (key, value) in &replay_env { + rebuilt.set_env(key, value); + } let token = rebuilt.cancellation_token(); *bash = rebuilt; if let Ok(mut current) = cancelled.write() { @@ -4402,6 +4478,16 @@ pub struct PyBash { /// Wrapped in `Arc>` so post-construction registration works /// through `&self` like the rest of `PyBash`'s API. custom_builtins: Arc>>, + /// Host mutations applied *after* construction — `set_env()` and `mount()`. + /// + /// THREAT[TM-ISO-025]: `reset()` must not silently drop host capabilities. + /// Constructor options are replayed from the fields above; recording the + /// runtime equivalents puts them on the same rebuild path, so a bundle of + /// setup installed on a live instance (mount + env + builtins) is whole + /// after a reset instead of half-applied. Script-set env is deliberately + /// *not* recorded — only what the host asked for survives. + runtime_env: RuntimeEnvLog, + runtime_mounts: RuntimeMountLog, /// Host-owned live registry of custom builtins. Cloned into the bashkit /// builder and retained here so post-construction registrations take /// effect without rebuilding the interpreter (and without disturbing the @@ -4682,6 +4768,8 @@ impl PyBash { external_handler, external_handler_reentry_depth, custom_builtins: Arc::new(StdMutex::new(custom_builtins)), + runtime_env: Arc::new(StdMutex::new(Vec::new())), + runtime_mounts: Arc::new(StdMutex::new(Vec::new())), host_registry, builtin_engine, files, @@ -4909,6 +4997,8 @@ impl PyBash { &self.inner, &self.cancelled, self.build_live_builder(py)?, + &self.runtime_env, + &self.runtime_mounts, ) } @@ -5330,16 +5420,21 @@ impl PyBash { } /// Mount a filesystem at `vfs_path` without rebuilding the interpreter. + /// + /// Recorded, so `reset()` replays it — see `runtime_mounts`. fn mount(&self, py: Python<'_>, vfs_path: String, fs: PyRef<'_, PyFileSystem>) -> PyResult<()> { self.reject_external_handler_reentry()?; let inner = self.inner.clone(); let source = fs.inner.clone(); + let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { self.rt.block_on(async move { let mounted_fs = source.resolve().await?; let bash = inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) - .map_err(|e| PyRuntimeError::new_err(e.to_string())) + bash.mount(Path::new(&vfs_path), Arc::clone(&mounted_fs)) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + record_runtime_mount(&runtime_mounts, &vfs_path, mounted_fs)?; + Ok(()) }) }) } @@ -5348,11 +5443,34 @@ impl PyBash { fn unmount(&self, py: Python<'_>, vfs_path: String) -> PyResult<()> { self.reject_external_handler_reentry()?; let inner = self.inner.clone(); + let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { self.rt.block_on(async move { let bash = inner.lock().await; bash.unmount(Path::new(&vfs_path)) - .map_err(|e| PyRuntimeError::new_err(e.to_string())) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + forget_runtime_mount(&runtime_mounts, &vfs_path)?; + Ok(()) + }) + }) + } + + /// Set an exported environment variable on the live interpreter. + /// + /// The env counterpart to runtime `mount()`: usable after construction, so + /// a reusable setup bundle can apply mounts, env, and builtins to an + /// existing instance instead of only through the constructor. Survives + /// `reset()` — like custom builtins, and unlike env a *script* exported. + fn set_env(&self, py: Python<'_>, key: String, value: String) -> PyResult<()> { + self.reject_external_handler_reentry()?; + let inner = self.inner.clone(); + let runtime_env = self.runtime_env.clone(); + py.detach(|| { + self.rt.block_on(async move { + let mut bash = inner.lock().await; + bash.set_env(&key, &value); + record_runtime_env(&runtime_env, &key, &value)?; + Ok(()) }) }) } @@ -5417,6 +5535,16 @@ pub struct BashTool { /// Wrapped in `Arc>` so post-construction registration works /// through `&self` like the rest of `BashTool`'s API. custom_builtins: Arc>>, + /// Host mutations applied *after* construction — `set_env()` and `mount()`. + /// + /// THREAT[TM-ISO-025]: `reset()` must not silently drop host capabilities. + /// Constructor options are replayed from the fields above; recording the + /// runtime equivalents puts them on the same rebuild path, so a bundle of + /// setup installed on a live instance (mount + env + builtins) is whole + /// after a reset instead of half-applied. Script-set env is deliberately + /// *not* recorded — only what the host asked for survives. + runtime_env: RuntimeEnvLog, + runtime_mounts: RuntimeMountLog, /// Host-owned live registry of custom builtins; see [`PyBash::host_registry`]. host_registry: BuiltinRegistry, builtin_engine: Arc, @@ -5656,6 +5784,8 @@ impl BashTool { cwd, env, custom_builtins: Arc::new(StdMutex::new(custom_builtins)), + runtime_env: Arc::new(StdMutex::new(Vec::new())), + runtime_mounts: Arc::new(StdMutex::new(Vec::new())), host_registry, builtin_engine, files, @@ -5847,6 +5977,8 @@ impl BashTool { &self.inner, &self.cancelled, self.build_live_builder(py)?, + &self.runtime_env, + &self.runtime_mounts, ) } @@ -6144,15 +6276,35 @@ impl BashTool { } /// Mount a filesystem at `vfs_path` without rebuilding the interpreter. + /// + /// Recorded, so `reset()` replays it — see `runtime_mounts`. fn mount(&self, py: Python<'_>, vfs_path: String, fs: PyRef<'_, PyFileSystem>) -> PyResult<()> { let inner = self.inner.clone(); let source = fs.inner.clone(); + let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { self.rt.block_on(async move { let mounted_fs = source.resolve().await?; let bash = inner.lock().await; - bash.mount(Path::new(&vfs_path), mounted_fs) + bash.mount(Path::new(&vfs_path), Arc::clone(&mounted_fs)) .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + record_runtime_mount(&runtime_mounts, &vfs_path, mounted_fs)?; + Ok(()) + }) + }) + } + + /// Set an exported environment variable on the live interpreter. + /// + /// See `Bash.set_env`. Survives `reset()`. + fn set_env(&self, py: Python<'_>, key: String, value: String) -> PyResult<()> { + let inner = self.inner.clone(); + let runtime_env = self.runtime_env.clone(); + py.detach(|| { + self.rt.block_on(async move { + let mut bash = inner.lock().await; + bash.set_env(&key, &value); + record_runtime_env(&runtime_env, &key, &value)?; Ok(()) }) }) @@ -6161,11 +6313,13 @@ impl BashTool { /// Unmount a live filesystem without rebuilding the interpreter. fn unmount(&self, py: Python<'_>, vfs_path: String) -> PyResult<()> { let inner = self.inner.clone(); + let runtime_mounts = self.runtime_mounts.clone(); py.detach(|| { self.rt.block_on(async move { let bash = inner.lock().await; bash.unmount(Path::new(&vfs_path)) .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + forget_runtime_mount(&runtime_mounts, &vfs_path)?; Ok(()) }) }) diff --git a/crates/bashkit-python/tests/test_runtime_extension.py b/crates/bashkit-python/tests/test_runtime_extension.py new file mode 100644 index 000000000..3371a12d9 --- /dev/null +++ b/crates/bashkit-python/tests/test_runtime_extension.py @@ -0,0 +1,145 @@ +"""Runtime host mutations: set_env plus mounts applied after construction, +and their survival across reset() (issue #2291). + +The motivating shape is an "extension": a reusable function that mounts a +filesystem, sets env, and registers a builtin on an existing instance. All +three parts must behave the same way across reset(), or the extension is +half-installed after a rebuild. +""" + +from bashkit import Bash, BashTool, FileSystem + + +def skill_filesystem() -> FileSystem: + fs = FileSystem() + fs.write_file("/SKILL.md", b"# my-skill\n") + return fs + + +# --------------------------------------------------------------------------- +# set_env — live application +# --------------------------------------------------------------------------- + + +def test_set_env_is_visible_to_scripts(): + bash = Bash() + bash.set_env("SKILL_PATH", "/skills/my-skill") + assert bash.execute_sync("echo $SKILL_PATH").stdout == "/skills/my-skill\n" + + +def test_set_env_is_exported(): + bash = Bash() + bash.set_env("SKILL_PATH", "/skills/my-skill") + result = bash.execute_sync("env | grep '^SKILL_PATH='") + assert result.stdout == "SKILL_PATH=/skills/my-skill\n" + + +def test_set_env_preserves_existing_shell_state(): + bash = Bash() + bash.execute_sync("export EXISTING=kept") + bash.set_env("ADDED", "new") + assert bash.execute_sync("echo $EXISTING $ADDED").stdout == "kept new\n" + + +def test_set_env_overrides_constructor_env(): + bash = Bash(env={"SKILL_PATH": "/from-options"}) + bash.set_env("SKILL_PATH", "/from-set-env") + assert bash.execute_sync("echo $SKILL_PATH").stdout == "/from-set-env\n" + + +def test_tool_set_env_is_visible_to_scripts(): + tool = BashTool() + tool.set_env("SKILL_PATH", "/skills/my-skill") + assert tool.execute_sync("echo $SKILL_PATH").stdout == "/skills/my-skill\n" + + +# --------------------------------------------------------------------------- +# reset() replay — env +# --------------------------------------------------------------------------- + + +def test_reset_preserves_set_env(): + bash = Bash() + bash.set_env("SKILL_PATH", "/skills/my-skill") + bash.reset() + assert bash.execute_sync("echo $SKILL_PATH").stdout == "/skills/my-skill\n" + + +def test_reset_keeps_last_set_env_value(): + bash = Bash() + bash.set_env("SKILL_PATH", "/first") + bash.set_env("SKILL_PATH", "/second") + bash.reset() + assert bash.execute_sync("echo $SKILL_PATH").stdout == "/second\n" + + +def test_reset_discards_script_set_env(): + bash = Bash() + bash.execute_sync("export SCRIPT_ONLY=transient") + bash.reset() + assert bash.execute_sync("echo [$SCRIPT_ONLY]").stdout == "[]\n" + + +def test_reset_keeps_set_env_override_of_constructor_env(): + bash = Bash(env={"SKILL_PATH": "/from-options"}) + bash.set_env("SKILL_PATH", "/from-set-env") + bash.reset() + assert bash.execute_sync("echo $SKILL_PATH").stdout == "/from-set-env\n" + + +def test_tool_reset_preserves_set_env(): + tool = BashTool() + tool.set_env("SKILL_PATH", "/skills/my-skill") + tool.reset() + assert tool.execute_sync("echo $SKILL_PATH").stdout == "/skills/my-skill\n" + + +# --------------------------------------------------------------------------- +# reset() replay — mounts +# --------------------------------------------------------------------------- + + +def test_reset_preserves_runtime_mount(): + bash = Bash() + bash.mount("/skills/my-skill", skill_filesystem()) + bash.reset() + result = bash.execute_sync("cat /skills/my-skill/SKILL.md") + assert result.stdout == "# my-skill\n" + + +def test_unmount_retracts_replay(): + bash = Bash() + bash.mount("/skills/my-skill", skill_filesystem()) + bash.unmount("/skills/my-skill") + bash.reset() + assert bash.execute_sync("cat /skills/my-skill/SKILL.md 2>&1").exit_code != 0 + + +def test_tool_reset_preserves_runtime_mount(): + tool = BashTool() + tool.mount("/skills/my-skill", skill_filesystem()) + tool.reset() + assert tool.execute_sync("cat /skills/my-skill/SKILL.md").stdout == "# my-skill\n" + + +# --------------------------------------------------------------------------- +# The whole extension shape, end to end +# --------------------------------------------------------------------------- + + +def test_mount_env_builtin_bundle_survives_reset(): + skill_path = "/skills/my-skill" + data = skill_filesystem() + + def install_skill(bash: Bash) -> None: + bash.mount(skill_path, data) + bash.set_env("SKILL_PATH", skill_path) + bash.add_builtin("my-skill", lambda ctx: ctx.fs.read_file(f"{skill_path}/SKILL.md").decode()) + + bash = Bash() + install_skill(bash) + bash.reset() + + result = bash.execute_sync('my-skill; cat "$SKILL_PATH/SKILL.md"') + assert result.exit_code == 0 + assert result.stdout == "# my-skill\n# my-skill\n" diff --git a/crates/bashkit/docs/live_mounts.md b/crates/bashkit/docs/live_mounts.md index 1f2e31054..34b527419 100644 --- a/crates/bashkit/docs/live_mounts.md +++ b/crates/bashkit/docs/live_mounts.md @@ -130,6 +130,43 @@ denied. Both approaches can be combined: configure initial mounts with the builder, then add/remove mounts at runtime. +## Live Environment + +[`Bash::set_env`] is the environment counterpart to a live mount: it applies an +exported variable to a running interpreter, preserving shell state the same way. +Together they let a host apply a *bundle* of setup — mount, env, and builtins — +to an existing instance, rather than only through the builder: + +```rust +# use bashkit::{Bash, FileSystem, InMemoryFs}; +# use std::path::Path; +# use std::sync::Arc; +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let mut bash = Bash::new(); + +let skill_fs = Arc::new(InMemoryFs::new()); +skill_fs.write_file(Path::new("/SKILL.md"), b"# my-skill\n").await?; + +bash.mount("/skills/my-skill", skill_fs)?; +bash.set_env("SKILL_PATH", "/skills/my-skill"); + +let result = bash.exec(r#"cat "$SKILL_PATH/SKILL.md""#).await?; +assert_eq!(result.stdout, "# my-skill\n"); +# Ok(()) +# } +``` + +The variable is exported, so scripts read it as `$NAME` and child contexts see it +in `env`. A later script assignment wins; call `set_env` again to reassert the +host value. + +In the JavaScript and Python bindings the equivalents are `setEnv` and +`set_env`, and both bindings additionally *replay* host `setEnv`/`mount` calls +across `reset()` — a rebuilt instance keeps the setup the host applied, while +env a script exported is still discarded. The Rust core has no `reset()`; +rebuilding is the embedder's own `BashBuilder` call. + Files and symlinks can be copied or moved across live mount boundaries. A move snapshots the previous destination and restores it if copying or source removal fails. Directories and FIFOs remain unsupported across mount boundaries. @@ -204,4 +241,5 @@ assert_eq!(result.stdout, "2.0"); - [`BashBuilder::readonly_filesystem`] — deny all VFS mutations after setup - [`BashBuilder::fs`] — custom filesystem injection - [`Bash::fs`] — direct filesystem access +- [`Bash::set_env`] — live environment on a running instance - [VFS specification](https://github.com/everruns/bashkit/blob/main/knowledge/foundations/vfs.md) diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 5f21145a6..c8d592adb 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1537,6 +1537,44 @@ impl Bash { self.interpreter.shell_state_view() } + /// Set an exported environment variable on a live interpreter. + /// + /// The counterpart to [`mount()`](Self::mount) for environment: hosts can + /// contribute variables **after** build without rebuilding, so a bundle of + /// setup (mount + env + builtins) can be applied to an existing instance + /// instead of only through [`BashBuilder::env`]. Shell state — variables, + /// cwd, history — is preserved. + /// + /// The variable is exported, so scripts see it via `$NAME` and child + /// contexts see it in `env`. A later script assignment wins; embedders + /// that need the host value back can call this again. + /// + /// # Example + /// + /// ```rust + /// use bashkit::Bash; + /// + /// # #[tokio::main] + /// # async fn main() -> bashkit::Result<()> { + /// let mut bash = Bash::new(); + /// bash.exec("cd /tmp").await?; + /// + /// bash.set_env("SKILL_PATH", "/skills/my-skill"); + /// + /// let result = bash.exec("echo $SKILL_PATH").await?; + /// assert_eq!(result.stdout, "/skills/my-skill\n"); + /// # Ok(()) + /// # } + /// ``` + pub fn set_env(&mut self, key: &str, value: &str) { + // Mirrors what `BashBuilder::env` does at build time: the exported + // entry alone is shadowed by a same-named shell variable during + // expansion, so a host value applied later would silently lose to a + // builder-configured one. + self.interpreter.set_env(key, value); + self.interpreter.set_var(key, value); + } + /// Restore shell state from a previous snapshot. /// /// Restores variables, env, cwd, arrays, functions, aliases, traps, and diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 69c2616b3..4a3688952 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -91,6 +91,7 @@ pub mod python_security_tests; pub mod regex_limit_tests; pub mod release_profile_tests; pub mod request_lifecycle_contract_tests; +pub mod runtime_env_tests; pub mod script_analysis; pub mod script_execution_tests; pub mod security_audit_pocs; diff --git a/crates/bashkit/tests/integration/runtime_env_tests.rs b/crates/bashkit/tests/integration/runtime_env_tests.rs new file mode 100644 index 000000000..733d6d69f --- /dev/null +++ b/crates/bashkit/tests/integration/runtime_env_tests.rs @@ -0,0 +1,84 @@ +//! Tests for `Bash::set_env()` on a running instance (issue #2291). +//! +//! Mirrors `live_mount_tests.rs`: hosts can contribute environment after +//! build, without rebuilding the interpreter or losing shell state. + +use bashkit::Bash; + +#[tokio::test] +async fn set_env_is_visible_to_scripts() { + let mut bash = Bash::new(); + + bash.set_env("SKILL_PATH", "/skills/my-skill"); + + let result = bash.exec("echo $SKILL_PATH").await.unwrap(); + assert_eq!(result.stdout, "/skills/my-skill\n"); +} + +#[tokio::test] +async fn set_env_is_exported_to_child_context() { + let mut bash = Bash::new(); + + bash.set_env("SKILL_PATH", "/skills/my-skill"); + + let result = bash.exec("env | grep '^SKILL_PATH='").await.unwrap(); + assert_eq!(result.stdout, "SKILL_PATH=/skills/my-skill\n"); +} + +#[tokio::test] +async fn set_env_preserves_existing_shell_state() { + let mut bash = Bash::new(); + + bash.exec("export EXISTING=kept").await.unwrap(); + bash.exec("cd /tmp").await.unwrap(); + + bash.set_env("ADDED", "new"); + + let result = bash.exec("echo $EXISTING $ADDED $PWD").await.unwrap(); + assert_eq!(result.stdout, "kept new /tmp\n"); +} + +#[tokio::test] +async fn script_assignment_overrides_set_env() { + let mut bash = Bash::new(); + + bash.set_env("SKILL_PATH", "/skills/my-skill"); + bash.exec("export SKILL_PATH=/skills/other").await.unwrap(); + + let result = bash.exec("echo $SKILL_PATH").await.unwrap(); + assert_eq!(result.stdout, "/skills/other\n"); +} + +#[tokio::test] +async fn set_env_overrides_builder_env() { + // `BashBuilder::env` sets both the exported env entry and a shell variable, + // and the variable wins during expansion. A host value applied later must + // beat it, or `set_env` would silently no-op for any pre-configured key. + let mut bash = Bash::builder().env("SKILL_PATH", "/from-builder").build(); + + bash.set_env("SKILL_PATH", "/from-set-env"); + + let result = bash.exec("echo $SKILL_PATH").await.unwrap(); + assert_eq!(result.stdout, "/from-set-env\n"); +} + +#[tokio::test] +async fn set_env_overrides_builder_env_for_child_context() { + let mut bash = Bash::builder().env("SKILL_PATH", "/from-builder").build(); + + bash.set_env("SKILL_PATH", "/from-set-env"); + + let result = bash.exec("env | grep '^SKILL_PATH='").await.unwrap(); + assert_eq!(result.stdout, "SKILL_PATH=/from-set-env\n"); +} + +#[tokio::test] +async fn set_env_overwrites_previous_value() { + let mut bash = Bash::new(); + + bash.set_env("SKILL_PATH", "/first"); + bash.set_env("SKILL_PATH", "/second"); + + let result = bash.exec("echo $SKILL_PATH").await.unwrap(); + assert_eq!(result.stdout, "/second\n"); +} diff --git a/knowledge/log.md b/knowledge/log.md index b1b5803cc..254035d24 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-08-12 +* **Decision**: Host configuration applied *after* construction is now first-class and rebuild-safe. `Bash::set_env()` in the core is the env counterpart to live `mount()`, surfaced as `setEnv` (NAPI) and `set_env` (Python). Both bindings record runtime `set_env`/`mount` calls and replay them on every rebuild, so `reset()` preserves them the way it already preserved custom builtins and constructor files; `unmount()` retracts its record. Script-set env stays transient. This makes the reusable mount+env+builtin bundle from issue #2291 installable on a live instance without a two-phase construct-then-apply dance. Recorded in [Public Capability Parity](status/capability-parity.md) (`runtime_env`), [Python Package](runtimes/python-package.md), and TM-ISO-025 in the [Threat Model](security/threat-model.md). +* **Constraint**: `Bash::set_env()` writes both the exported entry and the shell variable, because `BashBuilder::env` sets both and the variable shadows the env entry during expansion — writing only the env entry would make a host value silently lose to a builder-configured one. * **Security**: Documented the previously unrecorded `cargo audit --ignore RUSTSEC-2023-0071` suppression as TM-CRY-002 — the Marvin timing sidechannel in `rsa`, reached transitively through `russh`/`ssh-key` behind the opt-in `ssh` feature. The advisory covers every published `rsa` version with no patched release, so it cannot be cleared by upgrading; it is an accepted risk until `rsa` ships a constant-time implementation, and callers enabling `ssh` over an attacker-observable network should prefer Ed25519 keys. Recorded in the [Threat Model](security/threat-model.md). * **Contract**: Advisory suppressions now require a rationale and a removal condition, listed in one place ("Suppressed advisories" in the [Threat Model](security/threat-model.md)) and mirrored between `deny.toml` and the `cargo audit` flags in CI. A bare `--ignore` with no recorded reasoning is no longer acceptable; the two existing unmaintained-crate ignores (RUSTSEC-2023-0089, RUSTSEC-2026-0173) were folded into the same table. diff --git a/knowledge/runtimes/python-package.md b/knowledge/runtimes/python-package.md index 380d459b0..27ffa68dc 100644 --- a/knowledge/runtimes/python-package.md +++ b/knowledge/runtimes/python-package.md @@ -77,12 +77,29 @@ safety. Constructor kwargs: `username`, `hostname`, `cwd` (initial working directory), `env` (initial environment variables), `max_commands`, `max_loop_iterations`, `readonly_filesystem`, `files` (initial files; values may be eager strings or lazy sync callables), `network`, `custom_builtins`, -etc. Methods: `await execute(cmd)` / `execute_sync(cmd)` / `reset()`; direct +etc. Methods: `await execute(cmd)` / `execute_sync(cmd)` / `reset()`; +`set_env(key, value)` and `mount(vfs_path, fs)` / `unmount(vfs_path)` for +host configuration applied *after* construction; direct text-oriented VFS helpers (`read_file`, `write_file`, `append_file`, `mkdir`, `exists`, `remove`, `stat`, `chmod`, `symlink`, `read_link`, `read_dir`, `ls`, `glob`); LLM metadata (`name`, `short_description`, `description()`, `help()`, `system_prompt()`, `input_schema()`, `output_schema()`, `version`). +### Runtime host mutations + +`set_env()` and runtime `mount()` are recorded and replayed on every rebuild, +so `reset()` keeps them the way it already keeps `custom_builtins` and +constructor `files` (TM-ISO-025). This makes a reusable setup bundle — +mount + env + builtin, the extension shape from #2291 — survive a reset whole +rather than half-applied. `unmount()` retracts its record, so a rebuild does +not resurrect a removed mount. Env a *script* exported is not recorded: `reset()` +still discards it, and only host-set values come back. + +Same contract in the NAPI binding (`setEnv`), backed by `Bash::set_env()` in +the core. Absent from the CLI, browser wasm, and C ABI, which expose no +post-construction host API — see +[Public Capability Parity](../status/capability-parity.md). + Snapshot/restore on both `Bash` and `BashTool` (mirrors Node bindings): `snapshot()` / `snapshot(exclude_filesystem=True)` / `from_snapshot(blob)` / `restore_snapshot(blob)`, plus keyed variants `snapshot_keyed(secret)` / diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 23ea31691..f0d619798 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -826,7 +826,7 @@ Only exact domain matches are allowed (TM-NET-017). | TM-ISO-022 | `$?` leaks across `exec()` calls | Exit code from one `exec()` visible as `$?` in next `exec()` instead of resetting to 0 | `reset_transient_state()` zeroes `last_exit_code` at the start of every `exec()` | **MITIGATED** | | 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 constructor capabilities | A binding's `reset()` or fresh-execution path rebuilds `Bash` without limits, policy files, callbacks, network policy, or other host configuration, 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. Regression: `Bash: reset restores configured files` | **MITIGATED** | +| TM-ISO-025 | Wrapper rebuild silently drops constructor capabilities | A binding's `reset()` or fresh-execution path rebuilds `Bash` without limits, policy files, callbacks, network policy, or other host configuration, 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, and `unmount()` retracts its record so a rebuild cannot resurrect a 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`, `test_reset_preserves_set_env`, `test_unmount_retracts_replay` | **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 copy it into a Tokio task-local only for the current suspension; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`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** | diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 5107555af..30881cdc6 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -27,6 +27,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Bounded stdout and stderr with truncation reporting | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Initial virtual working directory | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | Initial virtual environment | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | +| Host environment applied after construction | ✅ | — | — | — | ✅ | ✅ | — | — | | Host-provided stdin for an execution | ✅ | — | ✅ | ✅ | — | — | — | — | | Host callbacks registered as shell builtins | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | | Host-visible process-local builtin suspension and resume | ✅ | — | — | — | — | — | — | — | @@ -48,6 +49,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### Rust BashTool +- `runtime_env`: BashTool builds a fresh Bash per execution, so there is no live instance to mutate; env is configured on the builder. - `cancellation`: ToolExecution has no host cancellation handle; callers can drop the future or set a deadline. - `stdin`: ToolRequest accepts commands and timeout_ms only; scripts can still create internal pipelines. - `host_call_suspension`: BashTool drives each fresh Bash execution to completion and exposes no event handle. @@ -56,6 +58,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### Rust ScriptedTool +- `runtime_env`: Each execution uses a fresh logic-only shell; env is configured before the run. - `cancellation`: No cancellation handle is exposed; per-call timeout_ms is the abort boundary. - `cwd`: ScriptedTool intentionally uses a logic-only shell without filesystem-backed cwd configuration. - `custom_builtins`: Only registered ToolDef callbacks become commands; arbitrary Bash builtins are not accepted. @@ -71,6 +74,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### CLI +- `runtime_env`: The CLI owns the shell lifecycle end to end and exposes no post-construction host API. - `cancellation`: No programmatic cancellation API exists at the process boundary. - `cwd`: The CLI exposes no initial virtual-cwd flag. - `env`: The CLI deliberately does not inherit or expose host environment injection flags. @@ -97,6 +101,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### Browser WASM +- `runtime_env`: The slim wasm package exposes no post-construction host mutation API; env is construction-only. - `stdin`: execute() accepts only a command string; pipelines still provide builtin stdin. - `host_call_suspension`: Browser custom builtins await JavaScript callbacks directly; no request and resume handle is exposed. - `tool_callbacks`: The slim browser package exposes custom builtins but no ScriptedTool class. @@ -109,6 +114,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason ### C ABI +- `runtime_env`: The C ABI exposes no post-construction configuration surface. - `cancellation`: ABI v1 exposes synchronous execute with no cancellation handle. - `stdin`: ABI v1 execute accepts script bytes only. - `custom_builtins`: ABI v1 has no callback registration table. diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index 9af5996d4..e1f500335 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -532,6 +532,37 @@ Example: >>> bash.unmount("/mnt/ext") ``` +### `set_env` + +```python +Bash.set_env(key: str, value: str) -> None +``` + +Set an exported environment variable on the live interpreter. + +The env counterpart to :meth:`mount` — usable after construction, so a +reusable setup bundle (mount + env + builtins) can be applied to an +existing instance instead of only through the constructor. Scripts see +it as ``$NAME``; child contexts see it in ``env``. A later script +assignment wins. + +Survives :meth:`reset`, like custom builtins and unlike env a *script* +exported: only host-set values are replayed on rebuild. + +**Parameters:** + +- **`key`** — Variable name. +- **`value`** — Variable value. + +Example: + +```python +>>> bash = Bash() +>>> bash.set_env("SKILL_PATH", "/skills/my-skill") +>>> bash.execute_sync("echo $SKILL_PATH").stdout +'/skills/my-skill\n' +``` + ## BashTool Sandboxed bash interpreter for AI agents. @@ -1076,6 +1107,25 @@ Example: >>> tool.unmount("/mnt/ext") ``` +### `set_env` + +```python +BashTool.set_env(key: str, value: str) -> None +``` + +Set an exported environment variable. See :meth:`Bash.set_env`. + +Survives :meth:`reset`. + +Example: + +```python +>>> tool = BashTool() +>>> tool.set_env("SKILL_PATH", "/skills/my-skill") +>>> tool.execute_sync("echo $SKILL_PATH").stdout +'/skills/my-skill\n' +``` + ## ScriptedTool Compose Python callbacks as bash builtins for multi-tool orchestration. diff --git a/site/src/content/apidocs/typescript.md b/site/src/content/apidocs/typescript.md index 9846ac41a..68f4a7851 100644 --- a/site/src/content/apidocs/typescript.md +++ b/site/src/content/apidocs/typescript.md @@ -308,6 +308,30 @@ bash.restoreSnapshotKeyed(data: Uint8Array, key: Uint8Array): void Restore interpreter state from a HMAC-protected snapshot. +### `setEnv` + +```typescript +bash.setEnv(key: string, value: string): void +``` + +Set an exported environment variable on the live interpreter. + +The env counterpart to runtime `Bash.mount` — usable after +construction, so a reusable setup bundle (mount + env + builtins) can be +applied to an existing instance instead of only through +`BashOptions`. Scripts see it as `$NAME`; child contexts see it in +`env`. A later script assignment wins. + +Survives `reset()`, like `customBuiltins` and unlike env a *script* +exported: only host-set values are replayed on rebuild. + +```typescript +const bash = new Bash(); +bash.mount("/skills/my-skill", skillFs); +bash.setEnv("SKILL_PATH", "/skills/my-skill"); +await bash.execute('cat "$SKILL_PATH/SKILL.md"'); +``` + ### `shellState` ```typescript @@ -721,6 +745,14 @@ bashTool.restoreSnapshotKeyed(data: Uint8Array, key: Uint8Array): void Restore interpreter state from a HMAC-protected snapshot. +### `setEnv` + +```typescript +bashTool.setEnv(key: string, value: string): void +``` + +Set an exported environment variable. See `Bash.setEnv`. + ### `shellState` ```typescript