#!/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. Pass # --enterprise to install your own policy (e.g. # openchamber-policy.json from this repo); otherwise built-in # defaults are used. --relay-url overrides the file's relayUrl. # 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 # (default 3000) if none is running # --allow-provider allow an extra model provider in the # OpenCode policy (repeatable), e.g. # --allow-provider constellation # --enterprise install this file as the OpenChamber # enterprise policy (e.g. the # openchamber-policy.json in this repo); # without it, built-in defaults apply # --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="" POLICY_SOURCE="" 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; } # parse_args "$@" — set INVITE/INVITE_PORT/NO_ENTERPRISE/ALLOW_PROVIDERS_STR/ # POLICY_SOURCE/RELAY_URL from the command line. parse_args() { while [ $# -gt 0 ]; do case "$1" in --relay-url) [ $# -ge 2 ] || die "--relay-url needs a value"; 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 ;; --enterprise) [ $# -ge 2 ] || die "--enterprise needs a policy file path" [ -r "$2" ] || die "enterprise policy file not found: $2" POLICY_SOURCE="$2" shift 2 ;; --no-enterprise) NO_ENTERPRISE=1; shift ;; *) echo "unknown arg: $1 (usage: $0 [--relay-url wss://host/ws] [--invite [port]] [--allow-provider ] [--enterprise ] [--no-enterprise])" >&2; exit 1 ;; esac done } # ---- helpers ---------------------------------------------------------------- # policy_json — emit the enterprise policy file content on stdout. # With POLICY_SOURCE set: the file is the source of truth, except --relay-url # overrides its relayUrl key. Without it: built-in defaults. policy_json() { if [ -z "$POLICY_SOURCE" ]; then printf '{\n "enterpriseMode": true,\n "organization": "%s",\n "relayUrl": "%s"\n}\n' \ "$ORGANIZATION" "$RELAY_URL" return 0 fi local tmp tmp="$(mktemp)" if command -v python3 >/dev/null 2>&1; then python3 - "$POLICY_SOURCE" "$tmp" "$RELAY_URL" <<'PY' || { rm -f "$tmp"; die "invalid JSON in enterprise policy file: $POLICY_SOURCE"; } import json, sys src, dst, relay = sys.argv[1], sys.argv[2], sys.argv[3] p = json.load(open(src)) if relay and p.get("relayUrl") != relay: p["relayUrl"] = relay with open(dst, "w") as f: json.dump(p, f, indent=2) f.write("\n") PY elif command -v bun >/dev/null 2>&1; then IN="$POLICY_SOURCE" OUT="$tmp" RELAY="$RELAY_URL" bun -e ' const fs = require("fs"); const p = JSON.parse(fs.readFileSync(process.env.IN, "utf8")); if (process.env.RELAY && p.relayUrl !== process.env.RELAY) p.relayUrl = process.env.RELAY; fs.writeFileSync(process.env.OUT, JSON.stringify(p, null, 2) + "\n"); ' || { rm -f "$tmp"; die "invalid JSON in enterprise policy file: $POLICY_SOURCE"; } else die "need python3 or bun to read the enterprise policy file" fi cat "$tmp" rm -f "$tmp" } # write_policy_file — idempotently install the enterprise policy file # at , 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 [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 — 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() { parse_args "$@" 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 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 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