- /tmp: the agent gets its own (<state>/tmp, files older than 7 days are removed on start) — the host's /tmp holds sockets that lead out of the sandbox (tmux, X11, browsers' singleton sockets, other tools). OCSB_TMP=shared keeps the old behaviour. /tmp is mounted before the project and state binds so they stay visible if they live under /tmp - DISPLAY, WAYLAND_DISPLAY, XAUTHORITY are empty in the sandbox and ~/.Xauthority is hidden; browsers must run headless - GIT_SSH_COMMAND uses -F ~/.ssh/config: in the user namespace root-owned system ssh configs look owned by nobody and ssh refuses them - netlock self-test checks that the proxy opens the tunnel, not that a remote site answers; failed self-tests now say so instead of exiting silently - doctor: no false error for opencode outside a project - skill and READMEs: worktrees only for code changes, private /tmp, headless browsers, troubleshooting entries
303 lines
13 KiB
Bash
303 lines
13 KiB
Bash
# 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
|
|
--tmpfs "/run/user/${uid}"
|
|
)
|
|
# /tmp: the host's holds sockets that lead out of the sandbox (tmux, X11, browsers,
|
|
# other agents) — by default the agent gets its own, kept in the state directory.
|
|
# Mounted before the project and the state, so they stay visible if they live under /tmp
|
|
if [[ ${OCSB_TMP} == shared ]]; then
|
|
OCSB_BWRAP+=(--bind /tmp /tmp)
|
|
else
|
|
mkdir -p "${OCSB_STATE_DIR}/tmp"
|
|
chmod 700 "${OCSB_STATE_DIR}/tmp"
|
|
find "${OCSB_STATE_DIR}/tmp" -mindepth 1 -mtime +7 -delete 2>/dev/null || true
|
|
OCSB_BWRAP+=(--bind "${OCSB_STATE_DIR}/tmp" /tmp)
|
|
fi
|
|
OCSB_BWRAP+=(
|
|
--bind "${OCSB_PROJECT_DIR}" "${OCSB_PROJECT_DIR}"
|
|
--bind "${OCSB_WORKTREES_DIR}" "${OCSB_WORKTREES_DIR}"
|
|
--bind "${OCSB_STATE_DIR}" "${OCSB_STATE_DIR}"
|
|
)
|
|
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.
|
|
# No display: GUI programs fail fast; browsers must run headless
|
|
env+=("DISPLAY=" "WAYLAND_DISPLAY=" "XAUTHORITY=")
|
|
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
|
|
}
|