Files
opencode-public/openchamber-boostrap.sh
T
c4ch3c4d3 778341b7a5 Fix review findings: fail-closed merge, die ordering, sudo-path idempotency
- merge_policies: interpreter failure now aborts with the temp file
  removed instead of moving an empty tmp into place (errexit is
  suppressed when the function is called in a || context, so the
  status is checked explicitly); config stays untouched on failure
- move info/ok/warn/die above arg parsing so --allow-provider without
  a value prints the intended error, not 'die: command not found'
- install_enterprise_policy: compare content before writing on the
  sudo path (skip when already enforced, warn on drift), repair the
  no-root-with-existing-file case instead of pinning env vars, chmod
  644 the policy file in both paths
- tests: in-place merge, fail-closed on bad JSON, clean arg error
2026-10-06 10:45:56 -06:00

396 lines
15 KiB
Bash

#!/usr/bin/env bash
# openchamber-prereqs.sh — bootstrap a Linux host for opencode + openchamber
#
# Intent: run this once on a fresh Linux host, then connect from the
# OpenChamber desktop app over SSH — the app spawns openchamber itself
# and inherits the pinned environment. No UI password is used.
#
# Run as the user who will own the install. Idempotent: safe to re-run,
# every step checks before acting. No root required (enterprise policy
# file install uses sudo when available; see step 6).
#
# What it does:
# 1. Installs bun (if missing)
# 2. Creates the node -> bun shim (what makes `env node` work)
# 3. Pins PATH + OPENCHAMBER_RELAY_URL into shell rc files so
# non-interactive SSH sessions (OpenChamber's exec path) inherit them
# 4. Installs opencode (if missing)
# 5. Installs/updates @openchamber/web to latest via bun (every run)
# 6. Installs the enterprise policy file (/etc/openchamber/policy.json):
# enterprise mode + organization + pinned relay. Without root it
# falls back to OPENCHAMBER_ENTERPRISE_MODE=1 in the rc files
# (weaker lock — the user can edit those). Skip with --no-enterprise.
# 7. Syncs ~/.config/opencode/opencode.json from CONFIG_URL and injects
# OpenCode provider policies: deny every provider, then allow the
# ones derived from the config's own providers, the baseline list
# (BASELINE_ALLOWED_PROVIDERS, for plugin-registered providers like
# openai), and any --allow-provider extras.
# 8. Verifies: versions + relay health + policy state
#
# Flags: --relay-url wss://host/ws (override the default relay endpoint)
# --invite [port] print an openchamber:// invite link (+QR)
# for importing this host into the desktop
# app; starts the instance on <port>
# (default 3000) if none is running
# --allow-provider <id> allow an extra model provider in the
# OpenCode policy (repeatable), e.g.
# --allow-provider constellation
# --no-enterprise do not install the enterprise policy
# file / env pin
set -euo pipefail
RELAY_URL="wss://code.zuccaro.me/ws"
CONFIG_URL="https://git.zuccaro.me/bzuccaro/opencode-public/raw/branch/main/opencode.json"
ORGANIZATION="Zuccaro"
POLICY_FILE="/etc/openchamber/policy.json"
# Providers allowed in addition to those defined in the synced config.
# "openai" comes from the opencode-openai-codex-auth plugin, which never
# appears in the config's providers block.
BASELINE_ALLOWED_PROVIDERS="openai"
INVITE=0
INVITE_PORT=3000
NO_ENTERPRISE=0
ALLOW_PROVIDERS_STR=""
info() { printf '\033[1;34m[info]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--relay-url) RELAY_URL="$2"; shift 2 ;;
--invite)
INVITE=1
if [ $# -ge 2 ] && [ "${2#--}" = "$2" ]; then INVITE_PORT="$2"; shift; fi
shift ;;
--allow-provider)
[ $# -ge 2 ] || die "--allow-provider needs a provider id"
ALLOW_PROVIDERS_STR="$ALLOW_PROVIDERS_STR $2"
shift 2 ;;
--no-enterprise) NO_ENTERPRISE=1; shift ;;
*) echo "unknown arg: $1 (usage: $0 [--relay-url wss://host/ws] [--invite [port]] [--allow-provider <id>] [--no-enterprise])" >&2; exit 1 ;;
esac
done
# ---- helpers ----------------------------------------------------------------
# policy_json — emit the enterprise policy file content on stdout
policy_json() {
printf '{\n "enterpriseMode": true,\n "organization": "%s",\n "relayUrl": "%s"\n}\n' \
"$ORGANIZATION" "$RELAY_URL"
}
# write_policy_file <path> — idempotently install the enterprise policy file
# at <path>, which must already be writable. Reports what it did on stdout.
write_policy_file() {
local f="$1" tmp
tmp="$(mktemp)"
policy_json > "$tmp"
if [ -f "$f" ] && cmp -s "$tmp" "$f"; then
rm -f "$tmp"
ok "$(basename "$f"): already enforced"
return 0
fi
if [ -f "$f" ]; then
warn "$(basename "$f"): differs from enforced policy — overwriting"
fi
mv "$tmp" "$f"
chmod 644 "$f"
ok "$(basename "$f"): installed"
}
# install_enterprise_policy — put the policy at /etc/openchamber/policy.json
# (sudo when available); without root, pin OPENCHAMBER_ENTERPRISE_MODE=1 in
# the rc files instead and say so loudly.
install_enterprise_policy() {
local dir sudo="" tmp current
dir="$(dirname "$POLICY_FILE")"
tmp="$(mktemp)"
policy_json > "$tmp"
# Already correct? Then we're done regardless of root.
if [ -r "$POLICY_FILE" ]; then
current="$(cat "$POLICY_FILE" 2>/dev/null || true)"
if [ "$current" = "$(cat "$tmp")" ]; then
rm -f "$tmp"
ok "$(basename "$POLICY_FILE"): already enforced"
return 0
fi
fi
if [ -d "$dir" ] && [ -w "$dir" ]; then
write_policy_file "$POLICY_FILE"
rm -f "$tmp"
return 0
fi
if sudo -n true 2>/dev/null; then
sudo="sudo -n"
elif [ -t 0 ]; then
sudo="sudo"
fi
if [ -n "$sudo" ]; then
if [ -f "$POLICY_FILE" ]; then
warn "$(basename "$POLICY_FILE"): differs from enforced policy — overwriting"
fi
if $sudo mkdir -p "$dir" 2>/dev/null && $sudo tee "$POLICY_FILE" >/dev/null < "$tmp" && $sudo chmod 644 "$POLICY_FILE" 2>/dev/null; then
rm -f "$tmp"
ok "enterprise policy installed at $POLICY_FILE"
return 0
fi
fi
rm -f "$tmp"
if [ -f "$POLICY_FILE" ]; then
warn "no root: $POLICY_FILE exists but differs from enforced policy and could not be repaired"
return 1
fi
warn "no root: cannot write $POLICY_FILE — falling back to env-var mode"
warn "env-var mode is a weaker lock (the user can edit their own rc files)"
pin_enterprise_env
}
# pin_enterprise_env — pin OPENCHAMBER_ENTERPRISE_MODE=1 like the relay var
pin_enterprise_env() {
local line='export OPENCHAMBER_ENTERPRISE_MODE=1'
if grep -q "OPENCHAMBER_ENTERPRISE_MODE" ~/.bashrc 2>/dev/null; then
ok ".bashrc: enterprise mode already pinned"
else
pin_top ~/.bashrc "$line"
fi
pin_append ~/.zshenv "$line"
pin_append ~/.zshrc "$line"
pin_append ~/.profile "$line"
}
# merge_policies <in> <out> [extra provider ids...] — inject the OpenCode
# provider policy into a config: deny every provider, then allow those
# derived from the config's own providers block, the baseline list, and any
# extras (deduped, in that order). Uses python3, falls back to bun.
merge_policies() {
local in="$1" out="$2" tmp status=0
local extras="${*:3}"
tmp="$(mktemp)"
if command -v python3 >/dev/null 2>&1; then
python3 - "$in" "$tmp" "$BASELINE_ALLOWED_PROVIDERS" "$extras" <<'PY' || status=1
import json, sys
src, dst, baseline, extras = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
cfg = json.load(open(src))
derived = list((cfg.get("providers") or {}).keys())
rules = [{"action": "provider.use", "resource": "*", "effect": "deny"}]
seen = []
for p in derived + [b.strip() for b in baseline.split(",") if b.strip()] + extras.split():
if p and p not in seen:
seen.append(p)
rules.append({"action": "provider.use", "resource": p, "effect": "allow"})
cfg.setdefault("experimental", {})["policies"] = rules
with open(dst, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
PY
elif command -v bun >/dev/null 2>&1; then
IN="$in" OUT="$tmp" BASELINE="$BASELINE_ALLOWED_PROVIDERS" EXTRAS="$extras" bun -e '
const fs = require("fs");
const cfg = JSON.parse(fs.readFileSync(process.env.IN, "utf8"));
const derived = Object.keys(cfg.providers || {});
const rules = [{ action: "provider.use", resource: "*", effect: "deny" }];
const seen = [];
for (const p of [...derived,
...process.env.BASELINE.split(",").map(s => s.trim()).filter(Boolean),
...process.env.EXTRAS.split(/\s+/).filter(Boolean)]) {
if (p && !seen.includes(p)) { seen.push(p); rules.push({ action: "provider.use", resource: p, effect: "allow" }); }
}
cfg.experimental = cfg.experimental || {};
cfg.experimental.policies = rules;
fs.writeFileSync(process.env.OUT, JSON.stringify(cfg, null, 2) + "\n");
' || status=1
else
die "need python3 or bun to inject provider policies"
fi
if [ "$status" -ne 0 ]; then
rm -f "$tmp"
die "could not inject provider policies into $in (bad JSON or interpreter failure) — existing config left untouched"
fi
mv "$tmp" "$out"
}
# allowed_providers_from <config.json> — print the allow-listed provider ids
# from a config's experimental.policies as a comma-separated list, or "?" if
# the file can't be read/parsed.
allowed_providers_from() {
python3 -c '
import json,sys
try:
cfg=json.load(open(sys.argv[1]))
print(",".join(r["resource"] for r in cfg.get("experimental",{}).get("policies",[]) if r.get("effect")=="allow"))
except Exception:
print("?")
' "$1" 2>/dev/null
}
# ---- main -------------------------------------------------------------------
main() {
info "host: $(uname -srm)"
[ "$(uname -s)" = "Linux" ] || die "this script targets Linux hosts"
# ---- preflight -------------------------------------------------------------
for c in curl; do command -v "$c" >/dev/null || die "missing: $c (install it and re-run)"; done
command -v unzip >/dev/null || warn "unzip not found — bun's installer needs it; install unzip first if step 1 fails"
# ---- 1. bun ----------------------------------------------------------------
if command -v bun >/dev/null 2>&1; then
ok "bun already installed: $(bun --version)"
else
info "installing bun..."
curl -fsSL https://bun.sh/install | bash
fi
export PATH="$HOME/.bun/bin:$PATH"
command -v bun >/dev/null 2>&1 || die "bun not on PATH after install"
# ---- 2. node shim ----------------------------------------------------------
if [ -x "$HOME/.bun/bin/node" ]; then
ok "node shim present"
else
ln -sf "$HOME/.bun/bin/bun" "$HOME/.bun/bin/node"
ok "created node -> bun shim"
fi
# ---- 3. rc file pinning ----------------------------------------------------
# .bashrc: prepend to the TOP so non-interactive ssh execs (which source
# .bashrc but return early on Debian-style interactive guards) still get it.
# zsh/profile files: only touched if they already exist.
pin_top() { # pin_top <file> <line>
local f="$1" line="$2"
touch "$f"
if grep -qF "$line" "$f"; then ok "$(basename "$f"): already pinned"; return 0; fi
sed -i "1i $line" "$f"
ok "$(basename "$f"): pinned at top"
}
pin_append() { # pin_append <file> <line>
local f="$1" line="$2"
[ -f "$f" ] || { info "$(basename "$f") does not exist, skipping"; return 0; }
if grep -qF "$line" "$f"; then ok "$(basename "$f"): already pinned"; return 0; fi
printf '%s\n' "$line" >> "$f"
ok "$(basename "$f"): appended"
}
PATH_LINE='export PATH="$HOME/.bun/bin:$PATH"'
RELAY_LINE="export OPENCHAMBER_RELAY_URL=\"$RELAY_URL\""
if grep -q "OPENCHAMBER_RELAY_URL" ~/.bashrc 2>/dev/null; then
info "relay var already pinned in .bashrc (respecting existing value)"
else
pin_top ~/.bashrc "$PATH_LINE"
pin_top ~/.bashrc "$RELAY_LINE"
fi
pin_append ~/.zshenv "$PATH_LINE"
pin_append ~/.zshenv "$RELAY_LINE"
pin_append ~/.zshrc "$PATH_LINE"
pin_append ~/.zshrc "$RELAY_LINE"
pin_append ~/.profile "$PATH_LINE"
pin_append ~/.profile "$RELAY_LINE"
# ---- 4. opencode -----------------------------------------------------------
if command -v opencode >/dev/null 2>&1 || [ -x "$HOME/.opencode/bin/opencode" ]; then
ok "opencode already installed"
else
info "installing opencode..."
curl -fsSL https://opencode.ai/install | bash
fi
[ -x "$HOME/.opencode/bin/opencode" ] && export PATH="$HOME/.opencode/bin:$PATH"
# ---- 5. openchamber --------------------------------------------------------
# Always (re)install the latest published version so re-runs upgrade in place.
info "installing/updating @openchamber/web to latest..."
bun install -g @openchamber/web@latest
ok "openchamber version: $(openchamber --version 2>/dev/null || echo '?')"
# ---- 6. enterprise policy --------------------------------------------------
if [ "$NO_ENTERPRISE" = "1" ]; then
info "skipping enterprise policy (--no-enterprise)"
else
install_enterprise_policy
fi
# ---- 7. opencode config sync + provider policies ---------------------------
CFG_DIR="$HOME/.config/opencode"
CFG_FILE="$CFG_DIR/opencode.json"
TMP_CFG="$(mktemp)"
curl -fsSL --max-time 60 "$CONFIG_URL" -o "$TMP_CFG" || die "could not download config from $CONFIG_URL"
info "injecting provider policies (deny all; allow derived + baseline:${ALLOW_PROVIDERS_STR:+ extras:$ALLOW_PROVIDERS_STR})"
merge_policies "$TMP_CFG" "$TMP_CFG"
if [ -f "$CFG_FILE" ] && cmp -s "$TMP_CFG" "$CFG_FILE"; then
ok "opencode config already up to date"
rm -f "$TMP_CFG"
else
mkdir -p "$CFG_DIR"
if [ -f "$CFG_FILE" ]; then
BAK="$CFG_FILE.bak.$(date +%Y%m%d-%H%M%S)"
cp "$CFG_FILE" "$BAK"
info "existing config backed up to $(basename "$BAK")"
fi
mv "$TMP_CFG" "$CFG_FILE"
ok "opencode config installed from CONFIG_URL (with provider policies)"
fi
if command -v python3 >/dev/null 2>&1; then
python3 -m json.tool "$CFG_FILE" >/dev/null && ok "config is valid JSON"
elif command -v bun >/dev/null 2>&1; then
bun -e "JSON.parse(require('fs').readFileSync('$CFG_FILE','utf8'))" && ok "config is valid JSON"
fi
# ---- 8. verify --------------------------------------------------------------
echo
info "==== verification ===="
echo "bun: $(bun --version 2>/dev/null || echo MISSING)"
echo "node shim: $(env node -e 'console.log("exec-ok")' 2>/dev/null || echo BROKEN)"
echo "opencode: $(opencode --version 2>/dev/null || echo MISSING)"
echo "openchamber:$(openchamber --version 2>/dev/null || echo MISSING)"
if [ -f "$POLICY_FILE" ]; then
if command -v python3 >/dev/null 2>&1; then
if python3 -m json.tool "$POLICY_FILE" >/dev/null 2>&1; then
ok "enterprise policy: $POLICY_FILE (valid JSON, relay pinned: $(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("relayUrl","?"))' "$POLICY_FILE" 2>/dev/null || echo '?'))"
else
warn "enterprise policy: $POLICY_FILE is not valid JSON — OpenChamber stays in enterprise mode but pins nothing"
fi
else
ok "enterprise policy: $POLICY_FILE present"
fi
elif grep -q "OPENCHAMBER_ENTERPRISE_MODE" ~/.bashrc 2>/dev/null; then
warn "enterprise mode via env var only (weaker lock) — no policy file at $POLICY_FILE"
else
warn "enterprise mode not configured (skipped with --no-enterprise?)"
fi
if command -v python3 >/dev/null 2>&1; then
echo "allowed providers: $(allowed_providers_from "$CFG_FILE")"
else
warn "cannot read back provider allow-list (no python3)"
fi
HEALTH_URL="$(printf '%s' "$RELAY_URL" | sed 's|^wss://|https://|; s|/ws$|/health|')"
if curl -fsS --max-time 15 "$HEALTH_URL" >/dev/null 2>&1; then
ok "relay reachable: $HEALTH_URL"
else
warn "relay health check failed: $HEALTH_URL (may not be deployed yet)"
fi
# ---- 9. invite link (optional) ----------------------------------------------
if [ "$INVITE" = "1" ]; then
info "generating invite link (relay transport embedded)..."
if openchamber connect-url --relay -p "$INVITE_PORT" --qr; then
ok "paste the openchamber:// link into OpenChamber: Settings > Remote Instances > add instance"
else
warn "invite link not generated - an instance may already be running on port $INVITE_PORT (openchamber stop -p $INVITE_PORT, then re-run with --invite)"
fi
fi
echo
ok "prerequisites complete"
}
# Sourceable for testing: only run when executed directly.
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi