#!/usr/bin/env bash# Per-user tier kit (euler-user): the shared layer, and provisioning one collaborator at a time.# ==============================================================================================## Deploys / removes the OS layer for the **per-user** web tier, and provisions# or tears down one collaborator at a time. This replaces the per-*profile*# shared-uid model: instead of three fixed rungs sharing a uid, every# collaborator gets their **own** uid, home, and repo clone, so their# own keys can rest in their own uid-private home without leaking to anyone else.# Design of record: docs/web-server-guide.md § The per-user tier.## Two planes:## deploy / remove / status — the SHARED layer, once per host:# - the euler-user parent group (egress + traversal);# - /etc/euler/user.env (scoped runtime config, no secrets);# - the socket-activated euler-user@.service + euler-user@.socket template,# deferred until euler_webshell.user lands in the /opt/euler venv (step 4).## provision / deprovision <slug> — ONE collaborator:# provision create the uid <slug> + home /home/<slug> (0700), clone ~/euler DIRECTLY from# the public GitHub repo (anonymous read — no credentials) on branch# user/<slug> with the crypt filter DISABLED (so solutions/private/**# stays ciphertext at rest — GitHub holds only the filter's ciphertext,# and the filter is wired later, in the web shell, once the user is# key-authorized, §6), lay down ~/.euler (0700, secrets dir), and enable# the instance socket.# deprovision stop + disable the instance, then (prompted) userdel + remove the# home. Dropping the account's master-key access is a SEPARATE admin# crypto act (`key-rekey`), reported here — it needs the master key,# which this root-plane script deliberately never touches.## Why the clone lands ciphertext for free: the crypt filter is registered in the# clone's LOCAL git config (never cloned) plus a TRACKED .gitattributes rule. A fresh# clone therefore inherits the attribute but not the filter definition, so encrypted# blobs pass through untouched — plaintext only appears once the user runs the filter# install with their own key (solver.crypto.gitfilter). No secret is provisioned here.## Because the units live in root's systemd and run as locked-down users, lifecycle# requires sudo.## Actions: deploy | remove | upgrade | redeploy | provision | deprovision | record-owner |# status | help## Author: Vikas Munshi <vikas.munshi@gmail.com># Copyright (c) 2026. All rights reserved.# Licensed under the MIT License.set-euopipefailSCRIPT_DIR="$(cd"$(dirname"${BASH_SOURCE[0]}")"&&pwd)"PROJECT_ROOT="$(cd"${SCRIPT_DIR}/../.."&&pwd)"SYS_DIR="/etc/euler"USER_ENV="${SYS_DIR}/user.env"# scoped runtime config (root:euler-web 0640)EGRESS_ENV="${SYS_DIR}/egress.env"# HTTPS_PROXY (egress.sh)WEB_GROUP="euler-web"# The group of the per-user instance sockets: Caddy, auth and msg (frontend.sh creates it# too), never a per-user uid — so one collaborator's code cannot connect to another's# instance. The per-user uids keep euler-web, which reaches only auth.sock and msg.sock.EDGE_GROUP="euler-edge"# Parent group: every per-user uid joins it. It is also the ROSTER — the per-user uids are# named for the slug alone (ue0f4a1), so there is no name prefix left to enumerate them by,# and this root-owned membership is what identifies them here and in firewall.sh.USER_GROUP="euler-user"USER_HOME_BASE="/home"# per-user homes: /home/<slug># Who owns each slug: slug → sha256(normalised e-mail), root:root 0600. The slug is the uid and# the routing key, so provisioning must never hand an existing account to a different address# whose digest happens to share it — require_owner checks this record first.OWNERS_FILE="/etc/euler/owners.json"SERVICE_TEMPLATE="euler-user@.service"SOCKET_TEMPLATE="euler-user@.socket"SERVICE_DEST="/etc/systemd/system/${SERVICE_TEMPLATE}"SOCKET_DEST="/etc/systemd/system/${SOCKET_TEMPLATE}"# The system venv — OPT_DIR / VENV_DIR / VENV_PY / PYTHON / deploy_venv.# shellcheck source=scripts/setup/venv.sh."${SCRIPT_DIR}/venv.sh"usage(){cat<<USAGEUsage: $0 <action> [args] deploy Create the euler-user group, /etc/euler/user.env, and — when euler_webshell.user is in the /opt/euler venv — the euler-user@.service/.socket template. Idempotent. remove Remove the template + user.env (prompted). Provisioned users must be deprovisioned first. upgrade Deploy, then stop running instances so they restart on the new code. redeploy Refresh /etc/euler/user.env, re-lay every provisioned user's git hooks (and the .gitignore / .shellcheckrc they read) from this repo, and stop the running instances so their sockets re-activate them against a freshly rebuilt venv (drops live shells). logout-all [reason] [--now] Warn every running instance and sign everyone out, waiting for running work up to a limit (euler_webshell.logout_all). Never fails a deploy: it warns and carries on. provision <slug> <email> <profile> Provision one collaborator: the uid <slug> + /home/<slug> (0700), a filter-disabled clone of ~/euler from the public GitHub repo on branch user/<slug> (cloned AS the user, through Squid), git hooks rendered from THIS repo's templates plus the .gitignore / .shellcheckrc they read, Claude Code (~/.local/bin, theirs), ~/.euler, and the instance socket. Idempotent — re-run it to repair an instance and re-lay its hooks; the clone is left as it stands (this plane cannot sync it). deprovision <slug> Tear one collaborator down: stop/disable the instance, then (prompted) userdel + remove the home. Reports the required 'key-rekey' to drop master-key access. record-owner <slug> <email> Record an existing account's owner (owners.json), so provision will accept it for that address. status [<slug>] Show the shared layer, or one user's instance health, including whether their web shell is live and connected. Requires: the /opt/euler venv (auth.sh). The Caddy X-User-Slug routing that dials these sockets is wired by frontend.sh in step 4 (the per-user service).USAGE}# ── guards ──────────────────────────────────────────────────────────────────────────check_can_sudo(){if!command-vsudo&>/dev/null;thenecho"Error: sudo is not installed or not found in PATH">&2return1fiif!sudo-v2>/dev/null;thenecho"Error: current user does not have sudo privileges">&2return1fi}require_systemd(){if[!-d/run/systemd/system]||!command-vsystemctl&>/dev/null;thenecho"Error: systemd is required for the per-user service; it is not active here.">&2return1fi}require_python(){if!command-v"${PYTHON}"&>/dev/null;thenecho"Error: ${PYTHON} not found — run 'make install-system' (deadsnakes) first.">&2return1fi}# A slug is exactly what solver.auth.identity.system_slug emits: 'u' + 16 hex digits of the# e-mail's SHA-256. Matching that shape exactly matters: the slug IS the uid, a bare name in# the shared account namespace, so a loose pattern would let a typo'd call provision (or,# worse, adopt) an ordinary login name. It also becomes a home path, a socket name, and a# git branch.valid_slug(){[["$1"=~^u[0-9a-f]{16}$]]}require_slug(){if!valid_slug"${1:-}";thenecho"Error: '${1:-}' is not a valid system slug (expected ^u[0-9a-f]{16}\$).">&2echo" The slug comes from solver.auth.identity.system_slug(email).">&2return1fi}# ── shared layer ──────────────────────────────────────────────────────────────────────ensure_shared_groups(){getentgroup"${WEB_GROUP}">/dev/null||sudogroupadd--system"${WEB_GROUP}"getentgroup"${EDGE_GROUP}">/dev/null||sudogroupadd--system"${EDGE_GROUP}"getentgroup"${USER_GROUP}">/dev/null||sudogroupadd--system"${USER_GROUP}"}# Deploy the shared /opt/euler venv (venv.sh); the per-user service module (step 4)# imports from it. euler_webshell.user is expected to be absent until step 4, and the# unit install is gated on it.deploy_user_venv(){deploy_venv"${PROJECT_ROOT}""${WEB_GROUP}"ifsudo"${VENV_PY}"-P-c'import euler_webshell.user'2>/dev/null;thenecho"Deployed: ✓ euler_webshell.user importable from ${VENV_DIR}"elseecho"Deployed: … euler_webshell.user not in the venv yet (step 4) — unit deferred"fi}# The public GitHub URL every per-user clone is taken from — read straight off the# operator repo's origin remote, so there is one source of truth and no hard-coding.origin_url(){git-C"${PROJECT_ROOT}"remoteget-urlorigin2>/dev/null;}# Scoped runtime config for the per-user instances. No ANTHROPIC_API_KEY and no master# key: each user brings their own, in their own vault. The per-instance# EULER_REPO_ROOT / EULER_USER_SLUG / socket come from the template unit (%i).deploy_user_env(){sudomkdir-p"${SYS_DIR}"sudotee"${USER_ENV}">/dev/null<<EOF# GENERATED by scripts/setup/user.sh — scoped runtime config for euler-user.# The per-instance EULER_REPO_ROOT, EULER_USER_SLUG and socket are set by the unit (%i).# No credentials here, deliberately: each user's secrets live in their own vault,# unwrapped per-session from their own home — nothing operator-owned reaches this tier.EULER_AUTH_SOCKET=/run/euler/auth.sockEULER_WEB_GROUP=${WEB_GROUP}EOFsudochownroot:"${WEB_GROUP}""${USER_ENV}"sudochmod0640"${USER_ENV}"}venv_has_user_service(){[-x"${VENV_PY}"]&&sudo"${VENV_PY}"-P-c'import euler_webshell.user'2>/dev/null}# The socket-activated template: one euler-user@<slug> instance per collaborator, each# born as its own uid (no setuid, no root), on its own /run/euler/user-<slug>.sock that# Caddy dials by the forward_auth X-User-Slug. Serves BOTH the content# routes and /ws — one service, not the retired content@/ws@ split.install_units(){echo"Installing ${SOCKET_TEMPLATE} + ${SERVICE_TEMPLATE} (socket-activated, per-user)..."sudotee"${SOCKET_DEST}">/dev/null<<EOF[Unit]Description=euler per-user instance socket (%i) — the collaborator's content + /wsDocumentation=https://github.com/vikasmunshi/euler/blob/master/docs/web-server-guide.md[Socket]ListenStream=/run/euler/user-%i.sockSocketMode=0660SocketUser=%iSocketGroup=${EDGE_GROUP}# Caddy, euler-auth and euler-msg (in ${EDGE_GROUP}) connect; no per-user uid can. The# instance is spawned on demand.[Install]WantedBy=sockets.targetEOFsudotee"${SERVICE_DEST}">/dev/null<<EOF[Unit]Description=euler per-user instance (%i) — one collaborator's content + web shellDocumentation=https://github.com/vikasmunshi/euler/blob/master/docs/web-server-guide.mdAfter=network.target euler-auth.serviceWants=euler-auth.serviceRequires=euler-user@%i.socket[Service]# Type=simple, like every sibling kit: the app never sd_notify()s, so Type=notify# would leave systemd waiting for a READY=1 that never comes — it then declares the# start "timed out" at TimeoutStartSec (90s), SIGTERMs the WHOLE cgroup (killing the# user's live PTY shell), and Restart= respawns it: an endless ~95s reset loop. The# .socket unit is the real readiness gate anyway — it queues connections until the# listener is up.Type=simple# Born as the collaborator's own uid — no setuid, no root. The uid IS the slug (%i); it# owns its home, its clone, and its vault, and the kernel (SO_PEERCRED) is the# authoritative identity.User=%iEnvironmentFile=${USER_ENV}# HTTPS_PROXY: the problem scraper egresses only through Squid.EnvironmentFile=-${EGRESS_ENV}Environment=EULER_USER_SLUG=%iEnvironment=EULER_REPO_ROOT=${USER_HOME_BASE}/%i/euler# The web shell's PTY inherits this PATH, and the uid's nologin shell never reads a# ~/.profile — so the per-user installs (claude, in ~/.local/bin) are put on it here.Environment=PATH=${USER_HOME_BASE}/%i/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binExecStart=${VENV_PY} -m euler_webshell.userRestart=on-failureRestartSec=5s# Egress layer 1: loopback only — the /run/euler sockets and the Squid proxy. The kernel# firewall (firewall.sh, scoped to the euler-user group's uids) is layer 2.IPAddressDeny=anyIPAddressAllow=localhost# Hardening. This tier runs the user's own code by design, contained to# THIS user's uid — their home, their keys, their clone, no one else's. The sandbox# must not break the code being run (native extensions need W^X), so containment is the# uid + egress lock, not a syscall jail.NoNewPrivileges=trueProtectSystem=strictProtectHome=false# Its own home only. The instance connects to sockets in /run/euler but never writes there.ReadWritePaths=${USER_HOME_BASE}/%iPrivateTmp=truePrivateDevices=trueRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6CapabilityBoundingSet=LockPersonality=trueRestrictRealtime=trueSystemCallArchitectures=native# The kernel, the clock, the hostname and cgroups are not a collaborator's to touch, and# their shell sees only its own processes in /proc. Deliberately NOT here, because this# unit runs the collaborator's own tools (Python, gcc, git, gh, Claude Code on Node): a# syscall filter or a namespace ban (Claude Code's sandbox can use user namespaces),# MemoryDenyWriteExecute (Node's JIT) and ProcSubset=pid (tools read /proc/cpuinfo).ProtectKernelTunables=trueProtectKernelModules=trueProtectKernelLogs=trueProtectControlGroups=trueProtectClock=trueProtectHostname=trueProtectProc=invisibleRestrictSUIDSGID=trueRemoveIPC=trueUMask=0007# One PTY child per attached tab; the cgroup kill collects them all on stop.KillMode=control-groupTimeoutStopSec=20s[Install]WantedBy=multi-user.targetEOFsudosystemctldaemon-reload}# ── per-user provision / deprovision ──────────────────────────────────────────────────# The uid IS the slug (ue0f4a1) — no prefix, so the home is /home/<slug> and the unit# instance name, the socket, the uid and the home all read the same.user_of(){echo"$1";}home_of(){echo"${USER_HOME_BASE}/$1";}sock_of(){echo"/run/euler/user-$1.sock";}# The per-user roster, by group membership. With the euler-user- name prefix gone there is# nothing to grep /etc/passwd for; the parent group is the enumerable set (and the same one# firewall.sh reads). Lists the group's members,# one per line, most-recent-last; empty when none are provisioned.euler_user_names(){getentgroup"${USER_GROUP}"2>/dev/null|awk-F:'{print $4}'|tr',''\n'|sed'/^$/d'}# True if <name> is one of ours. Provisioning must never ADOPT an account it did not# create: the slug is now a bare name in the shared login namespace, so a pre-existing# 'ue0f4a1' would otherwise be handed a service unit, a home rewrite, and a clone.is_euler_user(){euler_user_names|grep-qx"$1"}ensure_user_identity(){localslug="$1"userhomeuser="$(user_of"${slug}")"home="$(home_of"${slug}")"ifgetentpasswd"${user}">/dev/null&&!is_euler_user"${user}";thenecho"Error: an account named '${user}' already exists and is not in ${USER_GROUP}.">&2echo" Refusing to adopt it — a per-user instance must own its uid outright.">&2return1fiif!getentpasswd"${user}">/dev/null;thenecho"Creating system user ${user} (home ${home}, group ${USER_GROUP})..."sudouseradd--system--create-home--home-dir"${home}"\--shell/usr/sbin/nologin-U-G"${WEB_GROUP},${USER_GROUP}""${user}"elsesudousermod-G"${WEB_GROUP},${USER_GROUP}""${user}"fisudochmod0700"${home}"# ~/.euler — the uid-private secrets dir (matches solver.config root.parent/.{name}# for root=~/euler). Empty now; the user's key + vault land here later, self-service.sudoinstall-d-m0700-o"${user}"-g"${user}""${home}/.euler"}# Re-publish every account's own row of the policy (/etc/euler/profiles/<account>,# root:<account> 0640). The map is not readable by a per-user uid, so a `solver` it runs —# claude-solve's headless Claude above all — takes its rung from that file; `users add` wrote# the map before this uid existed to own one. Deprovision runs it too, to drop the row.publish_rungs(){sudo"${VENV_PY}"-P-meuler_site.auth.adminsync-rungs\||echo"warn: could not publish the policy rows — run 'make redeploy-auth'"}# Run a provisioning step AS the collaborator's own uid (never root): a heredoc# script under `sudo -Hu`, so every file it creates is born with the right owner —# no chown sweep, and no window where root-owned files sit in a user's home. The# The per-user uids are inside the egress lock (firewall.sh), so the script first# sources /etc/euler/egress.env: outbound HTTP(S) goes through Squid or not at all# (root, by contrast, bypassed the lock — one more reason not to work as root).as_user(){localuser="$1";shiftsudo-Hu"${user}"env"$@"EGRESS_ENV="${EGRESS_ENV}"bash-s}# Lay the git hooks — and the repo-root config they read — into a provisioned clone,# rendered from THIS repo's templates.## The operator checkout is the source: the hooks a collaborator runs must be the ones this# repo ships, never whatever their clone happens to hold. A clone can sit behind master# indefinitely and nothing on this plane can move it forward — a sync would check out# solutions/private through the smudge filter, whose master key lives in the user's own# vault, locked to their session and deliberately unreachable from root. So the clone's own# templates are not a source this plane can trust, and the hooks are copied in from here.## Rendered by substituting the deployed venv for __VENV__: a per-user clone has no .venv, so# its hooks' flake8/mypy must call /opt/euler/venv. That is the only substitution the# templates take — they resolve REPO_ROOT at runtime (git rev-parse), so one rendered file# is correct in any checkout, which is what makes copying them in sound at all.## Rendered as the operator and installed by root, because this is the one provisioning step# whose source sits in the operator's 0750 home: the per-user uids cannot read it, so# as_user has nothing to read and root must place the file. `install` sets the owner as it# copies, so the hook is never a root-owned file in the user's home, not even briefly.provision_hooks(){localslug="$1"userclonehooksrcrendereduser="$(user_of"${slug}")"clone="$(home_of"${slug}")/euler"if!sudotest-d"${clone}/.git";thenecho"note: no clone at ${clone} — skipping the git hooks."return0fiecho"Installing the git hooks from ${PROJECT_ROOT} into ${clone} (venv ${VENV_DIR})..."forhookinpre-commitpre-push;dosrc="${PROJECT_ROOT}/scripts/setup/hooks/${hook}.template"if[!-f"${src}"];thenecho"warn: ${src} not found — skipping the ${hook} hook.">&2continuefirendered="$(mktemp)"sed-e"s|__VENV__|${VENV_DIR}|g"-e"s|__REPO_ROOT__|${clone}|g""${src}">"${rendered}"sudoinstall-o"${user}"-g"${user}"-m0755"${rendered}""${clone}/.git/hooks/${hook}"rm-f"${rendered}"echo"Installed hook: ${hook}"doneprovision_hook_config"${slug}"provision_filter_command"${slug}"}# Restore the two repo-root files the hooks READ, from this checkout into the clone.## A hook is only as good as its configuration, and both of these are configuration:# `.gitignore` decides the pre-commit "gitignored files" verdict (its `!` negations are what# keep a tracked dotfile from reading as ignored), and `.shellcheckrc` decides how every# shell script is judged — `external-sources` above all, without which a script passes or# fails on whether the file it sources happened to be in the same shellcheck command. Ship# the hook from here and leave its config to the clone, and the gate a collaborator runs is# not the gate this repo chose.## Copied ONLY when the clone's own HEAD already holds these exact bytes. That looks like a# no-op and is precisely the point: it puts the file back when a collaborator has edited# theirs, which is the only case where the two can disagree without git being involved.## It is deliberately SKIPPED when the clone is behind on the file, and this is not caution —# it is the difference between a missing config and an unusable clone. Git refuses to merge# over a path whose worktree copy differs from HEAD: untracked, or tracked-and-modified, and# even when the bytes are byte-identical to what the merge is bringing in (both cases# verified). Copying into a behind clone would therefore trade "your shellcheck lane is# unconfigured until you sync" for "you cannot sync at all" — and the sync is exactly what# fixes the first. So the file waits for `git-sync`, which is the plane that can deliver it,# and the operator is told which clones are waiting.provision_hook_config(){localslug="$1"userclonenamesrcourstheirsuser="$(user_of"${slug}")"clone="$(home_of"${slug}")/euler"fornamein.gitignore.shellcheckrc;dosrc="${PROJECT_ROOT}/${name}"if[!-f"${src}"];thenecho"warn: ${src} not found — skipping ${name}.">&2continuefi# Blob hashes, not file contents: `rev-parse HEAD:<path>` is what the clone has# COMMITTED, so this compares against the clone's own history and not its worktree —# a worktree the collaborator may have edited, which is the case being repaired.ours="$(git-C"${PROJECT_ROOT}"hash-object--"${src}"2>/dev/null||true)"theirs="$(sudo-u"${user}"git-C"${clone}"rev-parse"HEAD:${name}"2>/dev/null||true)"if[-z"${ours}"]||["${ours}"!="${theirs}"];thenecho"note: ${clone} is behind on ${name} — it arrives when ${slug} runs git-sync."continuefisudoinstall-o"${user}"-g"${user}"-m0644"${src}""${clone}/${name}"echo"Installed config: ${name}"done}# Point a clone's crypt filter at the deployed venv, with -P.## A clone keeps whatever filter command was recorded the day it was wired, and only# `git-filter install` rewrites it — which needs the master key, so it can run in the# collaborator's own session and nowhere else. That makes a change to this command the one# piece of per-clone setup they cannot receive: it waits for them to happen to save a key.## -P is why that matters. git runs a filter with the cwd at the top of the worktree, and a# solver checkout has a `packages/solver/solver/` package sitting right there — without -P the filter imports# THE CLONE's source instead of the venv's, so a clone that falls behind runs an old filter# against a current key file and stops decrypting. Writing the command here needs no key# (only the re-checkout does, and their tree is already plaintext), so the operator can push# it to every clone at once, the same way the hooks go out.provision_filter_command(){localslug="$1"userclonebaseuser="$(user_of"${slug}")"clone="$(home_of"${slug}")/euler"base="${VENV_DIR}/bin/python -P -m solver.crypto.gitfilter"if!sudo-u"${user}"git-C"${clone}"config--local--getfilter.solver-crypt.process\>/dev/null2>&1;thenreturn0# unwired: theirs to wire when they are issued a keyfiforactioninprocesscleansmudge;dosudo-u"${user}"git-C"${clone}"config--local\"filter.solver-crypt.${action}""${base}${action}"doneecho"Filter command points at ${VENV_DIR} (-P)"}# Clone the repo into ~/euler on branch user/<slug>, filter DISABLED → ciphertext at# rest. Clone straight from the public GitHub repo: GitHub stores only the filter's# ciphertext (the clean output), anonymous read needs no credentials, and origin stays# the GitHub URL so the user can push user/<slug> as themselves later. Runs AS# the user; github.com is in the Squid allowlist, so the clone rides the proxy.provision_clone(){localslug="$1"userhomecloneurluser="$(user_of"${slug}")"home="$(home_of"${slug}")"clone="${home}/euler"# A repair-run keeps the clone exactly as it stands: this plane cannot move it forward# (see provision_hooks), so there is nothing to do here but leave it be. do_provision# re-lays the hooks either way — that, not the clone, is what a repair refreshes.ifsudotest-d"${clone}/.git";thenecho"Clone ${clone} already present — leaving it as it stands."return0fiurl="$(origin_url)"if[-z"${url}"];thenecho"Error: could not read the origin URL from ${PROJECT_ROOT} — is it a checkout?">&2return1fiecho"Cloning ${url} into ${clone} as ${user} (filter disabled → ciphertext at rest)..."# No filter is wired in the clone's local config, and .gitattributes' rule is not# 'required', so solutions/private/** checks out as the ciphertext GitHub holds.# Commit authorship + push credential are the user's own, configured# self-service in their shell later (`git-identity`). The hooks are not laid here:# they come from the operator checkout, in provision_hooks, for a fresh clone and a# repair alike — one source, so a clone can never render its own.as_user"${user}"URL="${url}"SLUG="${slug}"<<'CLONE'set -euo pipefail[ -f "${EGRESS_ENV}" ] && { set -a; . "${EGRESS_ENV}"; set +a; }git clone --branch master "${URL}" "${HOME}/euler"git -C "${HOME}/euler" checkout -B "user/${SLUG}"CLONE}# Install the Claude Code CLI into the user's own home (~/.local/bin) — their login,# their config, their spend: the `claude` binary is per-user by design, exactly# like their vault. Runs the CLONE's own claude_code.sh AS the user, so its project# links land in their clone; best-effort — the download rides Squid, so downloads.claude.ai# (and claude.ai / code.claude.com) must be in the egress allowlist or this skips with# a warning and the user is told how to finish later.provision_claude(){localslug="$1"userhomeuser="$(user_of"${slug}")"home="$(home_of"${slug}")"if!sudotest-x"${home}/euler/scripts/setup/claude_code.sh";thenecho"note: no claude_code.sh in the clone — skipping the Claude Code install."return0fiecho"Installing Claude Code for ${user} (via Squid)..."if!as_user"${user}"<<'CLAUDE'set -euo pipefail[ -f "${EGRESS_ENV}" ] && { set -a; . "${EGRESS_ENV}"; set +a; }export PATH="${HOME}/.local/bin:${PATH}" # as the instance's unit has itbash "${HOME}/euler/scripts/setup/claude_code.sh" installCLAUDEthenecho"warn: Claude Code install failed — likely the egress allowlist (needs .claude.ai,"echo" code.claude.com; edit /etc/euler-proxy/squid.allowlist + 'egress.sh reload')."echo" The user can finish later in their shell: ! bash scripts/setup/claude_code.sh install"fi}enable_socket(){localslug="$1"if[!-f"${SOCKET_DEST}"];thenecho"note: euler-user@.socket not installed yet (euler_webshell.user pending, step 4) —"echo" instance socket for ${slug} deferred; re-run '$0 upgrade' then provision."return0fisudosystemctlenable--now"euler-user@${slug}.socket"}# ── slug ownership ────────────────────────────────────────────────────────────────────# Check (and on success record) that <slug> belongs to <email>. Refused: a slug recorded for a# different address, or an existing account with no record at all — provisioning must never# adopt an account whose owner it cannot show. stdlib python, so it needs no venv.require_owner(){localslug="$1"email="$2"exists=0getentpasswd"${slug}">/dev/null&&exists=1sudopython3-"${OWNERS_FILE}""${slug}""${email}""${exists}"<<'PY'import hashlib, json, os, syspath, slug, email, exists = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == '1'digest = hashlib.sha256(email.strip().lower().encode()).hexdigest()try: owners = json.load(open(path))except FileNotFoundError: owners = {}recorded = owners.get(slug)if recorded is not None and recorded != digest: sys.exit(f'Error: {slug} belongs to a different address — refusing to provision it for {email}.')if recorded is None and exists: sys.exit(f'Error: account {slug} exists with no ownership record — refusing to adopt it.\n' f' If it is {email}\'s, record it: user.sh record-owner {slug} {email}')owners[slug] = digestfd = os.open(path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)with os.fdopen(fd, 'w') as handle: json.dump(owners, handle, indent=2, sort_keys=True)os.replace(path + '.tmp', path)PY}# Record <slug> as <email>'s outright (an operator's assertion, for an existing account).record_owner(){localslug="$1"email="$2"sudopython3-"${OWNERS_FILE}""${slug}""${email}"<<'PY'import hashlib, json, os, syspath, slug, email = sys.argv[1:4]try: owners = json.load(open(path))except FileNotFoundError: owners = {}owners[slug] = hashlib.sha256(email.strip().lower().encode()).hexdigest()fd = os.open(path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)with os.fdopen(fd, 'w') as handle: json.dump(owners, handle, indent=2, sort_keys=True)os.replace(path + '.tmp', path)PY}do_provision(){localslug="${1:-}"email="${2:-}"profile="${3:-reader}"require_slug"${slug}"||return1if[["${email}"!=*@*]];thenecho"Error: provision needs the collaborator's e-mail (the slug's owner).">&2return1ficheck_can_sudo||return1require_python||return1require_owner"${slug}""${email}"||return1ensure_shared_groupsensure_user_identity"${slug}"publish_rungsprovision_clone"${slug}"provision_hooks"${slug}"provision_claude"${slug}"enable_socket"${slug}"# The new uid must be inside the egress lock, or (chain policy accept) it would reach# the internet directly, bypassing Squid. firewall.sh enumerates the per-user uids by group.if[-f/etc/systemd/system/euler-firewall.service];thenecho"Reloading the egress firewall to cover $(user_of"${slug}")...""${SCRIPT_DIR}/firewall.sh"reload||echo"warn: firewall reload failed — run 'make deploy-firewall'"fiecho"Provisioned ${slug}${email:+ (}${email}${email:+)} → profile ${profile}."echo"Next (self-service, in the user's web shell): \`git-identity\` (GitHub sign-in +"echo"commit authorship), generate a key (\`user\`), have an admin \`user-authorize\` it"echo"+ push, then \`git-sync\` — the pull wires the filter and decrypts their private tree."}do_deprovision(){localslug="${1:-}"userhomereplyrequire_slug"${slug}"||return1check_can_sudo||return1user="$(user_of"${slug}")"home="$(home_of"${slug}")"if[-f"${SOCKET_DEST}"];thensudosystemctldisable--now"euler-user@${slug}.socket"2>/dev/null||truesudosystemctldisable--now"euler-user@${slug}.service"2>/dev/null||truefisudorm-f"$(sock_of"${slug}")"echoecho"IMPORTANT: this does NOT revoke ${slug}'s master-key access. If their key was"echo" user-authorized, run \`key-rekey\` in the shell to rotate the master key"echo" and re-wrap it to the remaining authorized users only (their key was"echo" never on this root plane, so it cannot be dropped from here)."echoif!getentpasswd"${user}">/dev/null;thenecho"No such user ${user} — nothing more to remove."return0firead-r-p"Remove the account ${user} and its home ${home}? [y/N] "replyif[["${reply}"=~^[Yy]$]];thensudouserdel--remove"${user}"2>/dev/null||sudouserdel"${user}"2>/dev/null||true# shellcheck disable=SC2015 # `|| true` only swallows a failed groupdel (flagged before ShellCheck 0.11)getentgroup"${user}">/dev/null&&sudogroupdel"${user}"2>/dev/null||truesudorm-rf"${home}"echo"Removed ${user} and ${home}."elseecho"Left ${user} in place (instance stopped/disabled). Home: ${home}."fipublish_rungsif[-f/etc/systemd/system/euler-firewall.service];then"${SCRIPT_DIR}/firewall.sh"reload2>/dev/null||truefi}# ── install / uninstall / status ──────────────────────────────────────────────────────do_deploy(){check_can_sudo||return1require_systemd||return1require_python||return1ensure_shared_groupsdeploy_user_venvdeploy_user_envsudomkdir-p/run/eulerifvenv_has_user_service;theninstall_unitselseecho"note: euler_webshell.user not yet in the deployed package — unit template deferred"echo" (re-run '$0 upgrade' once step 4 lands the service)."fido_status}do_redeploy(){check_can_sudo||return1deploy_user_envecho"Refreshed ${USER_ENV}."bounce_instancesdo_status}# Pick up a freshly rebuilt /opt/euler venv (redeploy-auth / upgrade-auth): re-lay each# user's hooks from this repo, then stop each running instance — this drops that user's live# shell — and leave its socket listening, so the next request re-activates the service# against the new code. Both redeploy and upgrade run it: an instance left running keeps# the code it started with, and once pip has replaced that code on disk (a template, a# moved package) every page it renders fails.## The hooks belong here for the same reason the venv does: they are the operator's code# running in the user's clone, they bake the deployed venv's path, and a clone cannot# refresh them itself. This is the one sweep that reaches every provisioned user, so it is# where a template change (a new gate) actually lands for everyone — otherwise a hook fix# would only reach a user who happened to be reprovisioned.bounce_instances(){localusersslugusers="$(euler_user_names||true)"forslugin${users};doprovision_hooks"${slug}"ifsystemctlis-active--quiet"euler-user@${slug}.service"2>/dev/null;thenecho"Stopping euler-user@${slug}.service (its socket re-activates it on the next request)..."sudosystemctlstop"euler-user@${slug}.service"fidone}# Re-create each provisioned user's socket so a changed template (its group, its mode)# applies to the socket itself, not just the unit file. The service must be down first# (bounce_instances), since it Requires= the socket.relay_sockets(){localusersslugusers="$(euler_user_names||true)"forslugin${users};doifsystemctlis-enabled--quiet"euler-user@${slug}.socket"2>/dev/null;thensudosystemctlrestart"euler-user@${slug}.socket"fidone}do_upgrade(){do_deploy||return1bounce_instancesrelay_sockets}do_remove(){check_can_sudo||return1# Any provisioned users left? Refuse to strip the shared layer out from under them.localleftoverleftover="$(euler_user_names||true)"if[-n"${leftover}"];thenecho"Refusing to uninstall — provisioned users remain:">&2# shellcheck disable=SC2001 # per-line indent of a multi-line string; ${//} can't anchor ^echo"${leftover}"|sed's/^/ /'>&2echo"Deprovision each ('$0 deprovision <slug>') first.">&2return1fiif[-f"${SOCKET_DEST}"]||[-f"${SERVICE_DEST}"];thensudorm-f"${SOCKET_DEST}""${SERVICE_DEST}"sudosystemctldaemon-reloadfisudorm-f"${USER_ENV}"localreplyread-r-p"Remove the ${USER_GROUP} group? [y/N] "reply# shellcheck disable=SC2015 # `|| true` only swallows a failed groupdel (flagged before ShellCheck 0.11)[["${reply}"=~^[Yy]$]]&&{getentgroup"${USER_GROUP}">/dev/null&&sudogroupdel"${USER_GROUP}"2>/dev/null||true;}echo"Per-user provisioning uninstall complete."}# One marker vocabulary for every status line, shared layer and per-user alike:## ✓ present / healthy# ✗ missing / broken — always a fault, always worth acting on# … deferred — a legitimate not-yet (a later step lays it down), not a fault## Every field leads with its marker, so a fault reads the same way wherever it appears.# The per-user line used to spell its faults in prose ("no-clone") between two ✓s, where# they went unread for weeks — hence: no word carries a verdict that a marker doesn't.OK='✓'BAD='✗'DEFER='…'# "label: <mark> <detail>" — the shared-layer column.status_line(){printf'%-12s %s %s\n'"$1:""$2""$3";}# Echo ✓ if the check succeeds, ✗ if it fails. Written as if/else rather than# `cmd && echo ✓ || echo ✗` so `set -e` never sees a bare failing command, and so a# check that fails cannot be mistaken for one that printed nothing.check_mark(){if"$@">/dev/null2>&1;thenprintf'%s'"${OK}";elseprintf'%s'"${BAD}";fi}do_status(){if[!-x"${VENV_PY}"];thenstatus_line"venv""${BAD}""${VENV_DIR} not deployed (run auth.sh / user.sh deploy)"elif"${VENV_PY}"-P-c'import euler_webshell.user'2>/dev/null;thenstatus_line"venv""${OK}""euler_webshell.user importable from ${VENV_DIR}"elsestatus_line"venv""${DEFER}""euler_webshell.user not in the venv yet (step 4)"fistatus_line"group""$(check_markgetentgroup"${USER_GROUP}")""${USER_GROUP}"status_line"config""$(check_marktest-f"${USER_ENV}")""${USER_ENV}"if[-f"${SOCKET_DEST}"];thenstatus_line"template""${OK}""${SOCKET_TEMPLATE} + ${SERVICE_TEMPLATE}"elsestatus_line"template""${DEFER}""deferred (euler_webshell.user not yet deployed)"fi# Per-user roster (or one slug's instance), each reported by unix name + e-mail alias.load_aliaseslocalslug="${1:-}"if[-n"${slug}"];thenstatus_line"users""${OK}""1 requested"status_one"${slug}"return0filocalusersslug_users="$(euler_user_names||true)"if[-z"${users}"];thenstatus_line"users""${DEFER}""none provisioned"return0fistatus_line"users""${OK}""$(wc-l<<<"${users}") provisioned"forslug_in${users};dostatus_one"${slug_}"done}# ── the per-user report ───────────────────────────────────────────────────────────────## A collaborator is reported BY UNIX NAME (the slug — that is what the uid, the home, the# socket, the unit instance and the branch are all called) with their **e-mail as an# alias**. The alias has to be looked up rather than read off the name: the slug is a hash# of the e-mail, so the only way back is to recompute system_slug() for every identity in# the deployed policy and index by the result. A slug with no alias is an account the# policy no longer maps (deprovision it, or re-add the user).AUTHZ_FILE="${SYS_DIR}/authorizations.json"# system_slug(email) for each mapped web identity → "<slug> <email> <profile>"._ALIAS_PY='import json, sysfrom solver.auth.identity import system_slugtry: users = json.load(open(sys.argv[1])).get("users", {})except (OSError, ValueError): raise SystemExit(0)for email, profile in sorted(users.items()): if "@" in email: print(system_slug(email), email, profile)'declare-AUSER_ALIAS=()# Populate USER_ALIAS[slug] = "<email> <profile>". Needs the deployed venv (it imports# solver.auth.identity, the one definition of the mapping); silently leaves the map empty# when the venv or the policy file is absent — the report then shows slugs alone.load_aliases(){[-x"${VENV_PY}"]||return0localslugemailprofilewhileread-rslugemailprofile;do[-n"${slug}"]&&USER_ALIAS["${slug}"]="${email} (${profile})"done<<(sudo"${VENV_PY}"-P-c"${_ALIAS_PY}""${AUTHZ_FILE}"2>/dev/null||true)}# Ask a RUNNING instance what its web shell is doing: GET /internal/status over that# user's own socket. Root can connect whatever the 0660 mode says.## Only ever called when the service is already active. Dialing the socket is what# *starts* an instance (it is socket-activated), so an unconditional probe would fork a# service — and a whole PTY-less python process — for every idle collaborator, which is a# strange thing for a status command to do.## The reply is reduced by _SHELL_PY to one line — "none", "unreadable", or# "live <attached> <detached-seconds> <pid>" — and this function turns that into the# marked phrase; there is at most one shell per instance (one user, one PtyManager key)._SHELL_PY='import json, systry: shells = json.loads(sys.argv[1]).get("shells") or []except ValueError: print("unreadable"); raise SystemExit(0)live = [s for s in shells if s.get("alive")]if not live: print("none"); raise SystemExit(0)s = live[0]print("live", int(s.get("attached", 0)), int(float(s.get("detached_for", 0))), s.get("pid", "?"))'shell_state(){localslug="$1"payloadstateattacheddetachedpidif!command-vcurl>/dev/null2>&1;thenprintf'%s unknown (curl not installed)'"${DEFER}"return0fipayload="$(sudocurl-sS--max-time3--unix-socket"$(sock_of"${slug}")"\http://localhost/internal/status2>/dev/null||true)"if[-z"${payload}"];thenprintf'%s unreachable (instance running, /internal/status silent)'"${BAD}"return0fi# `|| true`: a silent reducer leaves read at EOF, and a bare failing read would take# `set -e` down with it — a status probe must never abort the sweep.read-rstateattacheddetachedpid\<<("${PYTHON}"-c"${_SHELL_PY}""${payload}"2>/dev/null||true)||truecase"${state:-}"in# ':-' — a failed read leaves it unset (set -u)live)if["${attached:-0}"-gt0];thenprintf'%s live · connected (%s terminal(s)) · pid %s'"${OK}""${attached}""${pid}"elseprintf'%s live · NOT connected (detached %ss) · pid %s'"${OK}""${detached}""${pid}"fi;;none)printf'%s none (instance running, no shell forked)'"${DEFER}";;*)printf'%s unreadable reply from /internal/status'"${BAD}";;esac}# Two lines per collaborator: the identity line (unix name + e-mail alias), then the# instance line and the shell line, every field marked.status_one(){localslug="$1"userhomeclonesocketserviceshelluser="$(user_of"${slug}")"home="$(home_of"${slug}")"clone="${home}/euler"printf' %-10s %s\n'"${slug}""${USER_ALIAS[${slug}]:-<no mapped e-mail>}"if!getentpasswd"${user}">/dev/null2>&1;thenprintf' %-10s %s uid missing (nothing provisioned under this name)\n'''"${BAD}"return0fiif[!-f"${SOCKET_DEST}"];thensocket="${DEFER} deferred"service="${DEFER} deferred"shell="${DEFER} deferred"elseifsystemctlis-active--quiet"euler-user@${slug}.socket"2>/dev/null;thensocket="${OK} listening"elsesocket="${BAD} inactive"fi# Not running is NORMAL, not a fault: the socket activates the instance on the# first request and the reaper/logout tears it down again, so an idle# collaborator legitimately has no process — hence '…', not '✗'.ifsystemctlis-active--quiet"euler-user@${slug}.service"2>/dev/null;thenservice="${OK} running"shell="$(shell_state"${slug}")"elseservice="${DEFER} idle (socket-activated on demand)"shell="${DEFER} none (instance idle)"fifi# The clone and its hooks live under a 0700 home — only root can see them (as vikas the# test silently reads "absent", which is exactly how "no-clone" got misreported).printf' %-10s uid %s · clone %s · hooks %s · socket %s · service %s\n'''\"${OK}"\"$(check_marksudotest-d"${clone}/.git")"\"$(check_marksudotest-x"${clone}/.git/hooks/pre-commit"-a-x"${clone}/.git/hooks/pre-push")"\"${socket}"\"${service}"printf' %-10s web shell %s\n'''"${shell}"}# Sign everyone out ahead of a stop. The deployed venv runs this, so the first deploy of the# feature finds no module and skips it. A failure must not block a redeploy: that redeploy# may be the fix, and stopping the instances drops the shells regardless.do_logout_all(){if!sudo"${VENV_PY}"-P-c'import euler_webshell.logout_all'2>/dev/null;thenecho"note: the deployed venv has no logout-all yet; skipping the notice"return0fisudo"${VENV_PY}"-P-meuler_webshell.logout_all"$@"\||echo"warning: logout-all did not finish; carrying on (live shells will be dropped)"}# ── Dispatch ──────────────────────────────────────────────────────────────────────────ACTION="${1:-status}"shift||truecase"${ACTION}"indeploy)do_deploy;;upgrade)do_upgrade;;redeploy)do_redeploy;;logout-all)do_logout_all"$@";;remove)do_remove;;provision)do_provision"$@";;deprovision)do_deprovision"$@";;record-owner)require_slug"${1:-}"&&[["${2:-}"==*@*]]&&record_owner"$1""$2";;status)do_status"$@";;-h|--help|help)usage;;*)echo"Unknown action: ${ACTION}";usage;exit1;;esac