Last active
May 28, 2026 02:28
-
-
Save jeremyckahn/9ae607bb340b4c657e2c5c017f37de38 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # opencode-sbx.sh | |
| # | |
| # ============================================================================== | |
| # Overview: | |
| # This script automates the creation and management of persistent, secure, and | |
| # isolated AI agent development environments using Docker Sandboxes (sbx) | |
| # running OpenCode. | |
| # | |
| # What it does & What it's for: | |
| # 1. Sandbox Isolation: Runs the OpenCode agent as a headless server inside an | |
| # isolated, secure microVM sandbox. The sandbox mounts your project workspace | |
| # read-write, keeping your host system files safe from autonomous agent edits. | |
| # 2. Configuration Mapping: Mounts and symlinks your host system's global OpenCode | |
| # configuration folder (~/.config/opencode) into the sandbox so your agent | |
| # inherits your local settings, preferences, and custom models automatically. | |
| # 3. LLM Network Bridging: Runs a TCP forwarder inside the sandbox to route | |
| # local llama.cpp traffic (on port 4000) out to the host system's llama.cpp | |
| # instance, since sandboxes are otherwise network-isolated. | |
| # 4. Remote Exposure: Exposes the headless OpenCode server on a customizable port | |
| # (default: 3000) binding to all interfaces (0.0.0.0), allowing you to interact | |
| # with the agent remotely over your Tailscale Tailnet network. | |
| # 5. Keep-Alive: Spawns a host-side background process to maintain an active CLI | |
| # connection, preventing the sandbox daemon from auto-stopping the microVM | |
| # due to inactivity. | |
| # | |
| # System Dependencies: | |
| # - sbx CLI (Docker Sandboxes): Must be installed and authenticated on the host. | |
| # Install on macOS via Homebrew: brew install docker/tap/sbx | |
| # - Docker: Docker daemon must be active on the host machine. | |
| # - llama.cpp: Required for LLM inference (expected to be listening on host port 4000). | |
| # - Tailscale: Required on the host to expose the port (3000/custom) over your Tailnet. | |
| # - socat: Must be available in the sandbox (installed by default in the opencode template) | |
| # to bridge port 4000 back to the host system. | |
| # ============================================================================== | |
| COMMAND="${1:-help}" | |
| # Print help/usage instructions | |
| print_help() { | |
| echo "Usage: ./opencode-sbx.sh <start|stop|help> [path] [port]" | |
| echo " ./opencode-sbx.sh <start|stop|help> [port]" | |
| echo "" | |
| echo "Commands:" | |
| echo " start Starts the Opencode headless server inside a Docker sandbox." | |
| echo " By default, uses the current directory as the workspace and port 3000." | |
| echo " stop Stops the Opencode headless server and the sandbox." | |
| echo " By default, stops the sandbox for the current directory." | |
| echo " help Prints this usage guide and exits." | |
| echo "" | |
| echo "Arguments:" | |
| echo " [path] Optional. Absolute or relative path to the workspace directory" | |
| echo " (defaults to the current working directory)." | |
| echo " [port] Optional. Host port to expose the headless server on" | |
| echo " (defaults to port 3000)." | |
| echo "" | |
| echo "Examples:" | |
| echo " ./opencode-sbx.sh start" | |
| echo " ./opencode-sbx.sh start 4002" | |
| echo " ./opencode-sbx.sh start /path/to/workspace 4005" | |
| echo " ./opencode-sbx.sh stop 4002" | |
| } | |
| if [ "$COMMAND" = "help" ] || [ "$COMMAND" = "-h" ] || [ "$COMMAND" = "--help" ]; then | |
| print_help | |
| exit 0 | |
| fi | |
| if [ "$COMMAND" != "start" ] && [ "$COMMAND" != "stop" ]; then | |
| echo "Error: Invalid command '$COMMAND'." | |
| echo "" | |
| print_help | |
| exit 1 | |
| fi | |
| # Helper function to check if a value is numeric | |
| is_numeric() { | |
| [[ "$1" =~ ^[0-9]+$ ]] | |
| } | |
| # Determine optional path and port arguments | |
| ARG2="$2" | |
| ARG3="$3" | |
| WORKSPACE_DIR="" | |
| EXPOSED_PORT="3000" # Default port | |
| if [ -n "$ARG2" ]; then | |
| if is_numeric "$ARG2"; then | |
| # Example: ./opencode-sbx.sh start 4002 | |
| WORKSPACE_DIR=$(cd "." && pwd) | |
| EXPOSED_PORT="$ARG2" | |
| else | |
| # Example: ./opencode-sbx.sh start /path/to/workspace [port] | |
| WORKSPACE_DIR=$(cd "$ARG2" && pwd) | |
| if [ -n "$ARG3" ] && is_numeric "$ARG3"; then | |
| EXPOSED_PORT="$ARG3" | |
| fi | |
| fi | |
| else | |
| # Example: ./opencode-sbx.sh start | |
| WORKSPACE_DIR=$(cd "." && pwd) | |
| fi | |
| CONFIG_DIR="$HOME/.config/opencode" | |
| # Generate unique and valid sandbox name based on workspace path | |
| BASENAME=$(basename "$WORKSPACE_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g') | |
| if command -v md5 >/dev/null 2>&1; then | |
| PATH_HASH=$(echo -n "$WORKSPACE_DIR" | md5 | cut -c1-8) | |
| elif command -v md5sum >/dev/null 2>&1; then | |
| PATH_HASH=$(echo -n "$WORKSPACE_DIR" | md5sum | cut -c1-8) | |
| else | |
| PATH_HASH=$(echo -n "$WORKSPACE_DIR" | cksum | awk '{print $1}') | |
| fi | |
| SANDBOX_NAME="opencode-${BASENAME}-${PATH_HASH}" | |
| if [ "$COMMAND" = "start" ]; then | |
| echo "Workspace: $WORKSPACE_DIR" | |
| echo "Sandbox Name: $SANDBOX_NAME" | |
| echo "Exposed Port: $EXPOSED_PORT" | |
| # 1. Clean up existing sandbox if it exists or start it | |
| if sbx ls | grep -q "$SANDBOX_NAME"; then | |
| echo "Sandbox $SANDBOX_NAME already exists." | |
| STATUS=$(sbx ls | grep "$SANDBOX_NAME" | awk '{print $3}') | |
| if [ "$STATUS" = "stopped" ]; then | |
| echo "Starting sandbox..." | |
| sbx exec "$SANDBOX_NAME" echo "Starting up..." | |
| fi | |
| else | |
| echo "Creating sandbox $SANDBOX_NAME..." | |
| sbx create --name "$SANDBOX_NAME" opencode "$WORKSPACE_DIR" "$CONFIG_DIR:ro" | |
| sleep 2 | |
| sbx exec "$SANDBOX_NAME" echo "Sandbox booted." | |
| fi | |
| # 2. Setup configuration and workspace symlinks inside sandbox | |
| echo "Setting up configuration and workspace symlinks..." | |
| sbx exec "$SANDBOX_NAME" mkdir -p /home/agent/.config | |
| sbx exec "$SANDBOX_NAME" rm -f /home/agent/.config/opencode | |
| sbx exec "$SANDBOX_NAME" ln -sf "$CONFIG_DIR" /home/agent/.config/opencode | |
| sbx exec "$SANDBOX_NAME" sh -c "rm -rf /home/agent/workspace && ln -sf $WORKSPACE_DIR /home/agent/workspace" | |
| # 3. Start socat port forwarder to bridge llama.cpp at port 4000 | |
| echo "Starting llama.cpp port forwarder (localhost:4000 -> host.docker.internal:4000)..." | |
| sbx exec "$SANDBOX_NAME" pkill -f "socat TCP-LISTEN:4000" || true | |
| sbx exec "$SANDBOX_NAME" sh -c "nohup socat TCP-LISTEN:4000,fork TCP:host.docker.internal:4000 >> /tmp/socat.log 2>&1 &" | |
| # 4. Start Opencode headless server | |
| echo "Starting Opencode headless server inside sandbox..." | |
| sbx exec "$SANDBOX_NAME" pkill -f "opencode serve" || true | |
| # Collect any host environment variables prefixed with OPENCODE_ to forward to the sandbox | |
| ENV_FLAGS=() | |
| # Always set OPENCODE_OFFLINE=1 as a sane default, unless overridden by the host environment | |
| if [ -z "$OPENCODE_OFFLINE" ]; then | |
| ENV_FLAGS+=("-e" "OPENCODE_OFFLINE=1") | |
| fi | |
| for var in $(env | grep -E '^OPENCODE_' | cut -d= -f1); do | |
| ENV_FLAGS+=("-e" "$var=${!var}") | |
| done | |
| sbx exec "${ENV_FLAGS[@]}" "$SANDBOX_NAME" sh -c "cd /home/agent/workspace && nohup opencode serve --port 4096 --hostname 0.0.0.0 >> /tmp/opencode.log 2>&1 &" | |
| # 5. Expose host port to sandbox port 4096 (all interfaces for Tailnet) | |
| echo "Exposing port $EXPOSED_PORT over Tailnet (binding 0.0.0.0:$EXPOSED_PORT to sandbox:4096)..." | |
| sbx ports "$SANDBOX_NAME" --unpublish "$EXPOSED_PORT:4096" 2>/dev/null || true | |
| sbx ports "$SANDBOX_NAME" --publish "0.0.0.0:$EXPOSED_PORT:4096" | |
| # 6. Start host-side keep-alive process to prevent auto-stop | |
| echo "Starting keep-alive session to prevent auto-stop..." | |
| pkill -f "sbx exec $SANDBOX_NAME sleep infinity" || true | |
| nohup sbx exec "$SANDBOX_NAME" sleep infinity >/dev/null 2>&1 & | |
| echo "--------------------------------------------------------" | |
| echo "Opencode headless server is running in Sandbox!" | |
| echo "Workspace: $WORKSPACE_DIR" | |
| echo "Config: $CONFIG_DIR/opencode.json" | |
| echo "Port: $EXPOSED_PORT (0.0.0.0, available over Tailnet)" | |
| echo "LLM Bridge: localhost:4000 inside sandbox -> host:4000 (llama.cpp)" | |
| echo "Check sandbox status: sbx ls" | |
| echo "View Opencode server logs: sbx exec $SANDBOX_NAME cat /tmp/opencode.log" | |
| echo "View forwarder logs: sbx exec $SANDBOX_NAME cat /tmp/socat.log" | |
| echo "--------------------------------------------------------" | |
| elif [ "$COMMAND" = "stop" ]; then | |
| echo "Target Workspace: $WORKSPACE_DIR" | |
| echo "Target Sandbox: $SANDBOX_NAME" | |
| if ! sbx ls | grep -q "$SANDBOX_NAME"; then | |
| echo "Sandbox $SANDBOX_NAME does not exist." | |
| exit 0 | |
| fi | |
| echo "Stopping Opencode serve inside sandbox..." | |
| sbx exec "$SANDBOX_NAME" pkill -f "opencode serve" || true | |
| echo "Stopping socat port forwarder inside sandbox..." | |
| sbx exec "$SANDBOX_NAME" pkill -f "socat TCP-LISTEN:4000" || true | |
| echo "Stopping host-side keep-alive session..." | |
| pkill -f "sbx exec $SANDBOX_NAME sleep infinity" || true | |
| # Clean up published ports | |
| echo "Cleaning up published ports..." | |
| sbx ports "$SANDBOX_NAME" 2>/dev/null | grep "Published" | while read -r line; do | |
| # Extract host binding (e.g. 0.0.0.0:3000) and container port (e.g. 4096/tcp) | |
| UNPUB_FORMAT=$(echo "$line" | awk '{print $2 ":" $4}' | sed 's/\/tcp//') | |
| sbx ports "$SANDBOX_NAME" --unpublish "$UNPUB_FORMAT" 2>/dev/null || true | |
| done | |
| echo "Stopping sandbox..." | |
| sbx stop "$SANDBOX_NAME" | |
| echo "Sandbox $SANDBOX_NAME has been stopped." | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment