- 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
396 lines
15 KiB
Bash
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
|