opencode-sandbox: OpenCode 2 in a sandbox for any project

Runs OpenCode 2 with full permissions while containing its mistakes:

- egress filter: squid in a container per project; squid.conf is rendered
  from layered egress.conf files (package, user, project, local) with
  allow/block/connect rules and expect-pass/expect-block self-tests
- netlock: nftables rejects direct egress for the opencode-sandbox group
  (installed once with sudo as a systemd unit)
- bubblewrap: read-only host, writable project, worktrees and state;
  credentials hidden; ssh keys via the host's ssh-agent; session bus
  replaced by an xdg-dbus-proxy limited to notifications; no permission
  prompts (sudo and systemd-run denied); refuses to run unsandboxed
- per-project state in ~/.local/share/opencode-sandbox/<name>
- worktrees: `wt add|rm|list` with project hooks
- skills mounted per project; the opencode-sandbox skill explains the
  environment to the agent
- plugins as submodules: context-panel, skill-compact-resurrect;
  global config adds opencode-notifier and cc-safety-net
- README in English and Russian
This commit is contained in:
2026-10-30 01:00:02 -07:00
commit 64c222a084
29 changed files with 1945 additions and 0 deletions
+158
View File
@@ -0,0 +1,158 @@
# shellcheck shell=bash
# Shared helpers: messages, project discovery, configuration, state paths.
ocsb_die() {
echo "opencode-sandbox: $*" >&2
exit 1
}
ocsb_warn() { echo "⚠ $*" >&2; }
ocsb_info() { echo "→ $*"; }
# --- configuration ------------------------------------------------------------------------
# Layers, later wins:
# 1. defaults below
# 2. ~/.config/opencode-sandbox/sandbox.conf (user, all projects)
# 3. <project>/.opencode-sandbox/sandbox.conf (committed)
# 4. <project>/.opencode-sandbox/local/sandbox.conf (git-ignored, personal)
# 5. OCSB_<SETTING> from the environment (scalars only, one run)
# Config files are bash: scalars and arrays. OCSB_PROJECT_DIR is already set when they load.
OCSB_SCALARS=(
OCSB_NAME OCSB_WEB_PORT OCSB_EGRESS_PORT OCSB_NETLOCK_GROUP OCSB_SANDBOX OCSB_PANEL
OCSB_NOTIFICATIONS OCSB_OPENCODE_BIN OCSB_ENV_FILE OCSB_KUBECONFIG OCSB_PASSWORD_FILE
OCSB_WORKTREES_DIR OCSB_WT_PREFIX OCSB_WT_BASE OCSB_WT_SLUG_REGEX OCSB_DOCKER_GW
OCSB_STATE_DIR OCSB_OPENCODE_CONFIG OCSB_EGRESS_BASE
)
OCSB_ARRAYS=(OCSB_RW OCSB_RW_HOME OCSB_HIDE OCSB_HIDE_HOME OCSB_SKILLS OCSB_SKILL_DIRS OCSB_PLUGINS OCSB_ENV)
ocsb_defaults() {
OCSB_NAME="" # default: basename of the project directory
OCSB_WEB_PORT=4196 # browser port (context panel); OpenCode itself listens on +1
OCSB_EGRESS_PORT=3128 # squid port on 127.0.0.1 and the docker0 gateway
OCSB_NETLOCK_GROUP=opencode-sandbox
OCSB_SANDBOX=1 # bubblewrap; 0 = egress filter and netlock only
OCSB_PANEL=1 # "Tokens" panel in the web UI (plugins/context-panel)
OCSB_NOTIFICATIONS=1 # desktop notifications through a filtered D-Bus proxy
OCSB_OPENCODE_BIN="" # default: `opencode` from PATH
OCSB_ENV_FILE="" # KEY=VALUE file with tokens, exported to OpenCode
OCSB_KUBECONFIG="" # kubeconfig for the agent (~/.kube/config stays hidden)
OCSB_PASSWORD_FILE="" # web UI password; default: generated in the state dir
OCSB_WORKTREES_DIR="" # default: <parent of project>/<project>-worktrees
OCSB_WT_PREFIX="" # worktree directory = <prefix><slug>
OCSB_WT_BASE="" # branch new worktrees start from; default: current HEAD
OCSB_WT_SLUG_REGEX="" # ERE with one group: slug from the branch name
OCSB_DOCKER_GW="" # default: address of docker0
OCSB_STATE_DIR="" # default: ~/.local/share/opencode-sandbox/<name>
OCSB_OPENCODE_CONFIG="" # global OpenCode config; default: user's or the package's
OCSB_RW=() # extra paths the agent may write (relative to the project)
# Home directories that stay writable for toolchains and caches (only if they exist)
OCSB_RW_HOME=(.cache .npm .bun .bundle .gem .asdf .local/share/mise .cargo .rustup go
.m2 .gradle .docker/buildx .kube/cache)
OCSB_HIDE=() # extra paths hidden from the agent (relative to the project)
# Hidden home paths: credentials of other tools, browsers, other agents
OCSB_HIDE_HOME=(.gnupg .aws .azure .config/gcloud .kube/config .docker/config.json .netrc
.git-credentials .config/gh/hosts.yml .password-store .local/share/keyrings .mozilla
.config/google-chrome .config/chromium .config/BraveSoftware .claude .codex
.config/opencode .local/share/opencode)
OCSB_SKILLS=(opencode-sandbox) # skills mounted for the agent (names of skill directories)
OCSB_SKILL_DIRS=() # extra directories to look for skills in
OCSB_PLUGINS=(skill-compact-resurrect/skill-compact-resurrect.v2.ts) # files in plugins/
OCSB_ENV=() # extra KEY=VALUE for OpenCode
}
ocsb_find_project() {
local dir=${1:-$PWD}
while [[ ${dir} != / ]]; do
[[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] && { echo "${dir}"; return 0; }
dir=$(dirname "${dir}")
done
return 1
}
# ocsb_load_project [--optional]: find the project, load config layers, derive paths
ocsb_load_project() {
local optional=0 saved=() v f
[[ ${1:-} == --optional ]] && optional=1
for v in "${OCSB_SCALARS[@]}"; do
[[ -n ${!v+x} ]] && saved+=("${v}=${!v}")
done
ocsb_defaults
OCSB_PROJECT_DIR=""
if [[ -n ${OCSB_PROJECT:-} ]]; then
OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd)
else
OCSB_PROJECT_DIR=$(ocsb_find_project) || true
fi
if [[ -z ${OCSB_PROJECT_DIR} || ! -f ${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf ]]; then
[[ ${optional} == 1 ]] || ocsb_die "no .opencode-sandbox/sandbox.conf here or above (run: opencode-sandbox init)"
fi
export OCSB_PROJECT_DIR
for f in "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/sandbox.conf" \
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf"} \
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/sandbox.conf"}; do
# shellcheck disable=SC1090
[[ -f ${f} ]] && source "${f}"
done
for v in "${saved[@]}"; do
printf -v "${v%%=*}" '%s' "${v#*=}"
done
ocsb_derive
}
ocsb_derive() {
local base
base=$(basename "${OCSB_PROJECT_DIR:-default}")
OCSB_NAME=${OCSB_NAME:-${base}}
OCSB_NAME=$(printf '%s' "${OCSB_NAME}" | tr -c 'a-zA-Z0-9_.-' '-' | tr '[:upper:]' '[:lower:]')
OCSB_STATE_DIR=${OCSB_STATE_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/opencode-sandbox/${OCSB_NAME}}
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
OCSB_WORKTREES_DIR=${OCSB_WORKTREES_DIR:-$(dirname "${OCSB_PROJECT_DIR}")/${base}-worktrees}
OCSB_WORKTREES_DIR=$(ocsb_abs "${OCSB_WORKTREES_DIR}")
fi
if [[ -z ${OCSB_DOCKER_GW} ]]; then
OCSB_DOCKER_GW=$(ip -4 -o addr show docker0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
OCSB_DOCKER_GW=${OCSB_DOCKER_GW:-172.17.0.1}
fi
OCSB_CONTAINER="opencode-sandbox-egress-${OCSB_NAME}"
OCSB_IMAGE="opencode-sandbox-egress"
}
# Path relative to the project (or ~/..., or absolute) → absolute path
ocsb_abs() {
local p=$1
# shellcheck disable=SC2088 # a literal "~" comes from config files
case "${p}" in
/*) ;;
"~") p=${HOME} ;;
"~/"*) p=${HOME}/${p#"~/"} ;;
*) p=${OCSB_PROJECT_DIR}/${p} ;;
esac
realpath -m "${p}"
}
ocsb_print_config() {
local v
echo "# project: ${OCSB_PROJECT_DIR:-none}"
for v in "${OCSB_SCALARS[@]}"; do
printf '%s=%q\n' "${v}" "${!v}"
done
for v in "${OCSB_ARRAYS[@]}"; do
local -n arr=${v}
printf '%s=(' "${v}"
[[ ${#arr[@]} -gt 0 ]] && printf ' %q' "${arr[@]}"
printf ' )\n'
unset -n arr
done
}
# OpenCode binary: OCSB_OPENCODE_BIN (a path, relative to the project, or a command) or PATH
ocsb_opencode_bin() {
local b=${OCSB_OPENCODE_BIN:-opencode}
if [[ ${b} == */* || ${b} == "~"* ]]; then
ocsb_abs "${b}"
else
command -v "${b}" || true
fi
}
+39
View File
@@ -0,0 +1,39 @@
# shellcheck shell=bash
# opencode-sandbox doctor: dependencies and host setup
ocsb_doctor() {
local fail=0 c
ocsb_load_project --optional
echo "Required:"
for c in docker bwrap sg curl jq ncat git; do
if command -v "${c}" >/dev/null; then echo " ✓ ${c}"; else echo " ✗ ${c}"; fail=1; fi
done
local bin
bin=$(ocsb_opencode_bin)
if [[ -x ${bin} ]]; then
echo " ✓ opencode: ${bin} ($("${bin}" --version 2>/dev/null | head -1))"
elif [[ -z ${OCSB_PROJECT_DIR} ]]; then
echo " - opencode: not in PATH (fine if projects set OCSB_OPENCODE_BIN; run doctor inside a project to check)"
else
echo " ✗ opencode: ${bin:-not in PATH} (set OCSB_OPENCODE_BIN in sandbox.conf or put opencode in PATH)"
fail=1
fi
echo "Optional:"
command -v xdg-dbus-proxy >/dev/null && echo " ✓ xdg-dbus-proxy (notifications)" || echo " - xdg-dbus-proxy: no desktop notifications in the sandbox"
if ssh-add -l >/dev/null 2>&1; then echo " ✓ ssh-agent has keys"; else echo " - ssh-agent has no keys: git over SSH fails in the sandbox (ssh-add)"; fi
[[ -d ${OCSB_HOME}/plugins/context-panel/.git || -f ${OCSB_HOME}/plugins/context-panel/.git ]] &&
echo " ✓ plugins checked out" || echo " - plugins missing: git -C ${OCSB_HOME} submodule update --init"
echo "Host:"
if bwrap --ro-bind / / --dev /dev true 2>/dev/null; then echo " ✓ unprivileged bubblewrap works"
else echo " ✗ bubblewrap cannot create a sandbox (user namespaces disabled?)"; fail=1; fi
source "${OCSB_HOME}/lib/netlock.sh"
ocsb_netlock status
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
echo "Project: ${OCSB_PROJECT_DIR} (name ${OCSB_NAME}, state ${OCSB_STATE_DIR})"
source "${OCSB_HOME}/lib/egress.sh"
ocsb_egress status
else
echo "Project: none here (opencode-sandbox init)"
fi
return ${fail}
}
+223
View File
@@ -0,0 +1,223 @@
# shellcheck shell=bash
# Egress filter: squid in a container on the host network, listening on loopback and the
# docker0 gateway. squid.conf is rendered from egress.conf layers (later layers add rules):
# <package>/egress/defaults.conf
# ~/.config/opencode-sandbox/egress.conf
# <project>/.opencode-sandbox/egress.conf (committed with the project)
# <project>/.opencode-sandbox/local/egress.conf (git-ignored)
#
# egress.conf lines (# starts a comment):
# allow <domain>... allowed even if a block rule matches (.domain = with subdomains)
# allow-regex <regex>... same, by regular expression
# block <domain>... denied
# block-regex <regex>...
# connect <host:port>... raw CONNECT tunnel to a non-443 port or an internal IP
# (git over SSH, Kubernetes API); host may be an IP or CIDR
# expect-block <url> [curl args] self-test: must be refused (egress verify)
# expect-pass <url> [curl args] self-test: must go through
# Built in, after all of the above: non-80/443 ports, names without a dot, private networks
# are denied; everything else is allowed (blacklist policy).
ocsb_egress_layers() {
local f
for f in "${OCSB_HOME}/egress/defaults.conf" \
"${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/egress.conf" \
"${OCSB_PROJECT_DIR}/.opencode-sandbox/egress.conf" \
"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/egress.conf"; do
[[ -f ${f} ]] && echo "${f}"
done
}
# Prints "directive<TAB>arg" for every argument of every rule line
ocsb_egress_rules() {
local f line directive rest a args
while IFS= read -r f; do
while IFS= read -r line || [[ -n ${line} ]]; do
line=${line%%#*}
read -r directive rest <<<"${line}"
[[ -z ${directive} ]] && continue
case "${directive}" in
allow | allow-regex | block | block-regex | connect)
read -ra args <<<"${rest}"
for a in "${args[@]}"; do printf '%s\t%s\n' "${directive}" "${a}"; done
;;
expect-block | expect-pass)
printf '%s\t%s\n' "${directive}" "${rest}"
;;
*) ocsb_die "${f}: unknown directive '${directive}'" ;;
esac
done <"${f}"
done < <(ocsb_egress_layers)
}
ocsb_is_ip() { [[ $1 =~ ^[0-9.]+(/[0-9]+)?$ || $1 =~ ^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]+)?$ ]]; }
ocsb_egress_render() {
local out=${OCSB_STATE_DIR}/egress/squid.conf rules allow=() allow_re=() block=() block_re=()
local connect=() d a i=0 host port
mkdir -p "${OCSB_STATE_DIR}/egress"
rules=$(ocsb_egress_rules)
while IFS=$'\t' read -r d a; do
case "${d}" in
allow) allow+=("${a}") ;;
allow-regex) allow_re+=("${a}") ;;
block) block+=("${a}") ;;
block-regex) block_re+=("${a}") ;;
connect) connect+=("${a}") ;;
esac
done <<<"${rules}"
{
echo "# Rendered by opencode-sandbox from egress.conf layers — do not edit, it is overwritten."
echo "http_port 127.0.0.1:${OCSB_EGRESS_PORT}"
echo "http_port ${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}"
echo "visible_hostname opencode-sandbox-egress"
echo "pid_filename /tmp/squid.pid"
echo "cache deny all"
echo "forwarded_for off"
echo "access_log /var/log/squid/access.log"
echo "logfile_rotate 10"
echo
echo "acl CONNECT method CONNECT"
echo "acl SSL_ports port 443"
echo "acl Safe_ports port 80 443"
for a in "${connect[@]}"; do
host=${a%:*}
port=${a##*:}
[[ ${host} != "${a}" && ${port} =~ ^[0-9]+$ ]] || ocsb_die "connect ${a}: expected host:port"
host=${host#[}
host=${host%]}
i=$((i + 1))
if ocsb_is_ip "${host}"; then
echo "acl connect_${i}_host dst ${host}"
else
echo "acl connect_${i}_host dstdomain ${host}"
fi
echo "acl connect_${i}_port port ${port}"
done
[[ ${#allow[@]} -gt 0 ]] && echo "acl allowed dstdomain ${allow[*]}"
[[ ${#allow_re[@]} -gt 0 ]] && echo "acl allowed_re dstdom_regex -i ${allow_re[*]}"
[[ ${#block[@]} -gt 0 ]] && echo "acl blocked dstdomain ${block[*]}"
[[ ${#block_re[@]} -gt 0 ]] && echo "acl blocked_re dstdom_regex -i ${block_re[*]}"
echo "acl unqualified dstdom_regex ^[^.]+\$"
echo "acl private_dst dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 127.0.0.0/8 fc00::/7 fe80::/10 ::1"
echo
for ((i = 1; i <= ${#connect[@]}; i++)); do
echo "http_access allow CONNECT connect_${i}_host connect_${i}_port"
done
echo "http_access deny !Safe_ports"
echo "http_access deny CONNECT !SSL_ports"
[[ ${#allow[@]} -gt 0 ]] && echo "http_access allow allowed"
[[ ${#allow_re[@]} -gt 0 ]] && echo "http_access allow allowed_re"
[[ ${#block[@]} -gt 0 ]] && echo "http_access deny blocked"
[[ ${#block_re[@]} -gt 0 ]] && echo "http_access deny blocked_re"
echo "http_access deny unqualified"
echo "http_access deny private_dst"
echo "http_access allow all"
} >"${out}.tmp"
# Replace via rename into the mounted directory: squid -k reconfigure then sees the new file
mv "${out}.tmp" "${out}"
}
ocsb_egress_src_hash() {
cat "${OCSB_HOME}/egress/Dockerfile" "${OCSB_HOME}/egress/entrypoint.sh" | sha256sum | cut -c1-16
}
ocsb_egress_running() {
[[ $(docker inspect -f '{{.State.Running}}' "${OCSB_CONTAINER}" 2>/dev/null) == true ]]
}
ocsb_egress_up() {
local hash
hash=$(ocsb_egress_src_hash)
ocsb_egress_render
if [[ $(docker image inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_IMAGE}" 2>/dev/null) != "${hash}" ]]; then
ocsb_info "egress: building image ${OCSB_IMAGE}"
docker build -q --label "opencode-sandbox.src-hash=${hash}" -t "${OCSB_IMAGE}" "${OCSB_HOME}/egress" >/dev/null
fi
if ocsb_egress_running &&
[[ $(docker inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_CONTAINER}") == "${hash}" ]]; then
ocsb_info "egress: ${OCSB_CONTAINER} running, reloading rules"
docker exec "${OCSB_CONTAINER}" squid -k reconfigure -f /etc/opencode-sandbox/squid.conf
sleep 1
return
fi
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
ocsb_die "port ${OCSB_EGRESS_PORT} is busy (another project's filter?) — set OCSB_EGRESS_PORT in sandbox.conf"
fi
ocsb_info "egress: starting ${OCSB_CONTAINER} on 127.0.0.1:${OCSB_EGRESS_PORT}"
docker run -d --name "${OCSB_CONTAINER}" --restart unless-stopped \
--label "opencode-sandbox.src-hash=${hash}" \
-v "${OCSB_STATE_DIR}/egress:/etc/opencode-sandbox:ro,z" \
--network host "${OCSB_IMAGE}" >/dev/null
local i
for i in $(seq 30); do
(exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null && return 0
sleep 0.2
done
docker logs --tail 20 "${OCSB_CONTAINER}" >&2
ocsb_die "egress: squid did not start"
}
# One self-test: want = block|pass
ocsb_egress_check() {
local want=$1 url=$2 connect http got
shift 2
read -r connect http < <(curl -s -o /dev/null -w '%{http_connect} %{http_code}' \
--max-time 15 -x "http://127.0.0.1:${OCSB_EGRESS_PORT}" "$@" "${url}" </dev/null)
if [[ ${connect} == 403 || ${http} == 403 ]]; then got=block
elif [[ ${connect} == 200 || ${http} != 000 ]]; then got=pass
else got=error; fi
if [[ ${got} == "${want}" ]]; then
printf ' ✓ %-5s %s\n' "${want}" "${url}"
else
printf ' ✗ %-5s %s → %s (connect=%s http=%s)\n' "${want}" "${url}" "${got}" "${connect}" "${http}"
return 1
fi
}
ocsb_egress_verify() {
local fail=0 d a url args
echo "Egress self-test (127.0.0.1:${OCSB_EGRESS_PORT}):"
ocsb_egress_check block https://localhost-name-without-dot/ || fail=1
ocsb_egress_check block https://10.255.255.1/ || fail=1
ocsb_egress_check block telnet://example.com:22 -p || fail=1
while IFS=$'\t' read -r d a; do
[[ ${d} == expect-* ]] || continue
read -r url args <<<"${a}"
# shellcheck disable=SC2086 # args are curl options from egress.conf
ocsb_egress_check "${d#expect-}" "${url}" ${args} || fail=1
done < <(ocsb_egress_rules)
return ${fail}
}
ocsb_egress() {
local sub=${1:-status}
case "${sub}" in
up) ocsb_egress_up ;;
down)
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
echo "✓ ${OCSB_CONTAINER} removed"
;;
status)
if ocsb_egress_running; then
echo " container ${OCSB_CONTAINER}: running"
else
echo " container ${OCSB_CONTAINER}: not running (opencode-sandbox egress up)"
fi
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: open"
else
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: closed"
fi
;;
logs) docker logs -f --tail 50 "${OCSB_CONTAINER}" ;;
verify) ocsb_egress_verify ;;
render)
ocsb_egress_render
cat "${OCSB_STATE_DIR}/egress/squid.conf"
;;
*) ocsb_die "egress: unknown subcommand ${sub} (up|down|status|logs|verify|render)" ;;
esac
}
+31
View File
@@ -0,0 +1,31 @@
# shellcheck shell=bash
# opencode-sandbox init: add .opencode-sandbox/ to the current git repository
ocsb_init() {
local root t=${OCSB_HOME}/templates/project f
root=$(git rev-parse --show-toplevel 2>/dev/null) || ocsb_die "run init inside a git repository"
if [[ -f ${root}/.opencode-sandbox/sandbox.conf ]]; then
ocsb_die "${root}/.opencode-sandbox already exists"
fi
mkdir -p "${root}/.opencode-sandbox/hooks" "${root}/.opencode-sandbox/local"
cp "${t}/sandbox.conf" "${t}/egress.conf" "${root}/.opencode-sandbox/"
printf '/local/\n' >"${root}/.opencode-sandbox/.gitignore"
for f in "${t}"/hooks/*.example; do
cp "${f}" "${root}/.opencode-sandbox/hooks/"
done
if [[ ! -e ${root}/.cc-safety-net/policy.json ]]; then
mkdir -p "${root}/.cc-safety-net"
cp "${t}/cc-safety-net-policy.json" "${root}/.cc-safety-net/policy.json"
fi
cat <<EOF
✓ created ${root}/.opencode-sandbox/
sandbox.conf settings (ports, writable paths, skills, worktrees) — commit it
egress.conf domains for this project — commit it
hooks/ worktree hooks: drop .example and chmod +x to enable
local/ personal overrides (sandbox.conf, egress.conf), git-ignored
Next:
opencode-sandbox doctor check the host
opencode-sandbox run start OpenCode in the sandbox
EOF
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Netlock, run as root (opencode-sandbox netlock setup|teardown calls it through sudo).
# Processes whose primary group is GROUP may only talk to loopback (where the egress filter
# listens) and to DNS servers from resolv.conf; everything else is rejected. The rules are
# loaded by a systemd unit and survive reboots.
# netlock-root.sh setup <group> <user>
# netlock-root.sh teardown
set -euo pipefail
TABLE=opencode_sandbox
CONF_DIR=/etc/opencode-sandbox
RULES="${CONF_DIR}/netlock.nft"
UNIT=opencode-sandbox-netlock.service
UNIT_PATH="/etc/systemd/system/${UNIT}"
[[ ${EUID} -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
setup() {
local group=$1 user=${2:-} gid ns dns_rules=""
getent group "${group}" >/dev/null || groupadd "${group}"
gid=$(getent group "${group}" | cut -d: -f3)
if [[ -n ${user} ]] && ! id -nG "${user}" | tr ' ' '\n' | grep -qx "${group}"; then
usermod -aG "${group}" "${user}"
echo " ${user} added to group ${group} (log out and in, or use the new group via sg)"
fi
# The loopback resolver (systemd-resolved) is covered by the loopback rule; non-local
# nameservers are allowed one by one, not port 53 anywhere
while read -r ns; do
case "${ns}" in
127.* | ::1) ;;
*:*) dns_rules+=" meta skgid ${gid} ip6 daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
*) dns_rules+=" meta skgid ${gid} ip daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
esac
done < <(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf)
mkdir -p "${CONF_DIR}"
cat >"${RULES}" <<EOF
# Generated by opencode-sandbox (lib/netlock-root.sh) — edit the script, not this file.
table inet ${TABLE} {
chain output {
type filter hook output priority 0; policy accept;
meta skgid ${gid} ip daddr 127.0.0.0/8 accept
meta skgid ${gid} ip6 daddr ::1 accept
${dns_rules} meta skgid ${gid} reject with icmpx admin-prohibited
}
}
EOF
chmod 644 "${RULES}"
cat >"${UNIT_PATH}" <<EOF
[Unit]
Description=opencode-sandbox netlock (nftables, group ${group})
Wants=network-pre.target
Before=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/usr/sbin/nft delete table inet ${TABLE}
ExecStart=/usr/sbin/nft -f ${RULES}
ExecStop=/usr/sbin/nft delete table inet ${TABLE}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${UNIT}" >/dev/null 2>&1
systemctl restart "${UNIT}"
echo "✓ netlock: group ${group} (gid ${gid}), unit ${UNIT} enabled"
echo " allowed: 127.0.0.0/8, ::1${dns_rules:+, DNS servers from resolv.conf}"
}
teardown() {
if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then
systemctl disable --now "${UNIT}" >/dev/null 2>&1 || true
rm -f "${UNIT_PATH}"
systemctl daemon-reload
fi
nft list table inet "${TABLE}" >/dev/null 2>&1 && nft delete table inet "${TABLE}"
rm -rf "${CONF_DIR}"
echo "✓ netlock removed (unit ${UNIT}, table ${TABLE}); the group is kept"
}
case "${1:-}" in
setup) shift; setup "$@" ;;
teardown) teardown ;;
*) echo "usage: $0 setup <group> [user] | teardown" >&2; exit 1 ;;
esac
+75
View File
@@ -0,0 +1,75 @@
# shellcheck shell=bash
# Netlock: direct egress (ping, curl by IP, ssh, anything not via the egress filter) is
# rejected for processes running with the netlock group as their primary group. OpenCode
# is started through `sg <group>`, so the agent and everything it spawns are covered.
OCSB_NETLOCK_UNIT=opencode-sandbox-netlock.service
OCSB_NETLOCK_PROBE=${OCSB_NETLOCK_PROBE:-1.1.1.1/443}
ocsb_netlock_active() { systemctl is-active --quiet "${OCSB_NETLOCK_UNIT}"; }
ocsb_netlock_member() {
local members
getent group "${OCSB_NETLOCK_GROUP}" >/dev/null || return 1
members=$(getent group "${OCSB_NETLOCK_GROUP}" | cut -d: -f4)
[[ ",${members}," == *",${USER},"* ]]
}
ocsb_netlock_setup() {
sudo "${OCSB_HOME}/lib/netlock-root.sh" setup "${OCSB_NETLOCK_GROUP}" "${USER}"
}
# Fails unless direct TCP from the group is rejected (not merely timing out)
ocsb_netlock_check() {
local rc=0
ocsb_netlock_member || ocsb_die "${USER} is not in group ${OCSB_NETLOCK_GROUP} (opencode-sandbox netlock setup)"
sg "${OCSB_NETLOCK_GROUP}" -c "timeout 10 bash -c 'exec 3<>/dev/tcp/${OCSB_NETLOCK_PROBE}'" 2>/dev/null || rc=$?
case ${rc} in
0) ocsb_die "netlock is NOT working: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} from ${OCSB_NETLOCK_GROUP} succeeded" ;;
124) ocsb_die "netlock check inconclusive: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} hung instead of being rejected" ;;
esac
}
ocsb_netlock_verify() {
local code
echo "Netlock self-test (group ${OCSB_NETLOCK_GROUP}):"
if sg "${OCSB_NETLOCK_GROUP}" -c 'curl --noproxy "*" -s -o /dev/null --max-time 5 https://example.com/' 2>/dev/null; then
echo " ✗ direct HTTPS bypassing the filter went through"; return 1
fi
echo " ✓ direct HTTPS bypassing the filter is blocked"
if sg "${OCSB_NETLOCK_GROUP}" -c 'ping -c1 -W1 1.1.1.1' >/dev/null 2>&1; then
echo " ✗ ping went through"; return 1
fi
echo " ✓ ping is blocked"
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
# What is checked is that the group reaches the proxy and the proxy opens the tunnel
# (CONNECT 200), not that a particular remote site answers in time
code=$(sg "${OCSB_NETLOCK_GROUP}" -c "curl -sx http://127.0.0.1:${OCSB_EGRESS_PORT} -o /dev/null -w '%{http_connect} %{http_code}' --max-time 20 https://example.com/" || true)
case "${code}" in
200\ * | *\ [23]??) echo " ✓ HTTPS through the filter → tunnel open (connect/http: ${code})" ;;
*) echo " ✗ HTTPS through the filter → connect/http: ${code:-none} (is the egress filter up?)"; return 1 ;;
esac
fi
}
ocsb_netlock() {
local sub=${1:-status}
case "${sub}" in
setup) ocsb_netlock_setup ;;
teardown) sudo "${OCSB_HOME}/lib/netlock-root.sh" teardown ;;
status)
if getent group "${OCSB_NETLOCK_GROUP}" >/dev/null; then
echo " group ${OCSB_NETLOCK_GROUP}: exists$(ocsb_netlock_member && echo ", ${USER} is a member")"
else
echo " group ${OCSB_NETLOCK_GROUP}: missing (opencode-sandbox netlock setup)"
fi
if systemctl is-enabled --quiet "${OCSB_NETLOCK_UNIT}" 2>/dev/null; then
echo " unit ${OCSB_NETLOCK_UNIT}: $(systemctl is-active "${OCSB_NETLOCK_UNIT}")"
else
echo " unit ${OCSB_NETLOCK_UNIT}: not installed (opencode-sandbox netlock setup)"
fi
;;
verify) ocsb_netlock_verify ;;
*) ocsb_die "netlock: unknown subcommand ${sub} (setup|teardown|status|verify)" ;;
esac
}
+288
View File
@@ -0,0 +1,288 @@
# shellcheck shell=bash
# Preparing and running OpenCode: per-project state, plugins, skills, bubblewrap, launch.
# Egress filter up, netlock in place, self-tests
ocsb_prepare() {
command -v docker >/dev/null || ocsb_die "docker is required for the egress filter"
ocsb_egress_up
if ! ocsb_netlock_member || ! (ocsb_netlock_check) 2>/dev/null; then
ocsb_info "netlock: not in place, installing (sudo)"
ocsb_netlock_setup
ocsb_netlock_member || ocsb_die "group ${OCSB_NETLOCK_GROUP} was just created: log out and back in, then retry"
fi
ocsb_netlock_check
ocsb_egress_verify || ocsb_die "egress self-test failed (rules: opencode-sandbox egress render; log: egress logs)"
ocsb_netlock_verify || ocsb_die "netlock self-test failed"
}
# XDG directory of the agent: entries of the user's directory as symlinks (git, gh, uv and
# friends keep working), except opencode — the agent gets its own.
ocsb_mirror_xdg() {
local src=$1 dst=$2 entry name
mkdir -p "${dst}"
chmod 700 "${dst}"
for entry in "${src}"/* "${src}"/.[!.]*; do
[[ -e ${entry} ]] || continue
name=$(basename "${entry}")
[[ ${name} == opencode ]] && continue
ln -sfn "${entry}" "${dst}/${name}"
done
}
ocsb_find_skill() {
local name=$1 dir
for dir in "${OCSB_HOME}/skills" "${OCSB_SKILL_DIRS[@]}"; do
dir=$(ocsb_abs "${dir}")
[[ -f ${dir}/${name}/SKILL.md ]] && { echo "${dir}/${name}"; return 0; }
done
return 1
}
ocsb_prepare_state() {
local s=${OCSB_STATE_DIR} cfg plugin skill src
mkdir -p "${s}"
chmod 700 "${s}"
ocsb_mirror_xdg "${XDG_CONFIG_HOME:-${HOME}/.config}" "${s}/config"
ocsb_mirror_xdg "${XDG_DATA_HOME:-${HOME}/.local/share}" "${s}/data"
ocsb_mirror_xdg "${XDG_STATE_HOME:-${HOME}/.local/state}" "${s}/state"
ocsb_mirror_xdg "${XDG_CACHE_HOME:-${HOME}/.cache}" "${s}/cache"
rm -f "${s}/data/opencode-sandbox" # the mirror would point back at the state root
# Global OpenCode config and instructions: explicit > user's > package's
cfg=${OCSB_OPENCODE_CONFIG:-${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/opencode.jsonc}
[[ -f ${cfg} ]] || cfg=${OCSB_HOME}/opencode/opencode.jsonc
mkdir -p "${s}/config/opencode"
ln -sfn "${cfg}" "${s}/config/opencode/opencode.jsonc"
ln -sfn "${OCSB_HOME}/opencode/AGENTS.md" "${s}/config/opencode/AGENTS.md"
# Plugins and skills are re-linked on every start: the list in sandbox.conf is the truth
rm -rf "${s}/config/opencode/plugins" "${s}/config/opencode/skills"
mkdir -p "${s}/config/opencode/plugins" "${s}/config/opencode/skills"
local plugins=("${OCSB_PLUGINS[@]}")
[[ ${OCSB_PANEL} == 1 ]] && plugins+=(context-panel/context-panel.v2.ts)
for plugin in "${plugins[@]}"; do
src=${OCSB_HOME}/plugins/${plugin}
[[ ${plugin} == /* ]] && src=${plugin}
if [[ -f ${src} ]]; then
ln -sfn "${src}" "${s}/config/opencode/plugins/$(basename "${src}")"
else
ocsb_warn "plugin not found: ${src} (git submodule update --init in ${OCSB_HOME}?)"
[[ ${plugin} == context-panel/* ]] && OCSB_PANEL=0
fi
done
for skill in "${OCSB_SKILLS[@]}"; do
if src=$(ocsb_find_skill "${skill}"); then
ln -sfn "${src}" "${s}/config/opencode/skills/${skill}"
else
ocsb_warn "skill not found: ${skill} (looked in ${OCSB_HOME}/skills ${OCSB_SKILL_DIRS[*]})"
fi
done
# Docker client config: the user's registry logins plus the proxy. Docker passes the proxy
# to RUN in builds and to containers' env; from a container 127.0.0.1 is the container,
# so the address is the docker0 gateway, where squid listens too.
mkdir -p "${s}/docker"
local proxy="http://${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}"
jq --arg p "${proxy}" '.proxies.default = {httpProxy: $p, httpsProxy: $p, noProxy: "localhost,127.0.0.1,::1"}' \
"${HOME}/.docker/config.json" 2>/dev/null >"${s}/docker/config.json" ||
jq -n --arg p "${proxy}" '.proxies.default = {httpProxy: $p, httpsProxy: $p, noProxy: "localhost,127.0.0.1,::1"}' \
>"${s}/docker/config.json"
chmod 600 "${s}/docker/config.json"
# Web UI password: OpenCode 2 refuses to serve without one
OCSB_PASSWORD_FILE=${OCSB_PASSWORD_FILE:-${s}/password}
if [[ ! -s ${OCSB_PASSWORD_FILE} ]]; then
(umask 077 && head -c 18 /dev/urandom | base64 | tr -d '/+=' >"${OCSB_PASSWORD_FILE}")
echo " generated web UI password: ${OCSB_PASSWORD_FILE}"
fi
}
# Filtered session bus: only desktop notifications, not systemd (systemd-run --user would
# start processes outside the sandbox)
ocsb_dbus_sock() { echo "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/opencode-sandbox-${OCSB_NAME}.bus"; }
ocsb_start_dbus_proxy() {
# In XDG_RUNTIME_DIR: unix socket paths are limited to 108 bytes
local sock
sock=$(ocsb_dbus_sock)
[[ ${OCSB_NOTIFICATIONS} == 1 && -n ${DBUS_SESSION_BUS_ADDRESS:-} ]] || return 1
command -v xdg-dbus-proxy >/dev/null || { ocsb_warn "no xdg-dbus-proxy — notifications are off in the sandbox"; return 1; }
rm -f "${sock}"
xdg-dbus-proxy "${DBUS_SESSION_BUS_ADDRESS}" "${sock}" --filter --talk=org.freedesktop.Notifications &
OCSB_DBUS_PROXY_PID=$!
local i
for i in $(seq 20); do [[ -S ${sock} ]] && return 0; sleep 0.1; done
kill "${OCSB_DBUS_PROXY_PID}" 2>/dev/null || true
ocsb_warn "xdg-dbus-proxy did not start — notifications are off"
return 1
}
# bubblewrap arguments for the sandbox (variant: read-only host, writable project)
ocsb_bwrap_args() {
local p f uid
uid=$(id -u)
OCSB_BWRAP=(
--die-with-parent --new-session --unshare-pid --unshare-ipc --unshare-uts
--ro-bind / / --dev /dev --proc /proc
--bind /tmp /tmp
--bind "${OCSB_PROJECT_DIR}" "${OCSB_PROJECT_DIR}"
--bind "${OCSB_WORKTREES_DIR}" "${OCSB_WORKTREES_DIR}"
--bind "${OCSB_STATE_DIR}" "${OCSB_STATE_DIR}"
--tmpfs "/run/user/${uid}"
)
for p in "${OCSB_RW[@]}"; do
p=$(ocsb_abs "${p}")
if [[ -e ${p} ]]; then
OCSB_BWRAP+=(--bind "${p}" "${p}")
else
ocsb_warn "OCSB_RW: no such path ${p}"
fi
done
for p in "${OCSB_RW_HOME[@]}"; do
[[ -e ${HOME}/${p} ]] && OCSB_BWRAP+=(--bind "${HOME}/${p}" "${HOME}/${p}")
done
# ssh: private keys hidden, the host's ssh-agent signs (load keys with ssh-add first)
if [[ -d ${HOME}/.ssh ]]; then
OCSB_BWRAP+=(--tmpfs "${HOME}/.ssh")
for f in "${HOME}"/.ssh/config "${HOME}"/.ssh/known_hosts "${HOME}"/.ssh/*.pub; do
[[ -e ${f} ]] && OCSB_BWRAP+=(--ro-bind "${f}" "${f}")
done
fi
if [[ -S ${SSH_AUTH_SOCK:-} ]]; then
OCSB_BWRAP+=(--bind "${SSH_AUTH_SOCK}" "${SSH_AUTH_SOCK}")
fi
# Hidden: directories become empty, files become empty
local hide=()
for p in "${OCSB_HIDE_HOME[@]}"; do hide+=("${HOME}/${p}"); done
for p in "${OCSB_HIDE[@]}"; do hide+=("$(ocsb_abs "${p}")"); done
for p in "${hide[@]}"; do
if [[ -d ${p} ]]; then
OCSB_BWRAP+=(--tmpfs "${p}")
elif [[ -e ${p} ]]; then
OCSB_BWRAP+=(--ro-bind /dev/null "${p}")
fi
done
if [[ -n ${OCSB_DBUS_PROXY_PID:-} && -S $(ocsb_dbus_sock) ]]; then
OCSB_BWRAP+=(--ro-bind "$(ocsb_dbus_sock)" "/run/user/${uid}/bus")
fi
OCSB_BWRAP+=(--chdir "${OCSB_PROJECT_DIR}")
}
ocsb_run() {
local bin
bin=$(ocsb_opencode_bin)
[[ -x ${bin} ]] || ocsb_die "OpenCode 2 not found (set OCSB_OPENCODE_BIN or put opencode in PATH)"
[[ -z ${OCSB_KUBECONFIG} || -r $(ocsb_abs "${OCSB_KUBECONFIG}") ]] || ocsb_die "OCSB_KUBECONFIG not readable: ${OCSB_KUBECONFIG}"
mkdir -p "${OCSB_WORKTREES_DIR}"
ocsb_prepare_state
# Tokens go to the environment before sg (sg keeps it), not into the command text (ps)
if [[ -n ${OCSB_ENV_FILE} ]]; then
local env_file
env_file=$(ocsb_abs "${OCSB_ENV_FILE}")
[[ -r ${env_file} ]] || ocsb_die "OCSB_ENV_FILE not readable: ${env_file}"
set -a
# shellcheck disable=SC1090
source "${env_file}"
set +a
fi
OPENCODE_SERVER_PASSWORD=$(head -1 "${OCSB_PASSWORD_FILE}")
export OPENCODE_SERVER_PASSWORD
unset OPENCODE_SERVER_USERNAME
local opencode_port=${OCSB_WEB_PORT}
if [[ ${OCSB_PANEL} == 1 ]]; then
# The panel's proxy lives inside OpenCode (plugins/context-panel) on WEB_PORT
opencode_port=$((OCSB_WEB_PORT + 1))
fi
local port
for port in "${OCSB_WEB_PORT}" "${opencode_port}"; do
if (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then
ocsb_die "port ${port} is busy (OpenCode for this project already running? else set OCSB_WEB_PORT)"
fi
done
local s=${OCSB_STATE_DIR} proxy="http://127.0.0.1:${OCSB_EGRESS_PORT}" kv
# git over SSH goes through the filter too (needs `connect host:port` in egress.conf).
# -F: only the user's config. Inside the sandbox root-owned files look owned by nobody,
# and ssh refuses the system ssh_config.d with "Bad owner"
local ssh_cmd="ssh"
[[ -f ${HOME}/.ssh/config ]] && ssh_cmd+=" -F ${HOME}/.ssh/config"
[[ -f ${HOME}/.ssh/config ]] || ssh_cmd+=" -F /dev/null"
ssh_cmd+=" -o ProxyCommand=\"ncat --proxy 127.0.0.1:${OCSB_EGRESS_PORT} --proxy-type http %h %p\""
local env=(
"HTTP_PROXY=${proxy}" "http_proxy=${proxy}" "HTTPS_PROXY=${proxy}" "https_proxy=${proxy}"
"NO_PROXY=localhost,127.0.0.1,::1" "no_proxy=localhost,127.0.0.1,::1"
"GIT_SSH_COMMAND=${ssh_cmd}"
"XDG_CONFIG_HOME=${s}/config" "XDG_DATA_HOME=${s}/data" "XDG_STATE_HOME=${s}/state" "XDG_CACHE_HOME=${s}/cache"
"DOCKER_CONFIG=${s}/docker"
"CC_SAFETY_NET_HOME=${s}/cc-safety-net"
# The agent may not open a browser: it would run outside the filter (shim/xdg-open)
"PATH=${OCSB_HOME}/shim:${OCSB_HOME}/bin:${PATH}"
"GDK_DEBUG=no-portals"
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1" "OPENCODE_DISABLE_AUTOUPDATE=1" "OPENCODE_DISABLE_SHARE=1"
"CTX_PANEL_LISTEN=${OCSB_WEB_PORT}" "CTX_PANEL_TARGET=${opencode_port}"
"OCSB_PROJECT=${OCSB_PROJECT_DIR}" "OCSB_IN_SANDBOX=${OCSB_SANDBOX}"
)
[[ -n ${OCSB_KUBECONFIG} ]] && env+=("KUBECONFIG=$(ocsb_abs "${OCSB_KUBECONFIG}")")
env+=("${OCSB_ENV[@]}")
local cmd=("${bin}" serve --hostname 127.0.0.1 --port "${opencode_port}" "$@")
if [[ ${OCSB_SANDBOX} == 1 ]]; then
command -v bwrap >/dev/null || ocsb_die "OCSB_SANDBOX=1 but bwrap is missing (install bubblewrap) — refusing to run unsandboxed"
ssh-add -l >/dev/null 2>&1 || ocsb_warn "ssh-agent has no keys — git over SSH will fail in the sandbox (ssh-add)"
if ocsb_start_dbus_proxy; then
env+=("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus")
trap 'kill "${OCSB_DBUS_PROXY_PID}" 2>/dev/null || true' EXIT
else
unset OCSB_DBUS_PROXY_PID
env+=("DBUS_SESSION_BUS_ADDRESS=")
fi
ocsb_bwrap_args
cmd=(bwrap "${OCSB_BWRAP[@]}" -- "${cmd[@]}")
# No permission prompts in the sandbox: permission rules from all configs form one
# list, the last match wins, and OPENCODE_CONFIG_CONTENT is the last document.
# Denies are repeated after "*" so they still win.
env+=('OPENCODE_CONFIG_CONTENT={"permission":{"bash":{"*":"allow","systemd-run *":"deny","sudo *":"deny"},"external_directory":{"*":"allow"}}}')
echo " sandbox: writable ${OCSB_PROJECT_DIR}, ${OCSB_WORKTREES_DIR}${OCSB_RW[*]:+, ${OCSB_RW[*]}}; the rest of the system is read-only"
fi
echo "→ OpenCode: ${OCSB_PROJECT_DIR}"
echo " web UI: http://127.0.0.1:${OCSB_WEB_PORT}/ — user opencode, password in ${OCSB_PASSWORD_FILE}"
# Inner command: env + exec, quoted for sg -c
local inner="exec env"
for kv in "${env[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
for kv in "${cmd[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
local opened=0 line
cd "${OCSB_PROJECT_DIR}" || return
sg "${OCSB_NETLOCK_GROUP}" -c "${inner}" 2>&1 | while IFS= read -r line; do
printf '%s\n' "${line}"
# The browser is opened by us, from the normal group, and only at the UI address
if [[ ${opened} -eq 0 && ${line} =~ http://127\.0\.0\.1:${opencode_port}(/|[[:space:]]|$) ]]; then
(ocsb_open_ui "${opencode_port}") &
opened=1
fi
done
}
# OpenCode loads plugins lazily, on the first API request for a project: until then the
# panel's proxy does not listen. Make that request, wait for the port, open the browser.
ocsb_open_ui() {
local opencode_port=$1 url="http://127.0.0.1:${OCSB_WEB_PORT}/" i
if [[ ${OCSB_PANEL} == 1 ]]; then
printf 'user = "opencode:%s"\n' "${OPENCODE_SERVER_PASSWORD}" |
curl -s -o /dev/null -m 10 -K - "http://127.0.0.1:${opencode_port}/api/plugin" || true
url="http://127.0.0.1:${opencode_port}/"
for i in $(seq 50); do
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_WEB_PORT}") 2>/dev/null; then
url="http://127.0.0.1:${OCSB_WEB_PORT}/"
break
fi
sleep 0.1
done
[[ ${url} == *":${OCSB_WEB_PORT}/" ]] || ocsb_warn "context panel did not come up — see ${OCSB_STATE_DIR}/data/opencode/log/context-panel.log"
fi
xdg-open "${url}" >/dev/null 2>&1 || true
}
+91
View File
@@ -0,0 +1,91 @@
# shellcheck shell=bash
# Worktrees for parallel agent sessions. Generic part here; project specifics (ports, .env,
# starting a stack, seeding) go into hooks:
# .opencode-sandbox/hooks/wt-post-add after `git worktree add`, cwd = the new worktree
# .opencode-sandbox/hooks/wt-pre-rm before removal (stop the stack), cwd = the worktree
# Hooks get WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT in the environment and the extra
# arguments of `wt add` / `wt rm` as their arguments. A failing hook aborts the command.
ocsb_wt_main_root() {
git -C "${OCSB_PROJECT_DIR}" rev-parse --path-format=absolute --git-common-dir | sed 's|/\.git$||'
}
ocsb_wt_slug() {
local branch=$1 slug=""
if [[ -n ${OCSB_WT_SLUG_REGEX} && ${branch} =~ ${OCSB_WT_SLUG_REGEX} ]]; then
slug=${BASH_REMATCH[1]}
fi
[[ -n ${slug} ]] || slug=$(printf '%s' "${branch}" | tr '/' '-' | tr -c 'a-zA-Z0-9._-' '-')
echo "${slug}"
}
ocsb_wt_hook() {
local name=$1 dir=$2
shift 2
local hook=${OCSB_PROJECT_DIR}/.opencode-sandbox/hooks/${name}
[[ -x ${hook} ]] || return 0
ocsb_info "hook ${name}"
(cd "${dir}" && "${hook}" "$@")
}
ocsb_wt_add() {
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt add <branch> [hook args]"
local branch=$1 main slug dir base
shift
git check-ref-format --branch "${branch}" >/dev/null 2>&1 || ocsb_die "invalid branch name: ${branch}"
main=$(ocsb_wt_main_root)
slug=$(ocsb_wt_slug "${branch}")
dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${slug}
[[ -e ${dir} ]] && ocsb_die "already exists: ${dir}"
base=${OCSB_WT_BASE:-HEAD}
if [[ ${base} != HEAD ]] && git -C "${main}" rev-parse -q --verify "origin/${base}" >/dev/null; then
echo " ${base} is $(git -C "${main}" rev-list --count "${base}..origin/${base}") commit(s) behind origin/${base} (git fetch if needed)"
fi
mkdir -p "${OCSB_WORKTREES_DIR}"
git -C "${main}" worktree add -b "${branch}" "${dir}" "${base}"
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${slug} MAIN_ROOT=${main}
ocsb_wt_hook wt-post-add "${dir}" "$@"
echo "✓ worktree ${dir} (branch ${branch}); remove: opencode-sandbox wt rm ${slug}"
}
ocsb_wt_rm() {
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt rm <name|path> [--force]"
local name=$1 force=0 main dir branch status a extra=()
shift
for a in "$@"; do
if [[ ${a} == --force ]]; then force=1; else extra+=("${a}"); fi
done
main=$(ocsb_wt_main_root)
case "${name}" in
/*) dir=${name} ;;
*) dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${name} ;;
esac
[[ -d ${dir} ]] || ocsb_die "no such worktree: ${dir}"
[[ $(realpath "${dir}") != "$(realpath "${main}")" ]] || ocsb_die "refusing to remove the main worktree"
branch=$(git -C "${dir}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${name##*/} MAIN_ROOT=${main}
ocsb_wt_hook wt-pre-rm "${dir}" "${extra[@]}"
status=$(git -C "${dir}" status --porcelain)
if [[ -n ${status} && ${force} -ne 1 ]]; then
echo "${status}" >&2
ocsb_die "worktree has uncommitted changes — commit them or pass --force (they will be lost)"
fi
if [[ ${force} -eq 1 ]]; then
git -C "${main}" worktree remove --force "${dir}"
else
git -C "${main}" worktree remove "${dir}"
fi
git -C "${main}" worktree prune
echo "✓ removed ${dir} (branch ${branch:-?} kept)"
}
ocsb_wt() {
local sub=${1:-list}
[[ $# -gt 0 ]] && shift
case "${sub}" in
add) ocsb_wt_add "$@" ;;
rm) ocsb_wt_rm "$@" ;;
list) git -C "${OCSB_PROJECT_DIR}" worktree list ;;
*) ocsb_die "wt: unknown subcommand ${sub} (add|rm|list)" ;;
esac
}