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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions contracts/capability-parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,14 +24,15 @@
"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"],
"deadlines": ["crates/bashkit/tests/integration/blackbox_security_tests.rs#timeout_builtin_cannot_override_execution_timeout"],
"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"],
Expand Down Expand Up @@ -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.",
Expand All @@ -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.",
Expand Down Expand Up @@ -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.",
Expand All @@ -127,14 +132,15 @@
},
"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"],
"deadlines": ["crates/bashkit-python/tests/test_ai_adapters.py#test_bash_timeout_seconds_aborts_long_command"],
"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"],
Expand All @@ -153,14 +159,15 @@
},
"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"],
"deadlines": ["crates/bashkit-js/__test__/ai-adapters.spec.ts#timeoutMs option propagates to interpreter"],
"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"],
Expand Down Expand Up @@ -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.",
Expand All @@ -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.",
Expand Down
188 changes: 188 additions & 0 deletions crates/bashkit-js/__test__/runtime-extension.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading