From 35c89fa90ce45ed739c3c3d5b42b4d15dcbc8f07 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 10 Jun 2026 16:03:24 +0000 Subject: [PATCH] workstation: managed Claude config self-deploys from the repo [ci skip] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viktor's claudeMd edits must keep reaching every user now that emo is out of the shared tree. Two reconciler additions: - sync_managed_config: installs scripts/workstation/managed-settings.json to /etc/claude-code whenever the repo copy changes — editing the org claudeMd is now edit + commit, no manual install step - refresh_codex_mirror: regenerates each user's ~/.codex/AGENTS.md (static mirror of the claudeMd; header-guarded so user-customized files are never clobbered) Verified live: corrupted emo's mirror -> reconcile restored it; wizard's stale mirror refreshed; in-sync managed config no-ops. Co-Authored-By: Claude Fable 5 --- docs/architecture/multi-tenancy.md | 2 +- scripts/t3-provision-users.sh | 39 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md index 067cdcaf..e95fcb8f 100644 --- a/docs/architecture/multi-tenancy.md +++ b/docs/architecture/multi-tenancy.md @@ -541,7 +541,7 @@ Separate from the in-cluster namespace-owner model above, the **devvm** (`10.0.1 **RBAC tiers:** `admin` (Viktor — cluster-admin, unlocked tree, secrets) · `power-user` (cluster-wide read-only, NO Secrets, via a dedicated `oidc-power-user-readonly` ClusterRole) · `namespace-owner` (admin in own namespace only). Each session acts as the user's **own** OIDC identity (kubelogin), never the admin's. -**Config inheritance (live):** wizard authors the base (his chezmoi-versioned `~/.claude`). Two native layers carry it to every user — the enforced org `claudeMd` in `/etc/claude-code/managed-settings.json` (top precedence, all sessions) and per-user `~/.claude/{skills,rules,…}` **symlinks** to the base (seeded via `/etc/skel`; edits propagate live). Secrets stay per-user at mode 600, never symlinked. +**Config inheritance (live):** wizard authors the base (his chezmoi-versioned `~/.claude`). Two native layers carry it to every user — the enforced org `claudeMd` in `/etc/claude-code/managed-settings.json` (top precedence, all sessions) and per-user `~/.claude/{skills,rules,…}` **symlinks** to the base (seeded via `/etc/skel`; edits propagate live). Secrets stay per-user at mode 600, never symlinked. **The managed config self-deploys from the repo** (2026-06-10): the hourly reconcile's `sync_managed_config` installs `scripts/workstation/managed-settings.json` to `/etc/claude-code/` whenever the repo copy changes — so editing the claudeMd = edit + commit, no manual install — and `refresh_codex_mirror` regenerates each user's `~/.codex/AGENTS.md` (a static mirror of the claudeMd; only files carrying the mirror header are touched, user-customized ones are left alone). Repo-level guidance (`.claude/CLAUDE.md`, `AGENTS.md`, `CONTEXT.md` in the infra repo) reaches non-admins through their auto-freshened clones — commit + push and every user has it within the hour. **Infra access:** non-admins get their own **writable, git-crypt-LOCKED** clone of the (public) infra repo at `~/code` — code/docs plaintext, secret files (`*.tfvars`, `secrets/**`) stay ciphertext. The provisioner clones anonymously from the public GitHub mirror; **contribute access is wired per-user on top** (see below). The apply boundary still holds (`scripts/tg apply` needs an admin Vault token + cluster RBAC), but **pushing `master` is NOT inert** — the Forgejo→Woodpecker webhook fires `.woodpecker/default.yml` (`event: push, branch: master`, `require_approval: forks` only), which terragrunt-applies changed stacks. `master` is **branch-protected on Forgejo** (force-push disabled for everyone — history is append-only; push + merge whitelists = `viktor` + explicitly granted users, deploy keys allowed). **Allow-then-audit (Viktor, 2026-06-10):** `ebarzin` (emo) is on the whitelist and pushes straight to `master` — no PR gate. The tracking burden moves to: (a) **commit messages that record what + why** (the agent instructions in AGENTS.md and the managed claudeMd require the body to paraphrase the user's request), (b) the **`notify-nonadmin-push` Slack audit step** in `.woodpecker/default.yml` — every master push by a non-admin author is posted to Slack (admin pushes are not), and (c) non-admins **never use `[ci skip]`** so every change fires the pipeline (and thus the audit feed). Users NOT on the whitelist fall back to `/` branches + PRs. **Clones stay fresh automatically** (2026-06-10): the hourly `t3-provision-users` reconcile runs `refresh_locked_clone` (fetch all remotes + fast-forward `master`, ONLY when on master with a clean tree and an upstream — dirty trees and local commits are left alone with a WARN), and `start-claude.sh` does the same freshen at session launch (15s-capped so an offline remote never stalls the session). diff --git a/scripts/t3-provision-users.sh b/scripts/t3-provision-users.sh index 52a27015..b252cc90 100644 --- a/scripts/t3-provision-users.sh +++ b/scripts/t3-provision-users.sh @@ -64,6 +64,41 @@ refresh_locked_clone() { || log "WARN: $user master not fast-forwardable (local commits?) — left as-is" } +# Machine-wide Claude managed config: the repo file (in the admin tree, like the +# roster) is the authoring surface; deploying it here means a plain infra commit +# propagates claudeMd/model edits to /etc — and thus every user's NEXT session — +# within one reconcile cycle. No manual install step. +sync_managed_config() { + local src="$WORKSTATION_DIR/managed-settings.json" dst=/etc/claude-code/managed-settings.json + [[ -r "$src" ]] || return 0 + python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$src" 2>/dev/null \ + || { log "WARN: $src is invalid JSON — managed-config sync skipped"; return 0; } + cmp -s "$src" "$dst" 2>/dev/null && return 0 + if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] managed-settings.json -> $dst"; return 0; fi + install -D -m 0644 "$src" "$dst" + log "deployed managed-settings.json -> /etc/claude-code (repo copy changed)" +} + +# ~/.codex/AGENTS.md is a STATIC mirror of the managed claudeMd (codex has no +# machine-wide managed layer). Regenerate stale mirrors so codex sessions inherit +# claudeMd edits the same way Claude sessions do. Never clobbers a user-customized +# file: only touches files carrying the mirror header (or creates absent ones). +refresh_codex_mirror() { + local user="$1" home dst tmp + home="$(getent passwd "$user" | cut -d: -f6)" + dst="$home/.codex/AGENTS.md" + [[ -n "$home" && -d "$home/.codex" ]] || return 0 + if [[ -f "$dst" ]] && ! head -1 "$dst" | grep -q '^# Codex global instructions (devvm)'; then return 0; fi + tmp="$(mktemp)" + { printf '# Codex global instructions (devvm)\n\n_Mirrors the machine-wide Claude managed policy._\n\n---\n\n' + python3 -c 'import json; print(json.load(open("/etc/claude-code/managed-settings.json"))["claudeMd"])' + } > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } + if cmp -s "$tmp" "$dst" 2>/dev/null; then rm -f "$tmp"; return 0; fi + if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] codex AGENTS.md mirror -> $user"; rm -f "$tmp"; return 0; fi + install -o "$user" -g "$user" -m 0644 "$tmp" "$dst"; rm -f "$tmp" + log "refreshed codex AGENTS.md mirror -> $user" +} + # Per-user OIDC kubeconfig (kubelogin/PKCE — the `kubernetes` Authentik client is # public, no secret). Identical for all users: identity comes from each user's own # interactive OIDC login, which the apiserver maps (email claim) to their RBAC. @@ -179,6 +214,9 @@ desired_file="$(mktemp)" python3 "$ENGINE" derive --roster "$ROSTER" --ports-json "$ports_file" > "$desired_file" jq -e . "$desired_file" >/dev/null || { echo "[t3-provision] derive produced invalid JSON" >&2; exit 1; } +# 3b) machine-wide Claude managed config (repo -> /etc; per-user codex mirrors in the loop below) +sync_managed_config + # 4) per-account: create-if-absent + ADDITIVE tier groups (never strip) + locked clone while IFS=$'\t' read -r os_user tier shell groups_csv; do if ! id "$os_user" >/dev/null 2>&1; then @@ -202,6 +240,7 @@ while IFS=$'\t' read -r os_user tier shell groups_csv; do install_user_kubeconfig "$os_user" install_user_claude_token "$os_user" fi + refresh_codex_mirror "$os_user" # all tiers — mirror of the managed claudeMd done < <(jq -r '.accounts[] | [.os_user, .tier, .shell, (.groups|join(","))] | @tsv' "$desired_file") # 5) per-user .env (sticky port) + enable t3-serve@