diff --git a/README.md b/README.md index 3632137..902bccf 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ opencodebox [OPTIONS] [OPENCODE_ARGS...] - `--with /host[:/sandbox]` - Bind host path read-write to sandbox - `--with-ro /host[:/sandbox]` - Bind host path read-only to sandbox +- `--exec COMMAND` - Run `COMMAND` inside the sandbox instead of `opencode` These options allow you to specify additional directories to mount inside the sandbox for read-write or read-only access. @@ -70,11 +71,17 @@ opencodebox --with-ro /etc/hosts # Run sandboxed opencode server with specified bind mounts opencodebox --with /data --with-ro /config serve + +# Run a custom command inside the sandbox +opencodebox --exec /bin/bash + +# Run a custom command with arguments +opencodebox --exec /bin/bash -l ``` ## How It Works -1. Parse arguments (`--with`, `--with-ro`, `--version`, `--help`) +1. Parse arguments (`--with`, `--with-ro`, `--exec`, `--version`, `--help`) 2. Check prerequisites (bwrap and opencode) 3. Load seccomp sandbox filter (see [details](#seccomp-sandbox-filter)) 4. **Enforce security restrictions**: @@ -84,7 +91,7 @@ opencodebox --with /data --with-ro /config serve 5. Build bubblewrap sandbox with namespace isolation and bind mounts 6. Setup SSH (sanitized `.pub` keys, `known_hosts`, agent forwarding) 7. Add conditional tool mounts (bun, npm, pnpm, uv, pipenv, cargo, git, mise) and extra bind mounts -8. Execute opencode inside the sandbox +8. Execute the target command (`opencode` by default, or `--exec` override) inside the sandbox ## Bind Mounts Structure diff --git a/opencodebox b/opencodebox index 71595ad..9475414 100755 --- a/opencodebox +++ b/opencodebox @@ -7,6 +7,9 @@ # ============================================================ VERSION="0.4.4" +# Command to run inside the sandbox; empty means the opencode launcher +# detected later (OPCODE_SANDBOX_PATH) is used. +EXEC_CMD="" print_version() { local opencode_version @@ -27,6 +30,7 @@ Usage: Options: --with /host[:/sandbox] Bind host path read-write (sandbox path defaults to host path) --with-ro /host[:/sandbox] Bind host path read-only (sandbox path defaults to host path) + --exec COMMAND Run COMMAND inside the sandbox instead of opencode --version, -v Show version information --help, -h Show this help message @@ -43,6 +47,7 @@ Examples: opencodebox --with /mnt/data:/workspace/data opencodebox --with-ro /etc/hosts opencodebox --with ~/another-project1 --with-ro ~/another-project2 serve + opencodebox --exec /bin/bash EOF exit 0 } @@ -253,6 +258,17 @@ while [[ $# -gt 0 ]]; do parse_bind_arg "--ro-bind" "$1" shift ;; + # --exec: override the command to run inside the sandbox + --exec) + shift + if [[ $# -eq 0 ]]; then + echo "Error: --exec requires a value" >&2 + echo "Usage: opencodebox --exec /path/to/command [ARGS...]" >&2 + exit 1 + fi + EXEC_CMD="$1" + shift + ;; # Pass through all other args to opencode *) REMAINING_ARGS+=("$1") @@ -411,6 +427,9 @@ if [[ "${OPCODE_DIR##*/}" == ".bin" ]]; then fi OPCODE_SANDBOX_PATH="/opt/opencode/$OPCODE_LAUNCHER" +# --exec overrides the command run inside the sandbox; default is opencode. +[[ -z "$EXEC_CMD" ]] && EXEC_CMD="$OPCODE_SANDBOX_PATH" + # bwrap --bind hard-fails if the source is missing; opencode v2 keeps state under # ~/.local/state/opencode, which v1 users and fresh hosts may not have created yet. STATE_DIR="$HOME/.local/state/opencode" @@ -690,5 +709,5 @@ exec bwrap "${BWRAP_ARGS[@]}" -- \ if [ "'"$mise_installed"'" = true ]; then eval "$(mise activate bash)" fi - exec "'"$OPCODE_SANDBOX_PATH"'" "$@" + exec "'"$EXEC_CMD"'" "$@" ' -- "$@" diff --git a/tests/test_launcher.py b/tests/test_launcher.py index cc4d6f5..524e256 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -123,6 +123,37 @@ def test_passthrough_args_reach_opencode(launcher): assert command[3:] == ["--", "-s", "session", "run", "prompt"] +def test_exec_overrides_default_command(launcher): + _, command = launcher.captured(launcher.run_launcher("--exec", "/bin/bash")) + assert command[3:] == ["--"] + assert 'exec "/bin/bash"' in command[2] + + +def test_exec_with_passthrough_args(launcher): + _, command = launcher.captured(launcher.run_launcher("--exec", "/bin/bash", "-l")) + assert command[3:] == ["--", "-l"] + assert 'exec "/bin/bash"' in command[2] + + +def test_exec_requires_value(launcher): + launcher.assert_rejected("--exec") + + +def test_default_command_is_opencode(launcher): + _, command = launcher.captured(launcher.run_launcher()) + assert command[3:] == ["--"] + assert 'exec "/opt/opencode/opencode"' in command[2] + + +def test_exec_with_binds(launcher): + extra = launcher.root / "extra" + extra.mkdir() + options, command = launcher.captured(launcher.run_launcher("--with", str(extra), "--exec", "/bin/sh")) + assert has_seq(options, ["--bind", str(extra), str(extra)]) + assert command[3:] == ["--"] + assert 'exec "/bin/sh"' in command[2] + + def test_reject_sensitive_and_bad_binds(launcher): launcher.assert_rejected("--with", str(launcher.home)) launcher.assert_rejected("--with", "/")