From 9345869fb88abc577a161413cd9b4148691cde7d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:57:30 +0000 Subject: [PATCH 1/2] fix(tooling): provision Claude OAuth EnvironmentFile on fleet+pet installs Installers write 42-claude-oauth-token.conf and migrate legacy tokens so runner ambient auth survives upgrades (Fixes heavygee/hapi#191). Verifier and soup-redeploy assert EnvironmentFile load; Meta ping backoff advances only after successful delivery. Co-authored-by: Cursor --- .github/workflows/soup-redeploy.yml | 266 +- docs/guide/pet-install.md | 14 +- .../2026-09-04-fleet-vm-swap-strategy.md | 5 +- scripts/install-hapi-pet.sh | 689 ++++- scripts/tooling/hapi-claude-account-toggle.sh | 256 +- .../tooling/hapi-claude-oauth-dropin.test.sh | 926 +++++++ scripts/tooling/hapi-meta-daily.sh | 38 +- scripts/tooling/hapi-meta-daily.test.sh | 6 + scripts/tooling/install-hapi-systemd-units.sh | 229 +- .../tooling/lib/hapi-claude-oauth-dropin.sh | 2251 +++++++++++++++++ scripts/tooling/verify-hapi-install.sh | 1029 +++++++- 11 files changed, 5609 insertions(+), 100 deletions(-) create mode 100755 scripts/tooling/hapi-claude-oauth-dropin.test.sh create mode 100755 scripts/tooling/lib/hapi-claude-oauth-dropin.sh diff --git a/.github/workflows/soup-redeploy.yml b/.github/workflows/soup-redeploy.yml index d8b774103f..d81048ca14 100644 --- a/.github/workflows/soup-redeploy.yml +++ b/.github/workflows/soup-redeploy.yml @@ -92,34 +92,96 @@ jobs: # the moment this workflow exists to react to. Proves the standalone path # with zero pre-existing state, zero Lockhouse dependency, exactly as # anyone else running this script would experience it. + # + # --with-systemd needs a real system manager (PID 1) plus a per-user manager + # (loginctl linger + /run/user/$UID). A plain `docker run debian bash` has + # neither, so systemctl --user daemon-reload fails under set -e. redeploy-pet: if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: [self-hosted, hapi] timeout-minutes: 20 steps: - uses: actions/checkout@v4 + - name: Build a throwaway systemd-capable pet test image + run: | + set -euo pipefail + cat > /tmp/hapi-pet-test.Dockerfile <<'EOF' + FROM debian:trixie + RUN apt-get update -qq && apt-get install -y -qq \ + systemd systemd-sysv dbus dbus-user-session libpam-systemd \ + curl ca-certificates procps sudo \ + && rm -rf /var/lib/apt/lists/* + EOF + docker build -f /tmp/hapi-pet-test.Dockerfile -t hapi-pet-test:ci /tmp + - name: Boot it with systemd as init + run: | + set -euo pipefail + docker rm -f hapi-pet-ci >/dev/null 2>&1 || true + docker run -d --name hapi-pet-ci \ + --privileged \ + --tmpfs /tmp --tmpfs /run --tmpfs /run/lock \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v "$PWD":/repo:ro \ + hapi-pet-test:ci /lib/systemd/systemd --log-target=console --log-level=info + sleep 3 + docker exec hapi-pet-ci systemctl is-system-running || true - name: Fresh install, then re-run to exercise the upgrade path run: | set -euo pipefail - docker run --rm -v "$PWD":/repo:ro debian:trixie bash -c ' - set -euo pipefail - apt-get update -qq >/dev/null - apt-get install -y -qq curl ca-certificates procps >/dev/null - useradd -m testuser + # Heredoc into docker exec -i (not bash -c '...') so inner single quotes + # (pgrep pattern, etc.) cannot terminate the host shell string. + docker exec -i hapi-pet-ci bash <<'EOS' + set -euo pipefail + useradd -m -s /bin/bash testuser + loginctl enable-linger testuser + for _ in $(seq 1 30); do + [[ -d /run/user/$(id -u testuser) ]] && break + sleep 1 + done + [[ -d /run/user/$(id -u testuser) ]] \ + || { echo "FAIL: user manager runtime dir never appeared for testuser"; exit 1; } + echo "OK: linger + /run/user/$(id -u testuser) ready" - echo "=== fresh install ===" - su - testuser -c "bash /repo/scripts/install-hapi-pet.sh" - su - testuser -c "curl -fsS localhost:3006/health" - FIRST_SHA=$(su - testuser -c "sha256sum ~/.local/bin/hapi" | awk "{print \$1}") - echo "binary sha256: $FIRST_SHA" + as_testuser() { + # install-hapi-pet.sh --with-systemd calls systemctl --user; that + # needs XDG_RUNTIME_DIR + the user dbus socket from linger. + su - testuser -c "export XDG_RUNTIME_DIR=/run/user/\$(id -u); export DBUS_SESSION_BUS_ADDRESS=unix:path=\$XDG_RUNTIME_DIR/bus; $*" + } - echo "=== re-run (upgrade path): must stop the old process, swap, relaunch ===" - su - testuser -c "bash /repo/scripts/install-hapi-pet.sh" - sleep 2 - su - testuser -c "curl -fsS localhost:3006/health" - su - testuser -c "pgrep -fa \"hapi (hub|runner)\"" || { echo "FAIL: no hapi processes survived the re-run"; exit 1; } - echo "OK: pet install + upgrade re-run both healthy" - ' + # Isolate the installer so SCRIPT_DIR has no tooling/ companion — + # same shape as curl|bash. Running /repo/scripts/... always prefers + # install-hapi-systemd-units.sh and never hits the embedded units. + install -m 0755 /repo/scripts/install-hapi-pet.sh /home/testuser/install-hapi-pet.sh + chown testuser:testuser /home/testuser/install-hapi-pet.sh + + echo "=== fresh install with --with-systemd (embedded curl|bash path) ===" + as_testuser "bash ~/install-hapi-pet.sh --with-systemd" \ + | tee /tmp/hapi-pet-fresh-install.log + grep -q 'No git checkout — writing embedded user systemd units' \ + /tmp/hapi-pet-fresh-install.log \ + || { echo "FAIL: expected embedded units path (no companion SCRIPT_DIR)"; exit 1; } + echo "OK: exercised embedded install_user_pet_systemd branch" + as_testuser "curl -fsS localhost:3006/health" + FIRST_SHA=$(as_testuser "sha256sum ~/.local/bin/hapi" | awk '{print $1}') + echo "binary sha256: $FIRST_SHA" + + echo "=== Claude OAuth drop-in must exist (token itself is operator-minted) ===" + as_testuser "test -f ~/.config/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf" + as_testuser "grep -q claude-setup-token.env ~/.config/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf" + echo "OK: pet installer provisioned 42-claude-oauth-token.conf" + + echo "=== re-run (upgrade path): must stop the old process, swap, relaunch ===" + as_testuser "bash ~/install-hapi-pet.sh --with-systemd" + sleep 2 + as_testuser "curl -fsS localhost:3006/health" + as_testuser "pgrep -fa 'hapi (hub|runner)'" || { echo "FAIL: no hapi processes survived the re-run"; exit 1; } + echo "OK: pet install + upgrade re-run both healthy" + EOS + - name: Cleanup + if: always() + run: | + docker rm -f hapi-pet-ci >/dev/null 2>&1 || true + docker rmi hapi-pet-test:ci >/dev/null 2>&1 || true # Also only on release/dispatch. Needs real systemd as PID 1 to test the # actual units Tier-1 installs (KillMode, OOMScoreAdjust, the watchdog) — @@ -133,17 +195,15 @@ jobs: - name: Build a throwaway systemd-capable test image run: | set -euo pipefail - # Dedicated, empty build context - NOT /tmp. This is a shared, - # busy host; /tmp as build context tries to package the whole - # directory (other processes' sockets etc.) and fails with - # "no permission to read from '/tmp/dotnet-diagnostic-...-socket'" - # (confirmed live, 2026-10-01). + # Dedicated, empty build context - NOT /tmp. Shared host /tmp as + # context fails with "no permission to read from '/tmp/dotnet-diagnostic-...'" + # (heavygee/hapi#190). CTX="$(mktemp -d)" trap 'rm -rf "$CTX"' EXIT cat > "$CTX/Dockerfile" <<'EOF' FROM debian:trixie RUN apt-get update -qq && apt-get install -y -qq \ - systemd systemd-sysv curl ca-certificates sudo procps \ + systemd systemd-sysv curl ca-certificates sudo procps python3 \ && rm -rf /var/lib/apt/lists/* EOF docker build -t hapi-fleet-test:ci "$CTX" @@ -165,10 +225,10 @@ jobs: docker exec hapi-fleet-ci bash -c ' set -euo pipefail useradd --system --create-home --home-dir /var/lib/hapi --shell /usr/sbin/nologin hapi - mkdir -p /opt/hapi /work + mkdir -p /opt/hapi /work /var/lib/hapi curl -fsSL "https://github.com/heavygee/hapi/releases/latest/download/hapi-linux-x64-baseline" -o /opt/hapi/hapi chmod +x /opt/hapi/hapi - chown hapi:hapi /opt/hapi/hapi /work + chown hapi:hapi /opt/hapi/hapi /work /var/lib/hapi /opt/hapi/hapi --version ' - name: Run the real installer (fleet-binary + Tier-1), with a real TTY @@ -178,11 +238,30 @@ jobs: cd /repo bash scripts/tooling/install-hapi-systemd-units.sh --profile fleet-binary --hapi-user hapi --workspace-root /work ' + - name: Plant a probe Claude OAuth token (proves EnvironmentFile loads) + run: | + set -euo pipefail + docker exec hapi-fleet-ci bash -c ' + set -euo pipefail + # Installer must have created the drop-in. Plant a known probe value + # so we can assert the runner process actually loaded it — the + # antevorta 2026-10-01 failure mode was drop-in/file missing after upgrade. + test -f /etc/systemd/system/hapi-runner.service.d/42-claude-oauth-token.conf \ + || { echo "FAIL: Claude OAuth drop-in not installed by fleet-binary profile"; exit 1; } + install -d -m 0755 /etc/hapi + TOKEN_FILE=/etc/hapi/claude-setup-token.env + umask 077 + printf "CLAUDE_CODE_OAUTH_TOKEN=%s\n" "probe-ci-token-do-not-use-against-anthropic" > "$TOKEN_FILE" + chown root:root "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + echo "planted probe token at $TOKEN_FILE" + ' - name: Start hub + runner, let the watchdog fire once, then verify run: | set -euo pipefail docker exec -t hapi-fleet-ci bash -c ' set -euo pipefail + systemctl daemon-reload systemctl start hapi-hub.service sleep 2 systemctl start hapi-runner.service @@ -191,18 +270,143 @@ jobs: systemctl is-active hapi-runner.service curl -sf http://127.0.0.1:3006/health - # A brand-new install watchdog fires once before settings.json - # exists and correctly skips (documented, expected) — give it one - # more real chance now that the hub has written it, same as the - # install script own guidance ("re-run verify once the hub has - # started"), rather than asserting on a known-stale first fire. + RPID=$(systemctl show -p MainPID --value hapi-runner.service) + tr "\0" "\n" < /proc/$RPID/environ | grep -q "^CLAUDE_CODE_OAUTH_TOKEN=probe-ci-token-do-not-use-against-anthropic$" \ + || { echo "FAIL: runner did not load Claude OAuth EnvironmentFile"; tr "\0" "\n" < /proc/$RPID/environ | grep -E "CLAUDE|HAPI_HOME" || true; exit 1; } + echo "OK: runner loaded probe CLAUDE_CODE_OAUTH_TOKEN from EnvironmentFile" + systemctl start hapi-runner-watchdog.service || true sleep 2 ' - name: verify-hapi-install.sh run: | set -euo pipefail - docker exec hapi-fleet-ci bash /repo/scripts/tooling/verify-hapi-install.sh + docker exec hapi-fleet-ci bash /repo/scripts/tooling/verify-hapi-install.sh --skip-restart + - name: Fresh non-resume spawn must not show Not logged in (when secret present) + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + echo "::notice::CLAUDE_CODE_OAUTH_TOKEN secret not set — skipping live Anthropic spawn assertion (EnvironmentFile load already proven above)" + exit 0 + fi + # Heredoc + docker exec -i keeps runuser/claude install quotes inside the + # container script (bash -c '...' nesting previously terminated the host string). + docker exec -i -e CLAUDE_CODE_OAUTH_TOKEN hapi-fleet-ci bash <<'EOS' + set -euo pipefail + # Native Claude install lands in $HOME/.local/bin; runuser -u is not a + # login shell so inherit an explicit PATH that includes it. + HAPI_CLAUDE_PATH="/var/lib/hapi/.local/bin:/usr/local/bin:/usr/bin:/bin" + # Never leak the live OAuth secret into network install.sh / npm. + if ! runuser -u hapi -- env -u CLAUDE_CODE_OAUTH_TOKEN HOME=/var/lib/hapi PATH="$HAPI_CLAUDE_PATH" \ + bash -c 'command -v claude >/dev/null'; then + echo "=== installing Claude Code CLI for hapi service user ===" + runuser -u hapi -- env -u CLAUDE_CODE_OAUTH_TOKEN HOME=/var/lib/hapi PATH="$HAPI_CLAUDE_PATH" \ + bash -c 'curl -fsSL https://claude.ai/install.sh | bash' + runuser -u hapi -- env -u CLAUDE_CODE_OAUTH_TOKEN HOME=/var/lib/hapi PATH="$HAPI_CLAUDE_PATH" \ + bash -c 'command -v claude && claude --version' + fi + install -d -m 0755 /etc/hapi + TOKEN_FILE=/etc/hapi/claude-setup-token.env + umask 077 + printf "CLAUDE_CODE_OAUTH_TOKEN=%s\n" "$CLAUDE_CODE_OAUTH_TOKEN" > "$TOKEN_FILE" + chown root:root "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + systemctl restart hapi-runner.service + sleep 3 + python3 - <<'PY' + import json, time, urllib.request + base="http://127.0.0.1:3006" + # Unique reply token — require it in agent-role text only (Codex P2). + PROBE="AUTHPROBEOK" + token=json.load(open("/var/lib/hapi/settings.json"))["cliApiToken"] + jwt=json.load(urllib.request.urlopen(urllib.request.Request( + base+"/api/auth", data=json.dumps({"accessToken":token}).encode(), + headers={"Content-Type":"application/json"}, method="POST")))["token"] + def http(path, method="GET", body=None): + data=None if body is None else json.dumps(body).encode() + req=urllib.request.Request(base+path, data=data, headers={"Authorization":f"Bearer {jwt}","Content-Type":"application/json"}, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + raw=r.read().decode() + try: return r.status, json.loads(raw) + except: return r.status, raw + def agent_blob(resp): + """Flatten real assistant output; skip Claude user-echo envelopes.""" + if not isinstance(resp, dict): + return str(resp) + parts=[] + for m in resp.get("messages") or []: + if not isinstance(m, dict): + continue + content = m.get("content") + if not isinstance(content, dict): + continue + # DecryptedMessage.content is usually {role, content: ...} + role = content.get("role") + body = content.get("content") + if role is None and isinstance(content.get("message"), dict): + role = content["message"].get("role") + body = content["message"].get("content", body) + if role not in ("agent", "assistant"): + continue + # Claude stream-json: user prompts are re-emitted as + # role=agent / type=output / data.type=user — ignore those. + if isinstance(body, dict) and body.get("type") == "output": + data = body.get("data") + if isinstance(data, dict) and data.get("type") != "assistant": + continue + parts.append(json.dumps(content, default=str)) + return "\n".join(parts) + st, machines = http("/api/machines") + arr = machines if isinstance(machines, list) else (machines.get("machines") or []) + mid = arr[0]["id"] + st, spawn = http(f"/api/machines/{mid}/spawn", "POST", { + "directory": "/work", "agent": "claude", "model": "sonnet", + "permissionMode": "bypassPermissions", "sessionType": "simple", + }) + sid = spawn.get("sessionId") + assert sid, spawn + print("spawned", sid) + # Spawn HTTP 200 only means the runner accepted the RPC. Poll active + # like pingPeer.waitUntilActive before POST /messages (else 409). + deadline = time.time() + 90 + while time.time() < deadline: + st, sess = http(f"/api/sessions/{sid}") + active = False + if isinstance(sess, dict): + active = bool((sess.get("session") or sess).get("active")) + print(f"wait-active status={st} active={active}") + if active: + break + time.sleep(1) + else: + raise SystemExit(f"FAIL: session {sid} never became active within 90s") + # Prompt names the expected token; we still require it in an agent-role + # message so the echoed user prompt cannot pass the assertion alone. + st, _ = http(f"/api/sessions/{sid}/messages", "POST", { + "text": f"Reply with the single token {PROBE} and nothing else." + }) + assert st == 200 + saw_login=False; saw_ok=False + for i in range(30): + time.sleep(2) + st, resp = http(f"/api/sessions/{sid}/messages?limit=40") + agent_text = agent_blob(resp) + if "Not logged in" in agent_text: saw_login=True + if PROBE in agent_text: saw_ok=True + print(f"poll[{i}] login={saw_login} ok={saw_ok}") + if saw_login or saw_ok: break + # archive smoke session + try: http(f"/api/sessions/{sid}/archive", "POST", {}) + except Exception: pass + if saw_login: + raise SystemExit("FAIL: fresh spawn showed Not logged in") + if not saw_ok: + raise SystemExit(f"FAIL: fresh spawn did not produce agent reply containing {PROBE} (auth may still be broken)") + print("OK: fresh non-resume spawn authenticated") + PY + EOS - name: Cleanup if: always() run: | diff --git a/docs/guide/pet-install.md b/docs/guide/pet-install.md index 55f001a9cc..852738e247 100644 --- a/docs/guide/pet-install.md +++ b/docs/guide/pet-install.md @@ -51,8 +51,18 @@ in `~/.hapi/settings.json` — **treat it exactly like a password.** **Do not paste it into an AI chat assistant, a support ticket, a screenshot, or anywhere else, even to ask for help.** If you ever do paste it somewhere by accident (including pasting full terminal output that happens to contain it), treat it as compromised and -get a fresh one — delete the `cliApiToken` line from `~/.hapi/settings.json` and restart -the hub; it generates a new one automatically. +get a fresh one — remove it with a JSON-safe edit and restart **both** hub and runner +(the runner caches the token at process start, so hub-only restart leaves reconnects +rejected): + +```bash +jq 'del(.cliApiToken)' ~/.hapi/settings.json > ~/.hapi/settings.json.tmp \ + && mv ~/.hapi/settings.json.tmp ~/.hapi/settings.json +systemctl --user restart hapi-hub.service hapi-runner.service +``` + +If you are on the nohup/path without user systemd units, re-run the install command +instead — it regenerates the token and relaunches hub + runner. ## Upgrading later diff --git a/docs/plans/2026-09-04-fleet-vm-swap-strategy.md b/docs/plans/2026-09-04-fleet-vm-swap-strategy.md index e6f19ea9ef..a4be1fca2c 100644 --- a/docs/plans/2026-09-04-fleet-vm-swap-strategy.md +++ b/docs/plans/2026-09-04-fleet-vm-swap-strategy.md @@ -24,6 +24,7 @@ | **Confirmed 2026-09-09:** an old (`main`-schema) `hapi.db` doesn't have soup-only tables (e.g. `session_jobs`) — the soup hub refuses to start against it | A swap onto a machine with an existing, differently-versioned DB is not just a single-writer problem, it's a **schema compatibility** problem | Stage 6 (patient drain / cutover) needs a schema-check step before attaching an existing data volume to a new artifact version; falling back to a fresh DB loses sessions, which is real, visible data loss the operator must consciously accept, not something automated silently | | **Confirmed 2026-09-30 on antevorta's live upgrade:** live agent child processes hold `/opt/hapi/hapi` mapped in memory for as long as they run — one process had it open continuously for 1d5h+ | In-place `cp`/`install` truncate-and-rewrite the target file, which fails with `Text file busy` against any process still executing it. This is not theoretical — it happened mid-upgrade on Doug's box and left hub+runner stopped for several minutes until diagnosed | The binary swap must always be **temp-file + `mv`** (atomic rename), never in-place `cp`/`install` — `scripts/install-hapi-pet.sh:214` already does this correctly (`mv "$TMP_BIN" "$INSTALL_DIR/hapi"`); any new swap script must reuse or mirror that path, not reimplement the swap from scratch | | **Confirmed 2026-09-30, same incident:** a swap script that stops the old service before replacing the binary has an exit path — here, `set -e` aborting on the failed `cp` above — that leaves the unit stopped with no restart attempted | A script that only restarts on its success path turns a recoverable mistake (wrong copy method) into a real outage (both services down until a human notices and intervenes) | Any swap script must restart hub and runner on **every** exit path, not only the happy path — e.g. a trap that always attempts the restart, not a bare sequential `set -e` script | +| **Confirmed 2026-10-01 on antevorta after the `17908b4` runner restart:** web UI `POST /api/machines/:id/spawn` does **not** send `options.token`. Hub `rpcGateway.spawnSession` has no token field; `SpawnSessionRequestSchema` has none. Runner `buildSpawnAuthEnv` only injects `CLAUDE_CODE_OAUTH_TOKEN` from an explicit RPC token or `directoryAuthProfiles`. Otherwise the child inherits the **runner process** ambient login (oos-linux: systemd `EnvironmentFile` `~/.hapi/claude-setup-token.env`). There is no `~/.claude/.credentials.json` on the `hapi` service account, and that is still expected — but the runner **must** carry the setup-token in its own environment or new UI Claude sessions print `Not logged in · Please run /login`. Live `--resume` children keep the token they inherited at spawn, so they survive a runner restart that dropped the env. `cliApiToken` / `protocolVersion` checks do not see this. | An in-place upgrade can look perfect (sha256, MainPID, live sessions still chatting) while Doug is fully blocked from starting any new Claude work | Fleet/system Claude hosts need the drop-in shape: `EnvironmentFile=-/etc/hapi/claude-setup-token.env` on the runner unit, file **root:root `0600`** (not under service-writable `$HAPI_HOME`), minted via `claude setup-token` + `printf ... | sudo install -m 0600 /dev/stdin /etc/hapi/claude-setup-token.env` (never `sudo tee` — tee cannot set mode and commonly leaves `0644`). **Tooling fix (issue #191 / PR #192):** `install-hapi-systemd-units.sh` (fleet-binary + primary-soup) and `install-hapi-pet.sh` install `42-claude-oauth-token.conf` as a first-class step (`lib/hapi-claude-oauth-dropin.sh`). Legacy `$HAPI_HOME` / `~/.hapi` paths are one-time migrate sources only. `verify-hapi-install.sh` asserts drop-in + EnvironmentFiles + `/proc` load + runner-vs-child split-brain (run as root on system scope). `soup-redeploy.yml` plants a probe token under `/etc/hapi` and asserts the runner loaded it; live Anthropic spawn runs when `secrets.CLAUDE_CODE_OAUTH_TOKEN` is set | --- @@ -141,7 +142,7 @@ Operator said "Upgrade" after §4a's investigate-only fact-find. Proceeded with - `/health` `protocolVersion` unchanged (`1`) - `verify-hapi-install.sh`: 16 pass, 0 fail, 0 inconclusive -**A claimed finding here was retracted the same night — recorded because the retraction is the more valuable lesson.** The absence of `~/.claude/.credentials.json` for the `hapi` service account was initially read as "no recovery path if any of the six live sessions dies." That's wrong, and it's wrong by design, not by luck: HAPI does not authenticate Claude agents from a credentials file on the service account at all. `cli/src/runner/run.ts:693,709-712` injects a per-spawn `CLAUDE_CODE_OAUTH_TOKEN` into the child's environment from `options.token`, supplied by the spawn request itself — the service account is never meant to hold a credential file, so its absence is expected, not a gap. All three tests that produced the original finding (`hapi spawn-peer` invoked by the investigating agent rather than Doug, `claude --print` bypassing HAPI entirely, `claude --print --resume ` — same raw-`claude` path, `--resume` only changes which transcript loads, not how auth is obtained) shared the same flaw: none of them went through the real hub-mediated spawn path as Doug's own client actually would. The decisive test — Doug starting a new session from his own UI — is what actually settles this, not anything an investigating agent can run standalone. +**The same-night retraction was wrong, and Doug's UI test proved it (2026-10-01).** The investigating agent was right that `~/.claude/.credentials.json` is absent on the `hapi` account, and wrong (then) about *why that mattered*. The later retraction was also wrong: UI spawn does **not** supply `options.token`. `hub/src/sync/rpcGateway.ts` `spawnSession` RPC payload has no `token`; `shared/src/apiTypes.ts` `SpawnSessionRequestSchema` has none. `cli/src/runner/run.ts` → `buildSpawnAuthEnv` only sets `CLAUDE_CODE_OAUTH_TOKEN` when the RPC carried a token or `~/.hapi/settings.json` `directoryAuthProfiles` matched. Antevorta had neither. New Claude sessions therefore inherit whatever is in the **runner systemd environment**. Tonight's upgrade restarted the runner as a clean unit (`10-resilience` + `90-oom` only). The previous runner process still had Doug's 108-char `setup-token` in its environ (12 live `hapi`/`claude` children still carried the same hash); the new runner did not. Result: existing `--resume` sessions kept working; Doug's brand-new UI session `test` (and two other post-restart spawns) had `oauth=False` and showed `Not logged in · Please run /login`. oos-linux works because `hapi-runner-oos.service.d/42-claude-oauth-token.conf` loads `~/.hapi/claude-setup-token.env` — and that drop-in was **hand-installed on 2026-08-25**, not produced by pet or fleet installers (confirmed via file birth time + grep of install scripts). Fix applied 2026-10-01: copied the live-child token onto `/var/lib/hapi/.hapi/claude-setup-token.env` (`0600` `hapi`), installed the same drop-in, restarted **runner only** (`KillMode=process`; four `--resume` wrappers kept their PIDs), `claude -p` smoke returned `ok-av`; later hub-mediated fresh spawn `6ba307a2-…` replied `peer-auth-ok` with no `/login`. Binary/version was not the bug (`protocolVersion` unchanged; token path identical across `cd7d515`→`17908b4`). The 09-13 "oauth login survived" note was about the old runner process still being the same login-bearing process, not about systemd persisting the token. **Follow-up #191:** installers + verify + soup-redeploy now treat the drop-in as first-class so this cannot silently recur on the next fleet bring-up. --- @@ -160,7 +161,7 @@ Operator said "Upgrade" after §4a's investigate-only fact-find. Proceeded with - [ ] Antevorta VM swap rehearsal (full disk reattach to a new VM root) — not started - [ ] Drain-under-load test (upgrade with `WORKING` sessions in flight) — not started, **the real remaining gap on antevorta, now with an active daily user** — and `hapi-restart-hub` (the patient-drain mechanism every other upgrade relies on) is **confirmed absent** on this box (2026-09-30 fact-find, §4a — full filesystem search, not just "not on the fleet-machine list"), so this needs building, not just testing - [x] **Antevorta fact-find (2026-09-30)** — see §4a: version confirmed via sha256↔manifest match (still `cd7d515`), `hapi-restart-hub` confirmed absent, `hapi-hub` confirmed plain `Type=simple` with no drain-aware stop, `hapi-runner` confirmed `KillMode=control-group` (violates standing `tiann/hapi#915` policy — **operator decision: investigate only, no fix applied, none scheduled**), `HAPI_HOME` externalization confirmed genuinely active (growing `hapi.db` + WAL), original `WORKING`-poll method found invalid on re-check (see correction in §4a) -- [x] **Antevorta upgraded, `cd7d515` → `17908b4` (2026-09-30)** — see §4b: Tier-1 hardening applied first (`KillMode=process` gate confirmed effective), real `Text file busy` incident mid-swap (fixed via atomic `mv`, two new hard-constraints rows added to §1), six live sessions confirmed uninterrupted via PID/uptime evidence across the whole operation, full verification clean (sha256, `MainPID`↔`runner.state.json`, live `oom_score_adj` on both processes, `cliApiToken`, `protocolVersion`, `verify-hapi-install.sh` 16/0/0). Drain-under-load caveat from the fact-find above is now addressed for this upgrade specifically. A same-night credential-gap claim was raised and retracted — see §4b; auth is per-spawn-token, not a service-account credential file, so its absence was never the gap it looked like +- [x] **Antevorta upgraded, `cd7d515` → `17908b4` (2026-09-30)** — see §4b: Tier-1 hardening + binary swap verified (sha256, MainPID, oom, `cliApiToken`, `protocolVersion`). Live `--resume` sessions survived. **Correction 2026-10-01:** the same-night "missing credentials.json is fine, UI injects `options.token`" retraction was false — see §4b and the new §1 hard-constraint. Doug's real new-session UI test failed `/login`. Fixed by restoring runner `EnvironmentFile` from the live-child token (runner-only restart, children kept). `verify-hapi-install.sh` 16/0/0 did not catch this; it now has a runner-vs-child token presence check. - [x] Janus `in-svc-01` in-place upgrade test — **PASS 2026-09-13**, same artifact bump as antevorta (9622798 → cd7d515), same result (state/token survived), see §4. Confirmed untangled from the blocked official IN-zone network rebuild (bridges/CIDR) — that's a separate host-network process, not touched. VM stopped again afterward, not running 24/7 yet. Same drain-under-load caveat as antevorta - [x] **Two-host proof (2026-09-13):** persistent-volume + in-place-upgrade design confirmed on two independent physical hosts (antevorta, janus), not just one - [x] Quiesce resolved 2026-09-11 (Gavin sign-off): reuse `hapi-restart-hub`'s existing patient drain unchanged diff --git a/scripts/install-hapi-pet.sh b/scripts/install-hapi-pet.sh index 69ddcbca49..1768c27664 100755 --- a/scripts/install-hapi-pet.sh +++ b/scripts/install-hapi-pet.sh @@ -65,6 +65,401 @@ done log() { printf '==> %s\n' "$1"; } fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; } +# Prefer the checkout OAuth helpers when present (curl|bash has none). +if [[ -n "${SCRIPT_DIR}" && -f "${SCRIPT_DIR}/tooling/lib/hapi-claude-oauth-dropin.sh" ]]; then + # shellcheck source=tooling/lib/hapi-claude-oauth-dropin.sh + source "${SCRIPT_DIR}/tooling/lib/hapi-claude-oauth-dropin.sh" +fi + +# Embed systemd EnvironmentFile value parser for curl|bash (keep in sync with +# hapi_claude_oauth_parse_env_file_value in lib/hapi-claude-oauth-dropin.sh). +if ! declare -F hapi_claude_oauth_parse_env_file_value >/dev/null 2>&1; then + hapi_claude_oauth_parse_env_file_value() { + local raw="${1-}" + raw="${raw%$'\r'}" + raw="${raw#"${raw%%[![:space:]]*}"}" + raw="${raw%"${raw##*[![:space:]]}"}" + [[ -n "$raw" ]] || return 1 + + local out="" i=0 c nxt state=pre + while (( i < ${#raw} )); do + c="${raw:i:1}" + case "$state" in + pre) + if [[ "$c" =~ [[:space:]] ]]; then + i=$((i + 1)); continue + fi + if [[ "$c" == "'" ]]; then + state=sq; i=$((i + 1)); continue + fi + if [[ "$c" == '"' ]]; then + state=dq; i=$((i + 1)); continue + fi + if [[ "$c" == '\' ]]; then + state=val + if (( i + 1 < ${#raw} )); then + out+="${raw:i+1:1}"; i=$((i + 2)) + else + out+='\'; i=$((i + 1)) + fi + continue + fi + state=val; out+="$c"; i=$((i + 1)); continue + ;; + sq) + if [[ "$c" == "'" ]]; then state=pre; else out+="$c"; fi + i=$((i + 1)); continue + ;; + dq) + if [[ "$c" == '\' && $((i + 1)) -lt ${#raw} ]]; then + nxt="${raw:i+1:1}" + case "$nxt" in + "\\"|'"'|'`'|'$') out+="$nxt" ;; + *) out+="\\$nxt" ;; + esac + i=$((i + 2)); continue + fi + if [[ "$c" == '"' ]]; then + state=pre; i=$((i + 1)); continue + fi + out+="$c"; i=$((i + 1)); continue + ;; + val) + if [[ "$c" == '\' && $((i + 1)) -lt ${#raw} ]]; then + out+="${raw:i+1:1}"; i=$((i + 2)); continue + fi + out+="$c"; i=$((i + 1)); continue + ;; + esac + done + [[ "$state" != sq && "$state" != dq ]] || return 1 + [[ -n "$out" ]] || return 1 + printf '%s' "$out" + return 0 + } +fi +if ! declare -F hapi_claude_oauth_env_line_state >/dev/null 2>&1; then + hapi_claude_oauth_env_line_state() { + local buf="${1-}" + HAPI_EF_QUOTE="" + HAPI_EF_CONT_BS=0 + [[ "$buf" == *"="* ]] || return 0 + local val="${buf#*=}" + val="${val#"${val%%[![:space:]]*}"}" + local i=0 c quote="" pre=1 + while (( i < ${#val} )); do + c="${val:i:1}" + if [[ -z "$quote" ]]; then + if [[ "$pre" -eq 1 ]]; then + if [[ "$c" =~ [[:space:]] ]]; then + i=$((i + 1)); continue + fi + if [[ "$c" == "'" ]]; then + quote="'"; i=$((i + 1)); continue + fi + if [[ "$c" == '"' ]]; then + quote='"'; i=$((i + 1)); continue + fi + if [[ "$c" == '\' ]]; then + pre=0 + if (( i + 1 >= ${#val} )); then + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)); continue + fi + pre=0 + i=$((i + 1)); continue + fi + if [[ "$c" == '\' ]]; then + if (( i + 1 >= ${#val} )); then + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)); continue + fi + i=$((i + 1)); continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$c" == "'" ]]; then + quote=""; pre=1 + fi + i=$((i + 1)); continue + fi + if [[ "$c" == '\' ]]; then + if (( i + 1 >= ${#val} )); then + HAPI_EF_QUOTE='"' + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)); continue + fi + if [[ "$c" == '"' ]]; then + quote=""; pre=1 + fi + i=$((i + 1)) + done + HAPI_EF_QUOTE="$quote" + HAPI_EF_CONT_BS=0 + } +fi +if ! declare -F hapi_claude_oauth_env_line_key >/dev/null 2>&1; then + hapi_claude_oauth_env_line_key() { + local s="${1-}" + HAPI_EF_KEY="" + HAPI_EF_RAW="" + s="${s#"${s%%[![:space:]]*}"}" + case "$s" in + ''|\#*|\;*) return 1 ;; + esac + [[ "$s" == *=* ]] || return 1 + HAPI_EF_KEY="${s%%=*}" + HAPI_EF_KEY="${HAPI_EF_KEY%"${HAPI_EF_KEY##*[![:space:]]}"}" + HAPI_EF_RAW="${s#*=}" + return 0 + } +fi +if ! declare -F hapi_claude_oauth_bash_last_oauth_token >/dev/null 2>&1; then + hapi_claude_oauth_bash_last_oauth_token() { + local token_file="${1:?token_file}" + local line raw last="" parsed logical="" join_nl=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + if [[ -z "$logical" ]]; then + logical="$line" + elif [[ "$join_nl" -eq 1 ]]; then + logical+=$'\n'"$line" + else + logical+="$line" + fi + hapi_claude_oauth_env_line_state "$logical" + if [[ "$HAPI_EF_CONT_BS" -eq 1 && "$logical" == *\\ ]]; then + logical="${logical%\\}" + join_nl=0 + continue + fi + if [[ -n "$HAPI_EF_QUOTE" ]]; then + join_nl=1 + continue + fi + if hapi_claude_oauth_env_line_key "$logical" && [[ "$HAPI_EF_KEY" == "CLAUDE_CODE_OAUTH_TOKEN" ]]; then + raw="$HAPI_EF_RAW" + if parsed="$(hapi_claude_oauth_parse_env_file_value "$raw")"; then + last="$parsed" + else + last="" + fi + fi + logical="" + join_nl=0 + done <"$token_file" + if [[ -n "$logical" ]]; then + if hapi_claude_oauth_env_line_key "$logical" && [[ "$HAPI_EF_KEY" == "CLAUDE_CODE_OAUTH_TOKEN" ]]; then + raw="$HAPI_EF_RAW" + if parsed="$(hapi_claude_oauth_parse_env_file_value "$raw")"; then + last="$parsed" + else + last="" + fi + fi + fi + [[ -n "$last" ]] || return 2 + printf '%s' "$last" + return 0 + } +fi + +# Export CLAUDE_CODE_OAUTH_TOKEN from an EnvironmentFile literally (never `source` +# the file — shell would expand $, backticks, and abort under set -u). +hapi_pet_export_oauth_from_env_file() { + local token_file="${1:?token_file}" + local eff="" + [[ -f "$token_file" && ! -L "$token_file" ]] || return 1 + if declare -F hapi_claude_oauth_effective_token_value >/dev/null 2>&1; then + set +e + eff="$(hapi_claude_oauth_effective_token_value "$token_file")" + local rc=$? + set -e + [[ "$rc" -eq 0 && -n "$eff" ]] || return 1 + else + # Last-assignment parser: quoted newlines + trailing-backslash continuations. + set +e + eff="$(hapi_claude_oauth_bash_last_oauth_token "$token_file")" + local rc=$? + set -e + [[ "$rc" -eq 0 && -n "$eff" ]] || return 1 + fi + export CLAUDE_CODE_OAUTH_TOKEN="$eff" + return 0 +} + +# 0=ambient token, 1=no process/no token, 2=live pid unreadable (fail closed). +hapi_pet_mainpid_oauth() { + local pid="${1:-0}" tok="" line efd + if declare -F hapi_claude_oauth_mainpid_has_oauth_token >/dev/null 2>&1; then + hapi_claude_oauth_mainpid_has_oauth_token "$pid" + return $? + fi + [[ "$pid" =~ ^[0-9]+$ ]] || return 2 + if [[ "$pid" == "0" ]]; then + return 1 + fi + if [[ ! -d "/proc/$pid" ]]; then + return 1 + fi + if [[ ! -r "/proc/$pid/environ" ]]; then + return 2 + fi + exec {efd}<"/proc/$pid/environ" || return 2 + while IFS= read -r -d '' -u "$efd" line || [[ -n "$line" ]]; do + case "$line" in + CLAUDE_CODE_OAUTH_TOKEN=*) tok="${line#CLAUDE_CODE_OAUTH_TOKEN=}" ;; + esac + done + exec {efd}<&- + [[ -n "$tok" ]] +} + +hapi_pet_assert_runner_owned_dir() { + local d="${1:?dir}" + local want="${2:?uid}" + local uid mode + if [[ -L "$d" ]]; then + fail "user token parent is a symlink: $d" + fi + if [[ ! -d "$d" ]]; then + fail "user token parent is not a directory: $d" + fi + uid="$(stat -c '%u' "$d" 2>/dev/null || true)" + mode="$(stat -c '%a' "$d" 2>/dev/null || true)" + if [[ "$uid" != "$want" ]]; then + fail "user token parent $d is owned by uid ${uid:-unknown} (must be runner uid $want)" + fi + if [[ -z "$mode" ]] || (( (8#$mode & 022) != 0 )); then + fail "user token parent $d is group/other-writable (mode ${mode:-unknown})" + fi +} + +hapi_pet_assert_user_token_parents() { + local token_file="${1:?token_file}" + local want_uid="${2:-$(id -u)}" + local home="${HOME:-}" + local cur nxt uid mode + if declare -F hapi_claude_oauth_assert_user_token_parents >/dev/null 2>&1; then + hapi_claude_oauth_assert_user_token_parents "$token_file" "$want_uid" \ + || fail "user token parent is not runner-owned / is group-or-other-writable" + return 0 + fi + cur="$(dirname -- "$token_file")" + hapi_pet_assert_runner_owned_dir "$cur" "$want_uid" + while true; do + nxt="$(dirname -- "$cur")" + if [[ "$nxt" == "/" || "$nxt" == "/tmp" || "$nxt" == "/var/tmp" ]]; then + return 0 + fi + if [[ -n "$home" && "$nxt" == "$home" ]]; then + hapi_pet_assert_runner_owned_dir "$nxt" "$want_uid" + return 0 + fi + if [[ -L "$nxt" ]]; then + fail "user token ancestor is a symlink: $nxt" + fi + if [[ ! -d "$nxt" ]]; then + fail "user token ancestor is not a directory: $nxt" + fi + uid="$(stat -c '%u' "$nxt" 2>/dev/null || true)" + mode="$(stat -c '%a' "$nxt" 2>/dev/null || true)" + if [[ "$uid" != "$want_uid" && "$uid" != "0" ]]; then + fail "user token ancestor $nxt is owned by uid ${uid:-unknown} (must be runner uid $want_uid or root)" + fi + if [[ -z "$mode" ]] || (( (8#$mode & 022) != 0 )); then + fail "user token ancestor $nxt is group/other-writable (mode ${mode:-unknown})" + fi + cur="$nxt" + done +} + +# Migrate ${HAPI_HOME}/.hapi/claude-setup-token.env → canonical before the +# embedded drop-in points EnvironmentFile at the (otherwise missing) canon. +# Companion path uses hapi_install_claude_oauth_dropin; curl|bash must do this +# itself or upgrades wipe ambient auth on the next runner restart. +hapi_pet_migrate_legacy_oauth_if_needed() { + local token_file="${1:?token_file}" + local hapi_home legacy_token dest tmp canon_ok=0 + hapi_home="$(dirname "$token_file")" + legacy_token="${hapi_home}/.hapi/claude-setup-token.env" + + # Subshell: export helper must not pollute the installer environment. + if ( hapi_pet_export_oauth_from_env_file "$token_file" >/dev/null 2>&1 ); then + canon_ok=1 + fi + [[ -e "$legacy_token" || -L "$legacy_token" ]] || return 0 + + if [[ -L "$legacy_token" ]]; then + fail "refusing symlink legacy token: $legacy_token (write a regular 0600 file)" + fi + if [[ ! -f "$legacy_token" ]]; then + fail "refusing non-regular legacy token: $legacy_token (write a regular 0600 file)" + fi + + # Canon already effective: still retire leftover legacy (retry retirement) so a + # later empty/missing canon cannot re-import a revoked credential. + if [[ "$canon_ok" -eq 1 ]]; then + if declare -F hapi_claude_oauth_retire_legacy_token_source >/dev/null 2>&1; then + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" \ + || fail "could not retire leftover legacy Claude OAuth token: $legacy_token" + else + dest="${legacy_token}.migrated.$(date +%s)" + mv -n -- "$legacy_token" "$dest" \ + || fail "could not retire leftover legacy Claude OAuth token: $legacy_token" + /bin/chmod 600 "$dest" \ + || fail "could not chmod 600 retired leftover legacy token: $dest" + log "Retired legacy Claude OAuth token: $legacy_token -> $dest" + fi + return 0 + fi + + if declare -F hapi_claude_oauth_secure_copy_regular_file >/dev/null 2>&1; then + hapi_claude_oauth_secure_copy_regular_file "$legacy_token" "$token_file" \ + || fail "could not migrate legacy Claude OAuth token: $legacy_token -> $token_file" + log "Migrated Claude OAuth token: $legacy_token -> $token_file" + if declare -F hapi_claude_oauth_retire_legacy_token_source >/dev/null 2>&1; then + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" \ + || fail "migrated but could not retire legacy source: $legacy_token" + fi + return 0 + fi + + # curl|bash (no checkout helpers): bash copy under $HAPI_HOME only — never /etc. + /bin/mkdir -p "$hapi_home" || fail "mkdir $hapi_home failed during legacy OAuth migrate" + if [[ -L "$token_file" ]]; then + fail "refusing to overwrite symlink canonical token: $token_file" + fi + tmp="$(/usr/bin/mktemp "${hapi_home}/.claude-oauth-copy.XXXXXX")" \ + || fail "mktemp failed during legacy OAuth migrate" + if ! /bin/cp -f -- "$legacy_token" "$tmp"; then + /bin/rm -f -- "$tmp" + fail "could not copy legacy Claude OAuth token: $legacy_token" + fi + if [[ -L "$tmp" ]]; then + /bin/rm -f -- "$tmp" + fail "temp copy became a symlink during legacy OAuth migrate: $tmp" + fi + /bin/chmod 600 "$tmp" || { /bin/rm -f -- "$tmp"; fail "chmod 600 failed on migrate temp"; } + if ! /bin/mv -f -- "$tmp" "$token_file"; then + /bin/rm -f -- "$tmp" + fail "could not install migrated Claude OAuth token at $token_file" + fi + log "Migrated Claude OAuth token: $legacy_token -> $token_file" + dest="${legacy_token}.migrated.$(date +%s)" + mv -n -- "$legacy_token" "$dest" \ + || fail "migrated but could not retire legacy source: $legacy_token" + /bin/chmod 600 "$dest" \ + || fail "could not chmod 600 retired legacy token: $dest" + log "Retired legacy Claude OAuth token: $legacy_token -> $dest" + return 0 +} + # User-level systemd units for pet installs. Embedded so curl|bash works without a # repo checkout. Keep in sync with scripts/tooling/systemd/units/user-pet/*.in — # the checkout path below prefers the companion installer when present. @@ -139,12 +534,152 @@ RestartSec=5 WantedBy=default.target EOF + # Claude OAuth EnvironmentFile drop-in (same shape as fleet/oos). Leading + # '-' means the unit still starts before the operator mints a setup-token. + local token_file="${HAPI_HOME}/claude-setup-token.env" + mkdir -p "$unit_dir/hapi-runner.service.d" + if [[ ! -d "$HAPI_HOME" ]]; then + mkdir -m 0700 -p "$HAPI_HOME" || fail "cannot create $HAPI_HOME" + fi + hapi_pet_assert_user_token_parents "$token_file" + # Migrate legacy nohup path BEFORE writing the drop-in / restarting — otherwise + # EnvironmentFile points at a missing canon and the runner loses ambient auth. + hapi_pet_migrate_legacy_oauth_if_needed "$token_file" + local dropin="$unit_dir/hapi-runner.service.d/42-claude-oauth-token.conf" + if [[ -L "$dropin" ]]; then + fail "refusing symlink drop-in: $dropin" + fi + if [[ -e "$dropin" && ! -f "$dropin" ]]; then + fail "refusing non-regular drop-in: $dropin" + fi + local dropin_tmp + dropin_tmp="$(mktemp "$unit_dir/hapi-runner.service.d/.42-claude-oauth-token.conf.XXXXXX")" \ + || fail "cannot create temp drop-in" + cat >"$dropin_tmp" < $token_file" + + # Tighten an existing token even when the setup banner is skipped (curl/embedded + # path does not go through hapi_install_claude_oauth_dropin). + if [[ -L "$token_file" ]]; then + fail "refusing symlink token file: $token_file (write a regular 0600 file)" + elif [[ -e "$token_file" && ! -f "$token_file" ]]; then + fail "refusing non-regular token file: $token_file (write a regular 0600 file)" + elif [[ -f "$token_file" ]]; then + chmod 600 "$token_file" + fi + systemctl --user daemon-reload loginctl enable-linger "$(id -un)" 2>/dev/null || true systemctl --user enable hapi-hub.service hapi-runner.service - systemctl --user start hapi-hub.service hapi-runner.service + # Always restart hub+runner AFTER units/drop-in are written. On upgrades + # section 4's pgrep kill + Restart=always can respawn the *old* binary + # before INSTALL_DIR is swapped; systemctl start is then a no-op for the + # already-active unit and the hub keeps running the deleted executable + # while only the runner would get the new binary. + # Refuse runner restart when MainPID still holds ambient-only auth. + if ! ( hapi_pet_export_oauth_from_env_file "$token_file" >/dev/null 2>&1 ); then + local _rp _orc + _rp="$(systemctl --user show -p MainPID --value hapi-runner.service 2>/dev/null || echo 0)" + set +e + hapi_pet_mainpid_oauth "$_rp" + _orc=$? + set -e + case "$_orc" in + 0) + fail "user-pet runner MainPID=$_rp has ambient CLAUDE_CODE_OAUTH_TOKEN but $token_file is missing/empty — persist the token before restart" + ;; + 2) + fail "cannot inspect user-pet runner MainPID=$_rp environ for ambient OAuth — refusing restart" + ;; + esac + fi + if declare -F hapi_claude_oauth_merged_env_discards_token >/dev/null 2>&1; then + if hapi_claude_oauth_merged_env_discards_token hapi-runner.service "$token_file" --user; then + fail "merged user hapi-runner.service environment would override or unset CLAUDE_CODE_OAUTH_TOKEN — refusing restart" + fi + else + local _ue _ef _saw=0 _p _ign _miss _grc + _ue="$(systemctl --user show hapi-runner.service -p UnsetEnvironment --value 2>/dev/null || true)" + if [[ "$_ue" =~ (^|[[:space:]])CLAUDE_CODE_OAUTH_TOKEN(=|[[:space:]]|$) ]]; then + fail "merged user hapi-runner.service UnsetEnvironment removes CLAUDE_CODE_OAUTH_TOKEN — refusing restart" + fi + _ef="$(systemctl --user show hapi-runner.service -p EnvironmentFiles --value 2>/dev/null || true)" + while IFS=$'\t' read -r _p _ign || [[ -n "${_p-}" ]]; do + [[ -n "$_p" ]] || continue + _miss=0 + [[ -e "$_p" || -L "$_p" ]] || _miss=1 + if [[ "$_miss" -eq 1 && "$_ign" == "no" ]]; then + fail "required EnvironmentFile is missing ($_p) — refusing restart" + fi + if [[ "$_miss" -eq 1 ]]; then + [[ "$_p" == "$token_file" ]] && _saw=1 + continue + fi + if [[ "$_ign" == "no" ]]; then + if [[ ! -f "$_p" ]]; then + fail "required EnvironmentFile is not a regular file ($_p) — refusing restart" + fi + if [[ ! -r "$_p" ]]; then + fail "required EnvironmentFile is unreadable ($_p) — refusing restart" + fi + fi + if [[ "$_p" == "$token_file" ]]; then + _saw=1 + continue + fi + [[ "$_saw" -eq 1 ]] || continue + if [[ -L "$_p" ]]; then + fail "later EnvironmentFile is a symlink ($_p) — refusing restart" + fi + if [[ -f "$_p" ]]; then + if [[ ! -r "$_p" ]]; then + fail "later EnvironmentFile is unreadable ($_p) — refusing restart" + fi + _grc=0 + set +e + grep -qE '^[[:space:]]*CLAUDE_CODE_OAUTH_TOKEN[[:space:]]*=' "$_p" + _grc=$? + set -e + if [[ "$_grc" -eq 0 ]]; then + fail "later EnvironmentFile $_p overrides CLAUDE_CODE_OAUTH_TOKEN — refusing restart" + fi + if [[ "$_grc" -ne 1 ]]; then + fail "later EnvironmentFile is unreadable ($_p) — refusing restart" + fi + fi + done < <(hapi_claude_oauth_iter_environment_files "$_ef" 2>/dev/null || printf '%s' "$_ef" | sed -E 's/ \(ignore_errors=(yes|no)\)/\t\1\n/g') + if [[ "$_saw" -ne 1 ]]; then + fail "canonical EnvironmentFile $token_file is not in merged user hapi-runner.service EnvironmentFiles — refusing restart" + fi + fi + systemctl --user restart hapi-hub.service + systemctl --user restart hapi-runner.service log "Installed: $unit_dir/hapi-hub.service" log "Installed: $unit_dir/hapi-runner.service" + # Last-assignment-wins (systemd EnvironmentFile): reuse the normalized + # parser so padded keys / empty finals match what the runner loads. + local token_configured=0 + if ( hapi_pet_export_oauth_from_env_file "$token_file" >/dev/null 2>&1 ); then + token_configured=1 + fi + if [[ "$token_configured" -eq 0 ]]; then + cat < Claude OAuth for runner-spawned sessions is NOT configured yet. + New UI sessions will print "Not logged in · Please run /login" until you: + claude setup-token + umask 077 + printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\\n' '' > '$token_file' + chmod 600 '$token_file' + systemctl --user restart hapi-runner.service + Existing --resume sessions can keep working and hide this gap - do not skip it. +EOF + fi } # --- 1. Architecture detection --- @@ -181,6 +716,110 @@ chmod +x "$TMP_BIN" "$TMP_BIN" --version >/dev/null 2>&1 || fail "fetched file at $TMP_BIN does not run (--version failed) — corrupt download or wrong architecture, nothing has been touched" log "New binary fetched and verified runnable." +# --- 3b. Preflight Claude OAuth token shape BEFORE stopping anything. +# Applies to BOTH --with-systemd and nohup upgrades: a symlink/non-regular +# canonical (or legacy) token must fail here — not after section 4 killed the +# old authenticated runner and left a replacement without CLAUDE_CODE_OAUTH_TOKEN. +# Also migrate legacy → canonical here so nohup launch (canon-only) keeps auth. +# Reject symlink *ancestors* (e.g. .hapi → elsewhere): final-node -f follows +# them and would greenlight a path migrate later refuses. +_preflight_token_ancestors() { + local path="$1" + if declare -F hapi_claude_oauth_assert_no_symlink_ancestors >/dev/null 2>&1; then + hapi_claude_oauth_assert_no_symlink_ancestors "$path" \ + || fail "refusing symlink ancestor in token path: $path — refusing before stop so the old install stays up" + return 0 + fi + local cur="" part + local abs="$path" + if [[ "$abs" != /* ]]; then + abs="$(pwd)/$abs" + fi + cur="" + IFS=/ read -r -a _pre_parts <<<"${abs#/}" || true + for part in "${_pre_parts[@]}"; do + [[ -n "$part" ]] || continue + cur="${cur}/${part}" + if [[ -L "$cur" ]]; then + unset _pre_parts + fail "refusing symlink path component: $cur (in $path) — refusing before stop so the old install stays up" + fi + done + unset _pre_parts +} +_preflight_token_shape() { + local path="$1" + # Walk ancestors even when the leaf is absent — a symlinked .hapi dir + # still breaks migrate after we would have stopped the old install. + _preflight_token_ancestors "$path" + if [[ -L "$path" ]]; then + fail "refusing symlink token file: $path (write a regular 0600 file) — refusing before stop so the old install stays up" + elif [[ -e "$path" && ! -f "$path" ]]; then + fail "refusing non-regular token file: $path (write a regular 0600 file) — refusing before stop so the old install stays up" + fi +} +# Canonical + legacy shape for both nohup and systemd — nohup launch only loads +# canon, so a bad legacy node must fail here before migrate/stop, not after. +_preflight_token_shape "${HAPI_HOME}/claude-setup-token.env" +_preflight_token_shape "${HAPI_HOME}/.hapi/claude-setup-token.env" +# Embedded pet path (curl|bash, no companion lib) must validate parents too — +# leaf 0600 alone is not enough when HAPI_HOME is group/other-writable. +if [[ -d "$HAPI_HOME" ]]; then + hapi_pet_assert_user_token_parents "${HAPI_HOME}/claude-setup-token.env" \ + || fail "user token parent $HAPI_HOME is not runner-owned / is group-or-other-writable — refusing before stop" +fi +# Promote legacy → canonical BEFORE stop. Systemd drop-in and nohup launch both +# read only ${HAPI_HOME}/claude-setup-token.env; treating legacy as "durable" +# without this migrate lets stop succeed then start an unauthenticated runner. +hapi_pet_migrate_legacy_oauth_if_needed "${HAPI_HOME}/claude-setup-token.env" + +# Tighten canon mode/owner while the old runner is still alive. A root-owned +# 0644 file from `sudo tee` used to pass preflight then die on chmod after stop. +_pre_canon="${HAPI_HOME}/claude-setup-token.env" +if [[ -f "$_pre_canon" && ! -L "$_pre_canon" ]]; then + if ! /bin/chmod 600 "$_pre_canon" 2>/dev/null; then + fail "cannot chmod 600 $_pre_canon before stop (got $(stat -c '%a uid=%u' "$_pre_canon" 2>/dev/null || echo unreadable)) — old runner left running" + fi + _pre_mode="$(stat -c '%a' "$_pre_canon" 2>/dev/null || true)" + _pre_uid="$(stat -c '%u' "$_pre_canon" 2>/dev/null || true)" + if [[ "$_pre_mode" != "600" || "$_pre_uid" != "$(id -u)" ]]; then + fail "token $_pre_canon must be owned by $(id -un) mode 0600 before stop (got mode ${_pre_mode:-unknown} uid ${_pre_uid:-unknown}) — old runner left running" + fi +fi + +# Ambient-only guard: if no durable token file exists but a live runner still +# carries CLAUDE_CODE_OAUTH_TOKEN, refuse to stop — restart would discard it. +_pre_canon="${HAPI_HOME}/claude-setup-token.env" +_pre_legacy="${HAPI_HOME}/.hapi/claude-setup-token.env" +_pre_durable=0 +if ( hapi_pet_export_oauth_from_env_file "$_pre_canon" >/dev/null 2>&1 ); then + _pre_durable=1 +elif ( hapi_pet_export_oauth_from_env_file "$_pre_legacy" >/dev/null 2>&1 ); then + # Migrate should have cleared this; keep as failsafe so we still refuse stop. + _pre_durable=1 +fi +if [[ "$_pre_durable" -eq 0 ]]; then + mapfile -t _pre_pids < <(pgrep -u "$(id -un)" -f 'hapi (hub|runner)' 2>/dev/null || true) + for _pre_pid in "${_pre_pids[@]:-}"; do + [[ -n "$_pre_pid" ]] || continue + set +e + hapi_pet_mainpid_oauth "$_pre_pid" + _pre_orc=$? + set -e + case "$_pre_orc" in + 0) + fail "runner pid=$_pre_pid has ambient CLAUDE_CODE_OAUTH_TOKEN but no durable token at $_pre_canon (or legacy $_pre_legacy) — refuse stop/restart; persist the token first" + ;; + 2) + fail "cannot inspect pid=$_pre_pid environ for ambient OAuth — refuse stop/restart" + ;; + esac + done + unset _pre_pids _pre_pid _pre_orc +fi +unset -f _preflight_token_shape _preflight_token_ancestors +unset _pre_canon _pre_legacy _pre_durable + # --- 4. Stop an already-running hub/runner, if any (upgrade path; no-op on fresh # install). Only reached after step 3's new binary is confirmed good. Waits for # the old process(es) to actually exit (not a blind sleep) before proceeding, so @@ -259,9 +898,21 @@ else # Runner, restricted to the workspace dir created above — without --workspace-root the # runner starts in "legacy mode" with no directory restriction, which defeats the point # of having a dedicated workspace dir at all. - nohup "$INSTALL_DIR/hapi" runner start-sync --workspace-root "$HAPI_WORKSPACE" \ - > "$HAPI_HOME/logs/runner.log" 2>&1 & - RUNNER_PID=$! + # Load Claude OAuth literally from EnvironmentFile (do NOT source the file). + # Legacy was migrated to canon in §3b; re-run migrate here so a mid-script + # drop of only `.hapi/...` still reaches the nohup runner env. + hapi_pet_migrate_legacy_oauth_if_needed "${HAPI_HOME}/claude-setup-token.env" + ( + unset CLAUDE_CODE_OAUTH_TOKEN + if [[ -f "$HAPI_HOME/claude-setup-token.env" && ! -L "$HAPI_HOME/claude-setup-token.env" ]]; then + chmod 600 "$HAPI_HOME/claude-setup-token.env" + hapi_pet_export_oauth_from_env_file "$HAPI_HOME/claude-setup-token.env" || true + fi + nohup "$INSTALL_DIR/hapi" runner start-sync --workspace-root "$HAPI_WORKSPACE" \ + > "$HAPI_HOME/logs/runner.log" 2>&1 & + echo $! > "$HAPI_HOME/runner.pid" + ) + RUNNER_PID="$(cat "$HAPI_HOME/runner.pid" 2>/dev/null || true)" sleep 2 if ! kill -0 "$RUNNER_PID" 2>/dev/null; then fail "runner exited immediately — check $HAPI_HOME/logs/runner.log" @@ -294,10 +945,12 @@ if ! command -v claude >/dev/null 2>&1; then rm -f /tmp/hapi-pet-npm-err.log fi +q_token_file="$(printf '%q' "${HAPI_HOME}/claude-setup-token.env")" +q_hapi_bin="$(printf '%q' "${INSTALL_DIR}/hapi")" +q_workspace="$(printf '%q' "${HAPI_WORKSPACE}")" cat < Install complete. Everything is set up EXCEPT Claude Code authentication — - that needs a real interactive login or account token, done deliberately by hand. +==> Install complete. Claude Code authentication is still a deliberate one-time step. IMPORTANT — open a NEW terminal (or run 'source ~/.bashrc') before the commands below. This script ran in its own subprocess; the PATH/nvm changes it made @@ -306,15 +959,33 @@ cat <' > ${q_token_file} + chmod 600 ${q_token_file} + Then reload the runner so it picks up the token: + # if you used --with-systemd: + systemctl --user restart hapi-runner.service + # if you did NOT (nohup path): stop+wait+restart with the token exported + # literally (do NOT `source` the env file — shell expands $, backticks, etc). + # Prefer 'hapi runner start' (stops the old runner and waits) over a raw + # kill + start-sync race that can leave you with no runner at all. + export CLAUDE_CODE_OAUTH_TOKEN='' + ${q_hapi_bin} runner start --workspace-root ${q_workspace} + + After that, confirm end-to-end: hapi --print "hello" "Claude Code CLI not found on PATH" -> either you're still in the old shell (see above), or install genuinely didn't complete — re-check the log above - "Not logged in - Please run /login" -> installed fine, just do the auth step + "Not logged in - Please run /login" -> runner ambient token missing/stale — + redo the setup-token steps above, then + restart the runner (systemd or nohup) EOF diff --git a/scripts/tooling/hapi-claude-account-toggle.sh b/scripts/tooling/hapi-claude-account-toggle.sh index 362e474c49..921a25ab86 100755 --- a/scripts/tooling/hapi-claude-account-toggle.sh +++ b/scripts/tooling/hapi-claude-account-toggle.sh @@ -2,10 +2,10 @@ # hapi-claude-account-toggle.sh — switch oos-linux Claude auth slot (primary | secondary). # # Copies both OAuth surfaces for the slot: -# ~/.claude/.credentials.json (interactive claude) -# ~/.hapi/claude-setup-token.env (systemd → hapi-runner-oos CLAUDE_CODE_OAUTH_TOKEN) +# ~/.claude/.credentials.json (interactive claude) +# /etc/hapi/claude-setup-token.env (systemd → hapi-runner-oos CLAUDE_CODE_OAUTH_TOKEN) # -# Slot storage: +# Slot storage (still under operator home): # ~/.hapi/claude-auth/{primary,secondary}/.credentials.json # ~/.hapi/claude-auth/{primary,secondary}/claude-setup-token.env # ~/.hapi/claude-auth/active @@ -30,33 +30,249 @@ for arg in "${@:2}"; do esac done +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=lib/hapi-claude-oauth-dropin.sh +source "$SCRIPT_DIR/lib/hapi-claude-oauth-dropin.sh" + ROOT="$HOME/.hapi/claude-auth" CRED="$ROOT/$SLOT/.credentials.json" TOKEN="$ROOT/$SLOT/claude-setup-token.env" +# Canonical EnvironmentFile for hapi-runner-oos (root-controlled; not ~/.hapi/). +CANON="$(hapi_claude_oauth_system_token_file)" +# Reject symlink slot sources before any CANON mutation. [[ -f ]] and JSON +# validation follow the link, but later `cp -a` preserves it as the active +# credential — every subsequent toggle then refuses the installed symlink. +if [[ -L "$CRED" ]]; then + echo "ERROR: refusing symlink slot credentials: $CRED" >&2 + echo " Replace with a regular file, then re-run toggle." >&2 + exit 1 +fi +if [[ -e "$CRED" && ! -f "$CRED" ]]; then + echo "ERROR: refusing non-regular slot credentials: $CRED" >&2 + exit 1 +fi [[ -f "$CRED" ]] || { echo "missing $CRED" >&2; exit 1; } [[ -f "$TOKEN" ]] || { echo "missing $TOKEN" >&2; exit 1; } -grep -q '^CLAUDE_CODE_OAUTH_TOKEN=' "$TOKEN" || { echo "no CLAUDE_CODE_OAUTH_TOKEN in $TOKEN" >&2; exit 1; } +hapi_claude_oauth_assert_safe_token_file "$TOKEN" || exit 1 + +# Validate slot credentials JSON BEFORE any backup or auth-surface mutation +# (malformed JSON must not leave active/credentials/canon half-switched). +TIER="$(python3 - "$CRED" <<'PY' +import json, sys +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) +except OSError as exc: + sys.stderr.write("ERROR: cannot read slot credentials %s: %s\n" % (path, exc)) + sys.exit(1) +except json.JSONDecodeError as exc: + sys.stderr.write("ERROR: invalid JSON in slot credentials %s: %s\n" % (path, exc)) + sys.exit(1) +if not isinstance(data, dict): + sys.stderr.write("ERROR: slot credentials must be a JSON object: %s\n" % path) + sys.exit(1) +oauth = data.get("claudeAiOauth") +if oauth is not None and not isinstance(oauth, dict): + sys.stderr.write("ERROR: claudeAiOauth must be an object in %s\n" % path) + sys.exit(1) +print((oauth or {}).get("rateLimitTier", "?")) +PY +)" || exit 1 + +# systemd last-assignment-wins - require the *final* value to be nonempty. +set +e +EFFECTIVE="$(hapi_claude_oauth_effective_token_value "$TOKEN")" +eff_rc=$? +set -e +if [[ "$eff_rc" -ne 0 || -z "$EFFECTIVE" ]]; then + echo "no nonempty final CLAUDE_CODE_OAUTH_TOKEN= assignment in $TOKEN" >&2 + exit 1 +fi + +if hapi_claude_oauth_merged_env_discards_token hapi-runner-oos.service; then + echo "ERROR: merged hapi-runner-oos.service environment would override or unset CLAUDE_CODE_OAUTH_TOKEN" >&2 + echo " Fix later EnvironmentFiles / UnsetEnvironment before toggling." >&2 + exit 1 +fi TS="$(date -u +%Y%m%d%H%M%S)" mkdir -p "$HOME/.claude" "$HOME/.hapi" "$ROOT/auth-bak" -for f in "$HOME/.claude/.credentials.json" "$HOME/.hapi/claude-setup-token.env"; do - [[ -f "$f" ]] && cp -f "$f" "$ROOT/auth-bak/$(basename "$f").bak-toggle-$TS" -done - -cp -a "$CRED" "$HOME/.claude/.credentials.json" -chmod 600 "$HOME/.claude/.credentials.json" -cp -a "$TOKEN" "$HOME/.hapi/claude-setup-token.env" -chmod 600 "$HOME/.hapi/claude-setup-token.env" -echo "$SLOT" > "$ROOT/active" +prev_cred="$ROOT/auth-bak/.credentials.json.bak-toggle-$TS" +cred_existed_before=0 +# Reject non-regular active credentials before any CANON mutation — GNU cp -a +# follows a destination symlink (overwrites its target) and copies *into* a +# directory node, so toggle would switch CANON while Claude still has no file +# and rollback would not restore a usable credentials path. +if [[ -e "$HOME/.claude/.credentials.json" || -L "$HOME/.claude/.credentials.json" ]]; then + if [[ -L "$HOME/.claude/.credentials.json" ]]; then + echo "ERROR: refusing symlink interactive credentials: $HOME/.claude/.credentials.json" >&2 + echo " Replace with a regular file (or remove the symlink), then re-run toggle." >&2 + exit 1 + fi + if [[ ! -f "$HOME/.claude/.credentials.json" ]]; then + echo "ERROR: refusing non-regular interactive credentials: $HOME/.claude/.credentials.json" >&2 + echo " Replace with a regular file, then re-run toggle." >&2 + exit 1 + fi + cred_existed_before=1 + cp -f "$HOME/.claude/.credentials.json" "$prev_cred" +fi -TIER="$(python3 - <&2 + exit 1 +fi +if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + hapi_claude_oauth_secure_copy_regular_file "$TOKEN" "$CANON" +else + # Unprivileged O_NOFOLLOW cat → privileged install via absolute python3. + # Never PATH-resolve bash under sudo or source this checkout as root. + # install_bytes refuses empty/ineffective payload before os.replace. + hapi_claude_oauth_cat_regular_file "$TOKEN" \ + | hapi_claude_oauth_install_bytes_via_sudo "$CANON" +fi + +# Restore CANON from pre-toggle backup if interactive credentials cannot be +# committed (avoids runner/interactive account split after canonical-first write). +# If CANON was newly created this run (no prior file / no backup), unlink it. +# Restore uses restore_bytes (not install_bytes) so empty prior tokens can return. +hapi_claude_oauth_rollback_canon_from_bak() { + local why="${1:-credentials update failed}" + echo "ERROR: $why - rolling back canonical token at $CANON" >&2 + if [[ -f "$bak" && ! -L "$bak" ]]; then + if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + hapi_claude_oauth_secure_copy_regular_file "$bak" "$CANON" || { + echo "ERROR: failed to restore $CANON from $bak" >&2 + return 1 + } + else + hapi_claude_oauth_cat_regular_file "$bak" \ + | hapi_claude_oauth_restore_bytes_via_sudo "$CANON" || { + echo "ERROR: failed to restore $CANON from $bak (sudo)" >&2 + return 1 + } + fi + echo "Restored canonical token from $bak" + return 0 + fi + if [[ "$canon_existed_before" -eq 0 ]]; then + if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + hapi_claude_oauth_secure_unlink_regular_file "$CANON" || { + echo "ERROR: failed to remove newly created $CANON" >&2 + return 1 + } + else + hapi_claude_oauth_secure_unlink_regular_file_via_sudo "$CANON" || { + echo "ERROR: failed to remove newly created $CANON (sudo)" >&2 + return 1 + } + fi + echo "Removed newly created canonical token $CANON (no prior backup)" + return 0 + fi + echo "WARN: no prior canonical backup at $bak and CANON existed before toggle - cannot safely roll back $CANON" >&2 + return 1 +} + +# Restore interactive credentials after a failed copy/chmod/active write. +hapi_claude_oauth_rollback_credentials() { + if [[ -f "$prev_cred" && ! -L "$prev_cred" ]]; then + if ! cp -a "$prev_cred" "$HOME/.claude/.credentials.json"; then + echo "ERROR: failed to restore interactive credentials from $prev_cred" >&2 + return 1 + fi + chmod 600 "$HOME/.claude/.credentials.json" 2>/dev/null || true + echo "Restored interactive credentials from $prev_cred" + return 0 + fi + if [[ "$cred_existed_before" -eq 0 ]]; then + rm -f "$HOME/.claude/.credentials.json" + echo "Removed partial interactive credentials (none existed before toggle)" + return 0 + fi + echo "WARN: no prior credentials backup at $prev_cred - cannot restore interactive credentials" >&2 + return 1 +} + +# Only after canonical token is installed: switch interactive credentials. +# Capture both rollback statuses independently — canon rollback must not +# prevent credentials rollback under set -e. +if ! cp -a "$CRED" "$HOME/.claude/.credentials.json"; then + set +e + hapi_claude_oauth_rollback_canon_from_bak "cannot replace $HOME/.claude/.credentials.json" + hapi_claude_oauth_rollback_credentials + set -e + exit 1 +fi +if ! chmod 600 "$HOME/.claude/.credentials.json"; then + set +e + hapi_claude_oauth_rollback_canon_from_bak "cannot chmod 600 $HOME/.claude/.credentials.json" + hapi_claude_oauth_rollback_credentials + set -e + exit 1 +fi +if ! echo "$SLOT" > "$ROOT/active"; then + set +e + hapi_claude_oauth_rollback_canon_from_bak "cannot write $ROOT/active" + hapi_claude_oauth_rollback_credentials + set -e + exit 1 +fi + +SHA12="$(printf '%s' "$EFFECTIVE" | sha256sum | cut -c1-12)" +echo "== claude auth → slot '$SLOT' tier=$TIER token_sha12=$SHA12 canon=$CANON ==" if [[ "$SKIP_RESTART" -eq 0 ]]; then HAPI_OPERATOR_SYSTEMCTL_OVERRIDE=1 HAPI_OPERATOR_PRODUCTION_MUTATION_OVERRIDE=1 \ @@ -66,7 +282,7 @@ if [[ "$SKIP_RESTART" -eq 0 ]]; then fi if [[ "$SKIP_SMOKE" -eq 0 ]]; then - echo "-- smoke (oos, credentials.json only; runner uses setup-token) --" + echo "-- smoke (oos, credentials.json only; runner uses /etc/hapi setup-token) --" ( unset CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CODE_OAUTH_REFRESH_TOKEN 2>/dev/null || true timeout 75 claude -p --output-format text "reply exactly: claude-slot-$SLOT-ok" diff --git a/scripts/tooling/hapi-claude-oauth-dropin.test.sh b/scripts/tooling/hapi-claude-oauth-dropin.test.sh new file mode 100755 index 0000000000..184f9fff75 --- /dev/null +++ b/scripts/tooling/hapi-claude-oauth-dropin.test.sh @@ -0,0 +1,926 @@ +#!/usr/bin/env bash +# Unit test for lib/hapi-claude-oauth-dropin.sh — path defaults + drop-in write. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# shellcheck source=lib/hapi-claude-oauth-dropin.sh +source "$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +check() { + local name="$1" cond="$2" + if eval "$cond"; then + echo "OK: $name" + else + echo "FAIL: $name" >&2 + exit 1 + fi +} + +# User/pet canonical path when nothing exists yet. +got="$(hapi_claude_oauth_default_token_file "$TMP/hapi-home")" +check "canonical path" "[[ \"$got\" == \"$TMP/hapi-home/claude-setup-token.env\" ]]" + +# System fleet path is root-controlled under /etc/hapi (not service home). +got="$(hapi_claude_oauth_system_token_file)" +check "system token path" "[[ \"$got\" == \"/etc/hapi/claude-setup-token.env\" ]]" + +# Legacy antevorta hotfix path must NOT win - migrate is operational, not dual-path. +mkdir -p "$TMP/legacy/.hapi" +touch "$TMP/legacy/.hapi/claude-setup-token.env" +got="$(hapi_claude_oauth_default_token_file "$TMP/legacy")" +check "canonical even when legacy exists" "[[ \"$got\" == \"$TMP/legacy/claude-setup-token.env\" ]]" + +# Fresh token parent is created 0700; pre-existing parents keep their mode. +fresh_parent="$TMP/fresh-home" +fresh_token="$fresh_parent/claude-setup-token.env" +export XDG_CONFIG_HOME="$TMP/xdg-fresh" +mkdir -p "$XDG_CONFIG_HOME" +PATH_STUB="$TMP/bin" +mkdir -p "$PATH_STUB" +cat >"$PATH_STUB/systemctl" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod +x "$PATH_STUB/systemctl" +PATH="$PATH_STUB:$PATH" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$fresh_token" >$TMP/hapi-claude-oauth-fresh-parent.out 2>$TMP/hapi-claude-oauth-fresh-parent.err +fresh_mode="$(stat -c '%a' "$fresh_parent")" +check "fresh token parent is 0700" "[[ \"$fresh_mode\" == \"700\" ]]" + +existing_parent="$TMP/existing-home" +mkdir -m 0755 -p "$existing_parent" +existing_token="$existing_parent/claude-setup-token.env" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$existing_token" >$TMP/hapi-claude-oauth-existing-parent.out 2>$TMP/hapi-claude-oauth-existing-parent.err +existing_mode="$(stat -c '%a' "$existing_parent")" +check "existing token parent mode preserved" "[[ \"$existing_mode\" == \"755\" ]]" + +# Legacy-only file is auto-copied to canonical (never cp -a); drop-in stays canonical. +legacy_home="$TMP/migrate-home" +mkdir -p "$legacy_home/.hapi" +printf 'CLAUDE_CODE_OAUTH_TOKEN=legacy\n' >"$legacy_home/.hapi/claude-setup-token.env" +chmod 600 "$legacy_home/.hapi/claude-setup-token.env" +canon_token="$legacy_home/claude-setup-token.env" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$canon_token" >$TMP/hapi-claude-oauth-migrate.out 2>$TMP/hapi-claude-oauth-migrate.err +check "legacy auto-migrated to canonical" "grep -q 'Migrated Claude OAuth token' $TMP/hapi-claude-oauth-migrate.out" +check "canonical has migrated value" "grep -q 'CLAUDE_CODE_OAUTH_TOKEN=legacy' \"$canon_token\"" +check "canonical is not a symlink" "[[ ! -L \"$canon_token\" ]]" +check "legacy source retired after migrate" "[[ ! -e \"$legacy_home/.hapi/claude-setup-token.env\" ]]" +check "legacy archive exists after migrate" \ + "ls \"$legacy_home/.hapi/claude-setup-token.env.migrated.\"* >/dev/null 2>&1" +legacy_arch="" +for _arch in "$legacy_home/.hapi/claude-setup-token.env.migrated."*; do + legacy_arch="$_arch" + break +done +legacy_arch_mode="$(stat -c '%a' "$legacy_arch")" +check "legacy archive is 0600" "[[ \"$legacy_arch_mode\" == 600 ]]" +check "retire message logged" "grep -q 'Retired legacy Claude OAuth token' $TMP/hapi-claude-oauth-migrate.out" +dropin_migrate="$XDG_CONFIG_HOME/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf" +check "drop-in stays on canonical despite legacy" \ + "grep -q \"EnvironmentFile=-$canon_token\" \"$dropin_migrate\"" + +# Empty canonical must NOT re-import a retired legacy archive on reinstall. +: >"$canon_token" +chmod 600 "$canon_token" +set +e +hapi_install_claude_oauth_dropin \ + --scope user --runner-unit hapi-runner.service \ + --token-file "$canon_token" >$TMP/hapi-claude-oauth-remigrate.out 2>$TMP/hapi-claude-oauth-remigrate.err +remigrate_rc=$? +set -e +check "retired remigrate invokes real installer" "[[ $remigrate_rc -eq 0 || $remigrate_rc -ne 127 ]]" +check "retired remigrate did not command-not-found" \ + "! grep -qi 'command not found' $TMP/hapi-claude-oauth-remigrate.err" +check "retired archive not re-copied into empty canon" \ + "! grep -q 'CLAUDE_CODE_OAUTH_TOKEN=legacy' \"$canon_token\"" +check "retired archive file still present" \ + "ls \"$legacy_home/.hapi/claude-setup-token.env.migrated.\"* >/dev/null 2>&1" + +# Symlink legacy must be refused (not copied into canonical). +symlink_legacy_home="$TMP/symlink-migrate" +mkdir -p "$symlink_legacy_home/.hapi" +printf 'CLAUDE_CODE_OAUTH_TOKEN=evil\n' >"$symlink_legacy_home/.hapi/real.env" +ln -s "$symlink_legacy_home/.hapi/real.env" "$symlink_legacy_home/.hapi/claude-setup-token.env" +symlink_canon="$symlink_legacy_home/claude-setup-token.env" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$symlink_canon" >$TMP/hapi-claude-oauth-symlink-mig.out 2>$TMP/hapi-claude-oauth-symlink-mig.err || true +check "symlink legacy refused" "grep -qi 'refusing.*symlink' $TMP/hapi-claude-oauth-symlink-mig.err" +check "symlink legacy leaves canonical absent" "[[ ! -e \"$symlink_canon\" ]]" +check "migrate pending after symlink refuse" "[[ \"${HAPI_CLAUDE_OAUTH_MIGRATE_PENDING:-0}\" -eq 1 ]]" + +# User-scope drop-in write (no systemctl root needed — daemon-reload may fail +# in CI/sandbox without a user bus; tolerate that by stubbing systemctl). +export XDG_CONFIG_HOME="$TMP/xdg" +mkdir -p "$XDG_CONFIG_HOME" +PATH="$PATH_STUB:$PATH" + +token="$TMP/pet/claude-setup-token.env" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$token" >$TMP/hapi-claude-oauth-dropin-test.out + +dropin="$XDG_CONFIG_HOME/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf" +check "drop-in created" "[[ -f \"$dropin\" ]]" +check "drop-in points at token file" "grep -q \"EnvironmentFile=-$token\" \"$dropin\"" +check "instructions printed when token missing" "grep -q 'Not logged in' $TMP/hapi-claude-oauth-dropin-test.out" +check "instructions name concrete user unit" "grep -q 'systemctl --user restart hapi-runner.service' $TMP/hapi-claude-oauth-dropin-test.out" +check "instructions have no placeholder unit" "! grep -q '' $TMP/hapi-claude-oauth-dropin-test.out" + +# With a real token line, no setup banner. +printf 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-test\n' >"$token" +chmod 600 "$token" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$token" >$TMP/hapi-claude-oauth-dropin-test2.out +check "token present message" "grep -q 'token file present' $TMP/hapi-claude-oauth-dropin-test2.out" +check "no setup banner when token present" "! grep -q 'NOT configured yet' $TMP/hapi-claude-oauth-dropin-test2.out" +check "token present prompts runner restart" \ + "grep -q 'systemctl --user restart hapi-runner.service' $TMP/hapi-claude-oauth-dropin-test2.out" + +# Whitespace-only / CRLF-empty assignment is unconfigured (not "present"). +printf 'CLAUDE_CODE_OAUTH_TOKEN=\r\n' >"$token" +chmod 600 "$token" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$token" >$TMP/hapi-claude-oauth-dropin-ws.out 2>$TMP/hapi-claude-oauth-dropin-ws.err +check "CRLF-empty not reported present" \ + "! grep -q 'token file present' $TMP/hapi-claude-oauth-dropin-ws.out" +check "CRLF-empty prints setup instructions" \ + "grep -q 'NOT configured yet' $TMP/hapi-claude-oauth-dropin-ws.out" + +# Symlink token file must be refused before chmod/chown (Codex P1 / antevorta threat). +symlink_target="$TMP/symlink-target" +symlink_token="$TMP/symlink-token.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-symlink\n' >"$symlink_target" +ln -s "$symlink_target" "$symlink_token" +set +e +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$symlink_token" >$TMP/hapi-claude-oauth-dropin-symlink.out 2>$TMP/hapi-claude-oauth-dropin-symlink.err +symlink_rc=$? +set -e +check "symlink token file refused" "[[ $symlink_rc -ne 0 ]]" +check "symlink refusal mentions symlink" "grep -qi symlink $TMP/hapi-claude-oauth-dropin-symlink.err" + +# Direct assert helper too. +set +e +hapi_claude_oauth_assert_safe_token_file "$symlink_token" 2>/dev/null +assert_rc=$? +set -e +check "assert_safe rejects symlink" "[[ $assert_rc -ne 0 ]]" +regular="$TMP/regular.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=x\n' >"$regular" +check "assert_safe accepts regular file" "hapi_claude_oauth_assert_safe_token_file \"$regular\"" + +# Secure chmod/chown must refuse symlink via O_NOFOLLOW (not check-then-use). +set +e +hapi_claude_oauth_secure_chmod_chown "$symlink_token" 2>$TMP/hapi-claude-oauth-secure-symlink.err +secure_rc=$? +set -e +check "secure_chmod rejects symlink" "[[ $secure_rc -ne 0 ]]" +chmod 0644 "$regular" +hapi_claude_oauth_secure_chmod_chown "$regular" +mode="$(stat -c '%a' "$regular")" +check "secure_chmod sets 600" "[[ \"$mode\" == \"600\" ]]" + +# Shell fallback must work for user-scope (no --owner) when python3 is forced off. +chmod 0644 "$regular" +HAPI_CLAUDE_OAUTH_FORCE_SHELL=1 hapi_claude_oauth_secure_chmod_chown "$regular" +mode="$(stat -c '%a' "$regular")" +check "secure_chmod shell fallback sets 600" "[[ \"$mode\" == \"600\" ]]" +set +e +HAPI_CLAUDE_OAUTH_FORCE_SHELL=1 hapi_claude_oauth_secure_chmod_chown "$symlink_token" 2>/dev/null +shell_symlink_rc=$? +set -e +check "secure_chmod shell fallback rejects symlink" "[[ $shell_symlink_rc -ne 0 ]]" + +# System-profile (owner set) must fail closed without python — never path chown as root. +set +e +HAPI_CLAUDE_OAUTH_FORCE_SHELL=1 hapi_claude_oauth_secure_chmod_chown "$regular" "hapi:hapi" \ + >$TMP/hapi-claude-oauth-force-shell-owner.out 2>$TMP/hapi-claude-oauth-force-shell-owner.err +owner_shell_rc=$? +set -e +check "secure_chmod system shell path fails closed" "[[ $owner_shell_rc -ne 0 ]]" +check "secure_chmod system shell path mentions python3" \ + "grep -qi python3 $TMP/hapi-claude-oauth-force-shell-owner.err" + +# System-scope instructions use systemctl restart (not --user). +hapi_print_claude_oauth_setup_instructions "/etc/hapi/claude-setup-token.env" "hapi-runner.service" "system" \ + >$TMP/hapi-claude-oauth-dropin-system-instr.out +check "system instructions use sudo systemctl restart" \ + "grep -q 'sudo systemctl restart hapi-runner.service' $TMP/hapi-claude-oauth-dropin-system-instr.out" +check "system instructions use privileged install -m 0600" \ + "grep -q 'sudo install -m 0600 /dev/stdin' $TMP/hapi-claude-oauth-dropin-system-instr.out" +check "system instructions do not use tee+chmod race" \ + "! grep -q 'sudo tee' $TMP/hapi-claude-oauth-dropin-system-instr.out" + +# Last-assignment empty final must not claim token present. +printf 'CLAUDE_CODE_OAUTH_TOKEN=first\nCLAUDE_CODE_OAUTH_TOKEN=\n' >"$token" +chmod 600 "$token" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$token" >$TMP/hapi-claude-oauth-dropin-lastempty.out 2>$TMP/hapi-claude-oauth-dropin-lastempty.err +check "empty final assignment not reported present" \ + "! grep -q 'token file present' $TMP/hapi-claude-oauth-dropin-lastempty.out" +check "empty final assignment prints setup instructions" \ + "grep -q 'NOT configured yet' $TMP/hapi-claude-oauth-dropin-lastempty.out" + +# Toggle resolves through ~/.local/bin symlink to the real scripts/tooling path. +toggle_link="$TMP/bin/hapi-claude-account-toggle" +ln -sf "$ROOT/scripts/tooling/hapi-claude-account-toggle.sh" "$toggle_link" +resolved_dir="$(cd "$(dirname "$(readlink -f "$toggle_link")")" && pwd)" +tooling_real="$(cd "$ROOT/scripts/tooling" && pwd -P)" +check "toggle symlink resolves to scripts/tooling" \ + "[[ \"$resolved_dir\" == \"$tooling_real\" ]]" +check "toggle symlink finds dropin lib" \ + "[[ -f \"$resolved_dir/lib/hapi-claude-oauth-dropin.sh\" ]]" + +# Last-assignment-wins: empty final CLAUDE_CODE_OAUTH_TOKEN= is unconfigured. +dup="$TMP/dup-assign.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=first\nCLAUDE_CODE_OAUTH_TOKEN=\n' >"$dup" +chmod 600 "$dup" +set +e +hapi_claude_oauth_effective_token_value "$dup" >$TMP/hapi-claude-oauth-eff-empty.out 2>$TMP/hapi-claude-oauth-eff-empty.err +eff_empty_rc=$? +set -e +check "effective token rejects empty final assignment" "[[ $eff_empty_rc -ne 0 ]]" +printf 'CLAUDE_CODE_OAUTH_TOKEN=first\nCLAUDE_CODE_OAUTH_TOKEN=second\n' >"$dup" +got_eff="$(hapi_claude_oauth_effective_token_value "$dup")" +check "effective token uses last assignment" "[[ \"$got_eff\" == \"second\" ]]" + +# Secure exclusive write refuses a pre-planted symlink destination. +bak_symlink="$TMP/auth-bak-symlink" +printf 'real\n' >"$TMP/auth-bak-real" +ln -s "$TMP/auth-bak-real" "$bak_symlink" +set +e +printf 'token-bytes\n' | hapi_claude_oauth_secure_write_new_file "$bak_symlink" \ + >$TMP/hapi-claude-oauth-excl.out 2>$TMP/hapi-claude-oauth-excl.err +excl_rc=$? +set -e +check "secure_write refuses symlink dst" "[[ $excl_rc -ne 0 ]]" +check "symlink dst target unchanged" "grep -qx real \"$TMP/auth-bak-real\"" +bak_ok="$TMP/auth-bak-ok.env" +printf 'token-bytes\n' | hapi_claude_oauth_secure_write_new_file "$bak_ok" +check "secure_write creates exclusive file" "grep -qx token-bytes \"$bak_ok\"" +set +e +printf 'again\n' | hapi_claude_oauth_secure_write_new_file "$bak_ok" 2>$TMP/hapi-claude-oauth-excl2.err +excl2_rc=$? +set -e +check "secure_write refuses existing dst" "[[ $excl2_rc -ne 0 ]]" + +# Profile-scoped legacy candidates (fleet vs soup). +mapfile -t fleet_arr < <(hapi_claude_oauth_legacy_system_token_candidates fleet-binary) +fleet_joined=$'\n'"$(printf '%s\n' "${fleet_arr[@]}")"$'\n' +check "fleet candidates include /var/lib/hapi" \ + "[[ \"$fleet_joined\" == *$'\n'/var/lib/hapi/claude-setup-token.env$'\n'* ]]" +check "fleet candidates exclude operator home" \ + "[[ \"$fleet_joined\" != *$'\n'/home/heavygee/.hapi/claude-setup-token.env$'\n'* ]]" +mapfile -t fleet_custom < <(hapi_claude_oauth_legacy_system_token_candidates fleet-binary /srv/hapi-custom) +fleet_custom_joined=$'\n'"$(printf '%s\n' "${fleet_custom[@]}")"$'\n' +check "fleet --hapi-home candidates include custom home" \ + "[[ \"$fleet_custom_joined\" == *$'\n'/srv/hapi-custom/claude-setup-token.env$'\n'* ]]" +check "fleet --hapi-home still includes default /var/lib/hapi" \ + "[[ \"$fleet_custom_joined\" == *$'\n'/var/lib/hapi/claude-setup-token.env$'\n'* ]]" +mapfile -t soup_arr < <(hapi_claude_oauth_legacy_system_token_candidates primary-soup) +soup_joined=$'\n'"$(printf '%s\n' "${soup_arr[@]}")"$'\n' +check "soup candidates include operator home" \ + "[[ \"$soup_joined\" == *$'\n'/home/heavygee/.hapi/claude-setup-token.env$'\n'* ]]" +check "soup candidates exclude /var/lib/hapi by default" \ + "[[ \"$soup_joined\" != *$'\n'/var/lib/hapi/claude-setup-token.env$'\n'* ]]" +mapfile -t soup_op < <(hapi_claude_oauth_legacy_system_token_candidates primary-soup /var/lib/hapi /home/otherop) +soup_op_joined=$'\n'"$(printf '%s\n' "${soup_op[@]}")"$'\n' +check "soup --migrate-operator-home uses configured home" \ + "[[ \"$soup_op_joined\" == *$'\n'/home/otherop/.hapi/claude-setup-token.env$'\n'* ]]" +check "soup operator-home does not use sudoer HOME=/root" \ + "[[ \"$soup_op_joined\" != *$'\n'/root/.hapi/claude-setup-token.env$'\n'* ]]" +# Real primary-soup caller always passes HAPI_HOME=/var/lib/hapi — must NOT +# reintroduce fleet legacy paths into the soup candidate set. +check "soup with default HAPI_HOME excludes fleet /var/lib/hapi" \ + "[[ \"$soup_op_joined\" != *$'\n'/var/lib/hapi/claude-setup-token.env$'\n'* ]]" +check "soup with default HAPI_HOME excludes fleet /var/lib/hapi/.hapi" \ + "[[ \"$soup_op_joined\" != *$'\n'/var/lib/hapi/.hapi/claude-setup-token.env$'\n'* ]]" +mapfile -t soup_custom < <(hapi_claude_oauth_legacy_system_token_candidates primary-soup /srv/soup-custom /home/otherop) +soup_custom_joined=$'\n'"$(printf '%s\n' "${soup_custom[@]}")"$'\n' +check "soup custom HAPI_HOME is included" \ + "[[ \"$soup_custom_joined\" == *$'\n'/srv/soup-custom/claude-setup-token.env$'\n'* ]]" + +# cat|install_bytes pipe (privileged-install pattern without mktemp SOURCE). +pipe_src="$TMP/pipe-src.env" +pipe_dst="$TMP/pipe-dst.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=from-pipe\n' >"$pipe_src" +chmod 600 "$pipe_src" +hapi_claude_oauth_cat_regular_file "$pipe_src" | hapi_claude_oauth_install_bytes "$pipe_dst" +check "cat|install_bytes copies token" "grep -qx 'CLAUDE_CODE_OAUTH_TOKEN=from-pipe' \"$pipe_dst\"" +mode_pipe="$(stat -c '%a' "$pipe_dst")" +check "install_bytes sets 600" "[[ \"$mode_pipe\" == \"600\" ]]" +# Empty / ineffective payload must not wipe an existing destination. +printf 'CLAUDE_CODE_OAUTH_TOKEN=keep-me\n' >"$pipe_dst" +chmod 600 "$pipe_dst" +set +e +: | hapi_claude_oauth_install_bytes "$pipe_dst" >$TMP/hapi-claude-oauth-empty-pipe.out 2>$TMP/hapi-claude-oauth-empty-pipe.err +empty_pipe_rc=$? +set -e +check "empty pipe refuses replace" "[[ $empty_pipe_rc -ne 0 ]]" +check "empty pipe preserves destination" "grep -qx 'CLAUDE_CODE_OAUTH_TOKEN=keep-me' \"$pipe_dst\"" +set +e +printf 'CLAUDE_CODE_OAUTH_TOKEN=\n' | hapi_claude_oauth_install_bytes "$pipe_dst" \ + >$TMP/hapi-claude-oauth-blank-assign.out 2>$TMP/hapi-claude-oauth-blank-assign.err +blank_rc=$? +set -e +check "blank assignment refuses replace" "[[ $blank_rc -ne 0 ]]" +check "blank assignment preserves destination" "grep -qx 'CLAUDE_CODE_OAUTH_TOKEN=keep-me' \"$pipe_dst\"" + +# Bash EnvironmentFile unescape (pet hosts without python3). +got_esc="$(hapi_claude_oauth_parse_env_file_value '"abc\$def"')" +if [[ "$got_esc" == 'abc$def' ]]; then echo "OK: double-quoted \\\$ unescapes to \$"; else + echo "FAIL: double-quoted \\\$ unescapes to \$ (got=$got_esc)" >&2; exit 1 +fi +raw_sq="$(python3 -c 'print(chr(39) + "abc\\$def" + chr(39))')" +got_esc2="$(hapi_claude_oauth_parse_env_file_value "$raw_sq")" +if [[ "$got_esc2" == 'abc\$def' ]]; then echo "OK: single-quoted keeps backslash"; else + echo "FAIL: single-quoted keeps backslash (got=$got_esc2)" >&2; exit 1 +fi +# Force bash path of effective_token_value (PATH with no python3). +esc_file="$TMP/esc-token.env" +printf '%s\n' 'CLAUDE_CODE_OAUTH_TOKEN="abc\$def"' >"$esc_file" +chmod 600 "$esc_file" +no_py="$TMP/no-python-bin" +mkdir -p "$no_py" +got_eff_esc="$(PATH="$no_py" hapi_claude_oauth_effective_token_value "$esc_file")" +if [[ "$got_eff_esc" == 'abc$def' ]]; then echo "OK: bash effective_token_value unescapes quoted \\\$"; else + echo "FAIL: bash effective_token_value unescapes (got=$got_eff_esc)" >&2; exit 1 +fi + +# Toggle must not PATH-inject bash / source checkout under sudo. +check "toggle uses install_bytes_via_sudo" \ + "grep -q 'hapi_claude_oauth_install_bytes_via_sudo' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle does not PATH-inject bash under sudo" \ + "! grep -E 'sudo.*PATH=.*bash' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle validates credentials JSON before mutation" \ + "grep -q 'invalid JSON in slot credentials' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle rolls back canon on credentials failure" \ + "grep -q 'hapi_claude_oauth_rollback_canon_from_bak' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle removes newly created canon on rollback" \ + "grep -q 'secure_unlink_regular_file' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle tracks canon_existed_before" \ + "grep -q 'canon_existed_before=0' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle restores empty canon via restore_bytes" \ + "grep -q 'hapi_claude_oauth_restore_bytes_via_sudo' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle rolls back interactive credentials" \ + "grep -q 'hapi_claude_oauth_rollback_credentials' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "toggle continues credentials rollback after canon rollback" \ + "grep -B1 'rollback_canon_from_bak \"cannot replace' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\" | grep -q 'set +e'" +check "retire failure marks migrate pending" \ + "grep -A3 'could not retire legacy source' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\" | grep -q 'MIGRATE_PENDING=1'" +check "secure_copy has bash fallback for non-/etc" \ + "grep -q 'Bash fallback for same-user pet hosts' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "pet does not source oauth env file" \ + "! grep -E '\\. \\\"\\\$HAPI_HOME/claude-setup-token|set -a; \\. ' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet exports oauth via parser helper" \ + "grep -q 'hapi_pet_export_oauth_from_env_file' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet embedded path migrates legacy oauth before drop-in" \ + "grep -q 'hapi_pet_migrate_legacy_oauth_if_needed' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "verify parent check does not use fixed /tmp paths" \ + "! grep -E '/tmp/hapi-verify-oauth-parent\\.(out|err)' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "secure_copy retries short writes" \ + "grep -q 'write returned %d with %d bytes remaining' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "companion passes --unit-dir for user-pet drop-in" \ + "grep -q -- '--unit-dir' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\"" +check "oauth drop-in install precedes watchdog tier1" \ + "awk '/^[[:space:]]+hapi_install_claude_oauth_dropin/ && !d {d=NR} /^[[:space:]]+bash .*install-hapi-primary-hub-tier1\\.sh/ {t=NR} END {exit !(d && t && d/dev/null || true' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\"" +check "verify refuses later EnvironmentFile oauth override" \ + "grep -q 'overrides CLAUDE_CODE_OAUTH_TOKEN after canonical token' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "installer checks ambient oauth before watchdog tier1" \ + "python3 -c 't=open(\"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\").read(); c=t.find(\"if hapi_system_runner_ambient_oauth_unpersisted\"); n=t.find(\"install-hapi-primary-hub-tier1.sh\\\"\"); raise SystemExit(0 if 0<=c"$anc_root/real/.hapi/claude-setup-token.env" +chmod 600 "$anc_root/real/.hapi/claude-setup-token.env" +ln -s "$anc_root/real/.hapi" "$anc_root/fleet/.hapi" +set +e +hapi_claude_oauth_assert_no_symlink_ancestors "$anc_root/fleet/.hapi/claude-setup-token.env" \ + >"$TMP/anc.out" 2>"$TMP/anc.err" +anc_rc=$? +set -e +check "symlink ancestor rejected" "[[ $anc_rc -ne 0 ]]" +check "symlink ancestor names component" "grep -q 'refusing symlink path component' \"$TMP/anc.err\"" +set +e +hapi_claude_oauth_secure_copy_regular_file \ + "$anc_root/fleet/.hapi/claude-setup-token.env" "$anc_root/canon.env" \ + >"$TMP/anc-copy.out" 2>"$TMP/anc-copy.err" +anc_copy_rc=$? +set -e +check "secure_copy refuses symlink ancestor source" "[[ $anc_copy_rc -ne 0 ]]" +check "secure_copy did not create canon from symlink ancestor" "[[ ! -e \"$anc_root/canon.env\" ]]" + +# Oversized legacy source must fail before buffering into privileged migrate. +big_src="$TMP/big-legacy.env" +# Sparse-ish: write >64KiB of filler with a token assignment prefix. +{ + printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "$(head -c 200 "$big_src" +chmod 600 "$big_src" +set +e +hapi_claude_oauth_secure_copy_regular_file "$big_src" "$TMP/big-canon.env" \ + >"$TMP/big.out" 2>"$TMP/big.err" +big_rc=$? +set -e +check "oversized legacy token refused" "[[ $big_rc -ne 0 ]]" +check "oversized legacy names size limit" "grep -Eiq 'too large|exceeded' \"$TMP/big.err\"" +check "oversized legacy did not create destination" "[[ ! -e \"$TMP/big-canon.env\" ]]" + +# --unit-dir overrides XDG_CONFIG_HOME for user drop-in placement. +unit_home="$TMP/unit-dir-home" +mkdir -p "$unit_home/.config/systemd/user" +export XDG_CONFIG_HOME="$TMP/xdg-wrong" +mkdir -p "$XDG_CONFIG_HOME" +unit_token="$TMP/unit-dir-token.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=unitdir\n' >"$unit_token" +chmod 600 "$unit_token" +hapi_install_claude_oauth_dropin \ + --scope user --runner-unit hapi-runner.service \ + --token-file "$unit_token" \ + --unit-dir "$unit_home/.config/systemd/user" \ + >"$TMP/unit-dir.out" 2>"$TMP/unit-dir.err" +check "unit-dir drop-in beside HOME unit root" \ + "[[ -f \"$unit_home/.config/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf\" ]]" +check "unit-dir ignores XDG_CONFIG_HOME" \ + "[[ ! -e \"$XDG_CONFIG_HOME/systemd/user/hapi-runner.service.d/42-claude-oauth-token.conf\" ]]" + +# Embedded pet migrate (curl|bash shape — no drop-in helpers). +pet_home="$TMP/pet-migrate-home" +mkdir -p "$pet_home/.hapi" +printf 'CLAUDE_CODE_OAUTH_TOKEN=pet-legacy\n' >"$pet_home/.hapi/claude-setup-token.env" +chmod 600 "$pet_home/.hapi/claude-setup-token.env" +pet_frag="$TMP/pet-migrate-frag.sh" +{ + cat <<'FRAG' +set -euo pipefail +fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; } +log() { printf '==> %s\n' "$1"; } +FRAG + # Extract helpers without executing the install body that follows them. + awk ' + /^hapi_pet_export_oauth_from_env_file\(\)/ {keep=1} + /^hapi_pet_migrate_legacy_oauth_if_needed\(\)/ {keep=1} + /^install_user_pet_systemd\(\)/ {keep=0} + keep {print} + ' "$ROOT/scripts/install-hapi-pet.sh" + printf 'hapi_pet_migrate_legacy_oauth_if_needed %q\n' "$pet_home/claude-setup-token.env" +} >"$pet_frag" +bash "$pet_frag" >"$TMP/pet-migrate-test.out" 2>"$TMP/pet-migrate-test.err" +check "pet migrate copies legacy to canonical" \ + "grep -q 'CLAUDE_CODE_OAUTH_TOKEN=pet-legacy' \"$pet_home/claude-setup-token.env\"" +check "pet migrate retires legacy source" \ + "[[ ! -e \"$pet_home/.hapi/claude-setup-token.env\" ]]" +check "pet migrate archives retired legacy" \ + "ls \"$pet_home/.hapi/claude-setup-token.env.migrated.\"* >/dev/null 2>&1" +check "pet migrate logs Migrated" \ + "grep -q 'Migrated Claude OAuth token' \"$TMP/pet-migrate-test.out\"" +check "systemd install blocks ambient-only restart" \ + "grep -q 'ambient CLAUDE_CODE_OAUTH_TOKEN' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\"" +check "user-pet install blocks ambient-only restart" \ + "grep -q 'hapi_user_pet_refuse_ambient_only_restart' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\"" +check "pet preflight checks symlink ancestors" \ + "grep -q '_preflight_token_ancestors' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet preflight blocks ambient-only stop" \ + "grep -q 'refuse stop/restart; persist the token first' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet preflight runs without --with-systemd for canonical" \ + "awk '/_preflight_token_shape \"\\\$\{HAPI_HOME\}\/claude-setup-token.env\"/ {found=1; exit} END {exit !found}' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet migrates legacy oauth before stop for nohup" \ + "awk '/hapi_pet_migrate_legacy_oauth_if_needed \"\\\$\{HAPI_HOME\}\/claude-setup-token.env\"/ {found=1; exit} END {exit !found}' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "pet nohup launch migrates legacy before export" \ + "awk '/Without: nohup/,/Runner started/ { if (\$0 ~ /hapi_pet_migrate_legacy_oauth_if_needed/) found=1 } END { exit !found }' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "verify refuses ambient-only restart" \ + "grep -q 'has ambient CLAUDE_CODE_OAUTH_TOKEN but' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify peer detection requires cgroup association" \ + "grep -q 'cgroup_related' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify validates every system drop-in conf before restart" \ + "grep -q 'unsafe drop-in' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "pet embeds systemd EnvironmentFile unescape" \ + "grep -q 'state=pre' \"$ROOT/scripts/install-hapi-pet.sh\" && grep -q 'state=dq' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "toggle rejects active credentials symlink" \ + "grep -q 'refusing symlink interactive credentials' \"$ROOT/scripts/tooling/hapi-claude-account-toggle.sh\"" +check "drop-in retires legacy even when canon already effective" \ + "grep -q 'retry retirement' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "retire function returns 1 on python failure" \ + "awk '/^hapi_claude_oauth_retire_legacy_token_source/,/^}/ { if (\$0 ~ /return 1/) found=1 } END { exit !found }' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" + +# restore_bytes must accept empty/ineffective payloads (toggle rollback of empty canon). +restore_dst="$TMP/restore-empty.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=new\n' >"$restore_dst" +chmod 600 "$restore_dst" +: | hapi_claude_oauth_restore_bytes "$restore_dst" +check "restore_bytes accepts empty payload" "[[ -f \"$restore_dst\" ]]" +check "restore_bytes empty clears prior content" "! grep -q 'CLAUDE_CODE_OAUTH_TOKEN=new' \"$restore_dst\"" +printf 'CLAUDE_CODE_OAUTH_TOKEN=\n' | hapi_claude_oauth_restore_bytes "$restore_dst" +check "restore_bytes accepts blank assignment" "grep -q 'CLAUDE_CODE_OAUTH_TOKEN=' \"$restore_dst\"" + +# retire must return nonzero when rename cannot succeed (dest already exists). +# Root ignores chmod a-w on the parent; collide dest with a frozen `date +%s`. +retire_src="$TMP/retire-src.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=legacy\n' >"$retire_src" +chmod 600 "$retire_src" +retire_ro="$TMP/retire-ro" +mkdir -p "$retire_ro" +printf 'CLAUDE_CODE_OAUTH_TOKEN=x\n' >"$retire_ro/token.env" +chmod 600 "$retire_ro/token.env" +touch "$retire_ro/token.env.migrated.9999999999" +mkdir -p "$TMP/datebin" +printf '%s\n' '#!/usr/bin/env bash' 'echo 9999999999' >"$TMP/datebin/date" +chmod +x "$TMP/datebin/date" +set +e +PATH="$TMP/datebin:$PATH" hapi_claude_oauth_retire_legacy_token_source "$retire_ro/token.env" \ + >"$TMP/hapi-claude-oauth-retire-ro.out" 2>"$TMP/hapi-claude-oauth-retire-ro.err" +retire_rc=$? +set -e +check "retire returns nonzero when archive rename fails" "[[ $retire_rc -ne 0 ]]" +check "retire leaves source when rename fails" "[[ -f \"$retire_ro/token.env\" ]]" + +# Bash secure_copy fallback (no python3 on PATH) for user-pet migrate. +bash_copy_src="$TMP/bash-copy-src.env" +bash_copy_dst="$TMP/bash-copy-dst.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=bash-copy\n' >"$bash_copy_src" +chmod 600 "$bash_copy_src" +no_py2="$TMP/no-python-bin2" +mkdir -p "$no_py2" +PATH="$no_py2" hapi_claude_oauth_secure_copy_regular_file "$bash_copy_src" "$bash_copy_dst" +check "bash secure_copy copies token" "grep -qx 'CLAUDE_CODE_OAUTH_TOKEN=bash-copy' \"$bash_copy_dst\"" +mode_bash="$(stat -c '%a' "$bash_copy_dst")" +check "bash secure_copy sets 600" "[[ \"$mode_bash\" == \"600\" ]]" +set +e +PATH="$no_py2" hapi_claude_oauth_secure_copy_regular_file "$bash_copy_src" /etc/hapi/should-fail.env \ + >$TMP/hapi-claude-oauth-bash-etc.out 2>$TMP/hapi-claude-oauth-bash-etc.err +bash_etc_rc=$? +set -e +check "bash secure_copy refuses /etc without python3" "[[ $bash_etc_rc -ne 0 ]]" + +# Invocation-only: the wrong helper name must not appear as a call site. +check "retired remigrate uses hapi_install_claude_oauth_dropin" \ + "grep -n 'hapi_install_claude_oauth_dropin' \"$ROOT/scripts/tooling/hapi-claude-oauth-dropin.test.sh\" | grep -q remigrate" +check "no call to nonexistent hapi_claude_oauth_install_dropin" \ + "! grep -E '^[[:space:]]*hapi_claude_oauth_install_dropin([[:space:]]|$)' \"$ROOT/scripts/tooling/hapi-claude-oauth-dropin.test.sh\"" +toggle_json_line="$(grep -n 'invalid JSON in slot credentials' "$ROOT/scripts/tooling/hapi-claude-account-toggle.sh" | head -1 | cut -d: -f1)" +toggle_cp_line="$(grep -n 'cp -a "$CRED"' "$ROOT/scripts/tooling/hapi-claude-account-toggle.sh" | head -1 | cut -d: -f1)" +check "toggle JSON validate precedes credentials cp" \ + "[[ -n \"$toggle_json_line\" && -n \"$toggle_cp_line\" && \"$toggle_json_line\" -lt \"$toggle_cp_line\" ]]" +check "install_bytes_via_sudo uses absolute python3" \ + "grep -q 'hapi_claude_oauth_absolute_python3' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "install_bytes_via_sudo sanitizes env" \ + "grep -q 'env -i PATH=/usr/bin:/bin' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "drop-in install returns on write failure" \ + "grep -q 'failed to write temporary drop-in' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "drop-in install returns on install failure" \ + "grep -q 'failed to install drop-in' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" + +# Secure unlink (new-canon rollback path). +unlink_target="$TMP/unlink-me.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=bye\n' >"$unlink_target" +chmod 600 "$unlink_target" +hapi_claude_oauth_secure_unlink_regular_file "$unlink_target" +check "secure_unlink removes regular file" "[[ ! -e \"$unlink_target\" ]]" +unlink_sym="$TMP/unlink-sym.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=keep\n' >"$TMP/unlink-real.env" +ln -s "$TMP/unlink-real.env" "$unlink_sym" +set +e +hapi_claude_oauth_secure_unlink_regular_file "$unlink_sym" \ + >$TMP/hapi-claude-oauth-unlink-sym.out 2>$TMP/hapi-claude-oauth-unlink-sym.err +unlink_sym_rc=$? +set -e +check "secure_unlink refuses symlink" "[[ $unlink_sym_rc -ne 0 ]]" +check "secure_unlink leaves symlink target" "grep -qx 'CLAUDE_CODE_OAUTH_TOKEN=keep' \"$TMP/unlink-real.env\"" + +# Drop-in write failure must surface even when caller uses set +e (installer pattern). +# Root ignores chmod a-w; a regular file as --unit-dir makes mkdir -p fail for anyone. +ro_unit="$TMP/ro-unit-dir-file" +printf 'not-a-directory\n' >"$ro_unit" +set +e +hapi_install_claude_oauth_dropin \ + --scope user --runner-unit hapi-runner.service \ + --unit-dir "$ro_unit" \ + --token-file "$TMP/missing-token.env" \ + >"$TMP/hapi-claude-oauth-ro-dropin.out" 2>"$TMP/hapi-claude-oauth-ro-dropin.err" +ro_rc=$? +set -e +check "drop-in write failure returns nonzero under set +e" "[[ $ro_rc -ne 0 ]]" + +# Existing non-root-owned "system" parent must fail closed. +bad_parent="$TMP/fake-etc-hapi" +mkdir -m 0775 -p "$bad_parent" +set +e +hapi_claude_oauth_assert_root_controlled_parent "$bad_parent" \ + >$TMP/hapi-claude-oauth-parent.out 2>$TMP/hapi-claude-oauth-parent.err +parent_rc=$? +set -e +check "assert_root_controlled rejects non-root parent" "[[ $parent_rc -ne 0 ]]" +check "assert_root_controlled mentions ownership or writable" \ + "grep -Eiq 'owned by uid|group/other-writable' $TMP/hapi-claude-oauth-parent.err" +check "ancestor walker is exported from dropin lib" \ + "declare -F hapi_claude_oauth_assert_root_controlled_ancestors >/dev/null" + +# systemd EnvironmentFile unescape (unquoted \X → X; whitespace then quotes). +got="$(hapi_claude_oauth_parse_env_file_value 'abc\def')" +check "parse unquoted backslash strips" "[[ \"$got\" == \"abcdef\" ]]" +got="$(hapi_claude_oauth_parse_env_file_value ' "quoted" ')" +check "parse strips outer whitespace before quotes" "[[ \"$got\" == \"quoted\" ]]" + +# Canon already effective + leftover legacy → retry retirement (not need_migrate=0 no-op). +retry_home="$TMP/retry-retire" +mkdir -p "$retry_home/.hapi" +printf 'CLAUDE_CODE_OAUTH_TOKEN=canon\n' >"$retry_home/claude-setup-token.env" +chmod 600 "$retry_home/claude-setup-token.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=stale\n' >"$retry_home/.hapi/claude-setup-token.env" +chmod 600 "$retry_home/.hapi/claude-setup-token.env" +set +e +hapi_install_claude_oauth_dropin \ + --scope user --runner-unit hapi-runner.service \ + --token-file "$retry_home/claude-setup-token.env" \ + >$TMP/hapi-claude-oauth-retry-retire.out 2>$TMP/hapi-claude-oauth-retry-retire.err +retry_rc=$? +set -e +check "retry retirement succeeds with effective canon" "[[ $retry_rc -eq 0 ]]" +check "retry retirement archives leftover legacy" \ + "[[ ! -e \"$retry_home/.hapi/claude-setup-token.env\" ]]" +check "retry retirement keeps canon value" \ + "grep -q 'CLAUDE_CODE_OAUTH_TOKEN=canon' \"$retry_home/claude-setup-token.env\"" +check "retry retirement logs Retired" \ + "grep -q 'Retired legacy Claude OAuth token' $TMP/hapi-claude-oauth-retry-retire.out" + +# systemd EnvironmentFile line continuation (unquoted trailing \ eats newline). +cont_file="$TMP/cont-token.env" +printf '%s\n' 'CLAUDE_CODE_OAUTH_TOKEN=abc\' 'def' >"$cont_file" +chmod 600 "$cont_file" +got="$(hapi_claude_oauth_effective_token_value "$cont_file")" +check "effective token joins unquoted continuation" "[[ \"$got\" == \"abcdef\" ]]" +check "continuation file is treated as durable" "hapi_claude_oauth_has_effective_token \"$cont_file\"" +qfile="$TMP/quoted-cont.env" +printf '%s\n' 'CLAUDE_CODE_OAUTH_TOKEN="abc' 'def"' >"$qfile" +chmod 600 "$qfile" +got_q="$(PATH="$no_py" hapi_claude_oauth_effective_token_value "$qfile")" +if [[ "$got_q" == $'abc\ndef' ]]; then echo "OK: bash fallback joins quoted EnvironmentFile newlines"; else + echo "FAIL: bash fallback quoted newline (got=$(printf %q "$got_q"))" >&2; exit 1 +fi +check "pet embedded parser tracks quoted continuations" \ + "grep -q 'hapi_claude_oauth_env_line_state' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "verify UnsetEnvironment refuses restart" \ + "grep -q 'UnsetEnvironment removes CLAUDE_CODE_OAUTH_TOKEN' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify later EnvironmentFile root-control is system-scope" \ + "python3 -c 'from pathlib import Path; t=Path(\"$ROOT/scripts/tooling/verify-hapi-install.sh\").read_text(); i=t.find(\"later EnvironmentFile parent is not root-controlled\"); c=t[max(0,i-250):i]; raise SystemExit(0 if \"SCOPE\" in c and \"system\" in c else 1)'" +check "verify user token requires runner-owned 0600" \ + "grep -q 'user Claude OAuth token must be owned by the runner account mode 0600' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "installer fails closed if watchdog quiesce fails" \ + "grep -q 'failed to stop hapi-runner-watchdog.timer before unit rewrite' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\" && ! grep -q 'stop hapi-runner-watchdog.timer 2>/dev/null || true' \"$ROOT/scripts/tooling/install-hapi-systemd-units.sh\"" +check "verify requires exact /etc/hapi EnvironmentFile" \ + "grep -q 'wired as /etc/hapi/claude-setup-token.env' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify UnsetEnvironment matches assignment form" \ + "grep -q 'CLAUDE_CODE_OAUTH_TOKEN=\\*' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify refuses system OAuth checks without root" \ + "grep -q 'system-scope OAuth restart checks require root' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "pet tightens token mode before stop" \ + "python3 -c 't=open(\"$ROOT/scripts/install-hapi-pet.sh\").read(); a=t.find(\"cannot chmod 600\"); b=t.find(\"# --- 4. Stop\"); raise SystemExit(0 if 0<=a"$seg_file" +chmod 600 "$seg_file" +got_seg_eff="$(hapi_claude_oauth_effective_token_value "$seg_file")" +got_seg_bash="$(PATH="$no_py" hapi_claude_oauth_effective_token_value "$seg_file")" +check "python effective token concatenates quote segments" "[[ \"$got_seg_eff\" == abcdef ]]" +check "bash effective token concatenates quote segments" "[[ \"$got_seg_bash\" == abcdef ]]" +writable_parent="$TMP/writable-home" +mkdir -m 0777 -p "$writable_parent" +writable_token="$writable_parent/claude-setup-token.env" +set +e +hapi_install_claude_oauth_dropin --scope user --runner-unit hapi-runner.service --token-file "$writable_token" >/dev/null 2>"$TMP/writable-parent.err" +writable_rc=$? +set -e +check "user drop-in rejects group/other-writable token parent" "[[ $writable_rc -ne 0 ]] && grep -q 'group/other-writable' \"$TMP/writable-parent.err\"" +check "merged env validates required EnvironmentFile shape" \ + "grep -q 'required EnvironmentFile is not a regular file' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\" && grep -q 'required EnvironmentFile is unreadable' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +interior_file="$TMP/interior-quote.env" +printf '%s\n' 'CLAUDE_CODE_OAUTH_TOKEN=abc"def' 'CLAUDE_CODE_OAUTH_TOKEN=' >"$interior_file" +chmod 600 "$interior_file" +set +e +got_int="$(hapi_claude_oauth_effective_token_value "$interior_file" 2>/dev/null)" +int_rc=$? +got_int_bash="$(PATH="$no_py" hapi_claude_oauth_effective_token_value "$interior_file" 2>/dev/null)" +int_bash_rc=$? +set -e +check "interior quote does not swallow following empty assignment" "[[ $int_rc -ne 0 && -z \"$got_int\" ]]" +check "bash interior quote does not swallow following empty assignment" "[[ $int_bash_rc -ne 0 && -z \"$got_int_bash\" ]]" +bs_file="$TMP/bs-quote.env" +printf '%s\n' 'CLAUDE_CODE_OAUTH_TOKEN="abc\\def"' >"$bs_file" +chmod 600 "$bs_file" +got_bs="$(PATH="$no_py" hapi_claude_oauth_effective_token_value "$bs_file")" +check "bash double-quoted escaped backslash is one backslash" "[[ \"$got_bs\" == \$'abc\\\\def' ]]" +check "install_bytes coalesces EnvironmentFile logical lines" \ + "grep -q 'def coalesce_env_lines' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "embedded pet drop-in write does not follow dest symlink" \ + "grep -q 'mktemp' \"$ROOT/scripts/install-hapi-pet.sh\" && grep -q 'refusing symlink drop-in' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "embedded pet validates user token parents" \ + "grep -q 'hapi_pet_assert_user_token_parents' \"$ROOT/scripts/install-hapi-pet.sh\"" +check "merged env fails closed on unreadable later EnvironmentFile" \ + "grep -q 'later EnvironmentFile is unreadable' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\" && grep -q 'later EnvironmentFile is unreadable' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "user token ancestors walk custom homes" \ + "grep -q 'hapi_claude_oauth_assert_trusted_user_token_ancestor' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\"" +check "retire archive is chmod 0600" \ + "grep -q 'fchmod' \"$ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh\" && grep -q 'chmod 600' \"$ROOT/scripts/install-hapi-pet.sh\"" +custom_anc="$TMP/custom-shared" +mkdir -p "$custom_anc/hapi" +chmod 0775 "$custom_anc" +chmod 0700 "$custom_anc/hapi" +set +e +hapi_claude_oauth_assert_user_token_parents "$custom_anc/hapi/claude-setup-token.env" "$(id -u)" \ + >"$TMP/custom-anc.out" 2>"$TMP/custom-anc.err" +custom_anc_rc=$? +set -e +check "custom HAPI_HOME fails on group-writable ancestor" "[[ $custom_anc_rc -ne 0 ]] && grep -q 'group/other-writable' \"$TMP/custom-anc.err\"" +later_ur="$TMP/later-unreadable" +mkdir -p "$later_ur" +printf 'CLAUDE_CODE_OAUTH_TOKEN=canon\n' >"$later_ur/canon.env" +chmod 600 "$later_ur/canon.env" +printf 'CLAUDE_CODE_OAUTH_TOKEN=later\n' >"$later_ur/later.env" +chmod 000 "$later_ur/later.env" +mkdir -p "$later_ur/bin" +cat >"$later_ur/bin/systemctl" <"$TMP/later-ur.out" 2>"$TMP/later-ur.err" +later_ur_rc=$? +set -e +chmod 600 "$later_ur/later.env" || true +check "unreadable later EnvironmentFile is unsafe" "[[ $later_ur_rc -eq 0 ]] && grep -Eq 'unreadable|overrides' \"$TMP/later-ur.err\"" +legacy_loose="$TMP/migrate-644" +mkdir -p "$legacy_loose/.hapi" +printf 'CLAUDE_CODE_OAUTH_TOKEN=legacy644\n' >"$legacy_loose/.hapi/claude-setup-token.env" +chmod 644 "$legacy_loose/.hapi/claude-setup-token.env" +hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$legacy_loose/claude-setup-token.env" >/dev/null 2>&1 +loose_arch="" +for _arch in "$legacy_loose/.hapi/claude-setup-token.env.migrated."*; do + loose_arch="$_arch" + break +done +loose_mode="$(stat -c '%a' "$loose_arch")" +check "retired 0644 legacy archive becomes 0600" "[[ \"$loose_mode\" == 600 ]]" +check "verify required EnvironmentFile shape before canon order" \ + "grep -q 'required EnvironmentFile is not a regular file' \"$ROOT/scripts/tooling/verify-hapi-install.sh\" && grep -q 'required EnvironmentFile is unreadable' \"$ROOT/scripts/tooling/verify-hapi-install.sh\"" +check "verify merged EnvironmentFiles gate is independent of token effectiveness" \ + "python3 -c 'from pathlib import Path; t=Path(\"$ROOT/scripts/tooling/verify-hapi-install.sh\").read_text(); a=t.find(\"hapi_claude_oauth_has_effective_token\"); b=t.find(\"Merged EnvironmentFiles gate runs independently of token effectiveness\"); c=t.find(\"required EnvironmentFile is missing\", b); raise SystemExit(0 if 0<=a"$later_miss/canon.env" +chmod 600 "$later_miss/canon.env" +# Fake a later optional path under a non-root-owned dir (this TMP tree). +mkdir -m 0775 -p "$later_miss/writable-home" +mkdir -p "$later_miss/bin" +cat >"$later_miss/bin/systemctl" <"$TMP/later-miss.out" 2>"$TMP/later-miss.err" +later_miss_rc=$? +set -e +check "missing optional later EnvironmentFile under non-root parent is unsafe" \ + "[[ $later_miss_rc -eq 0 ]] && grep -q 'not root-controlled' \"$TMP/later-miss.err\"" + +echo "ALL OK" diff --git a/scripts/tooling/hapi-meta-daily.sh b/scripts/tooling/hapi-meta-daily.sh index 04de7dfa92..fb4119edf3 100755 --- a/scripts/tooling/hapi-meta-daily.sh +++ b/scripts/tooling/hapi-meta-daily.sh @@ -1124,9 +1124,13 @@ main() { # In-turn skip: session.thinking means the agent is emitting / in a # turn (not merely active=true). Injecting "are you done yet?" steers # a live turn. Archived/inactive ⚠️ still rouse — chip says work owed. + # Keep the last *delivered* fingerprint when a due ping is suppressed so + # the next idle run still sees a fingerprint change (not backoff). local thinking="${SESS_THINKING[$sid8]:-false}" + local store_fp="$action_fp" if [[ "$thinking" == "true" && "$decision" == "yes" ]]; then decision="no" + store_fp="$prev_fp" Q_SKIP_RUNNING+=("$sid8 $combined #$(echo "$prs" | tr ' ' ',') — thinking; skip ping this window") fi if [[ "$combined" == "⚠️" && "$DO_PING" -eq 1 && "$session_sticky" == "true" && "$thinking" != "true" && "$decision" == "no" && "$prev_emoji" == "⚠️" ]]; then @@ -1151,18 +1155,24 @@ main() { fi fi if [[ "$ping_note" != "skip" ]]; then - _do_ping "$sid8" "$combined" "$prs" "$acts" - Q_PINGED+=("$sid8 $combined #$(echo "$prs" | tr ' ' ',')${ping_note}") - this_ping="$now" - if [[ "$combined" == "⚠️" ]]; then - if [[ "$action_fp" == "$prev_fp" && "$prev_emoji" == "⚠️" ]]; then - this_streak=$((prev_streak + 1)) - (( this_streak < 1 )) && this_streak=1 + if _do_ping "$sid8" "$combined" "$prs" "$acts"; then + Q_PINGED+=("$sid8 $combined #$(echo "$prs" | tr ' ' ',')${ping_note}") + this_ping="$now" + if [[ "$combined" == "⚠️" ]]; then + if [[ "$action_fp" == "$prev_fp" && "$prev_emoji" == "⚠️" ]]; then + this_streak=$((prev_streak + 1)) + (( this_streak < 1 )) && this_streak=1 + else + this_streak=1 + fi else - this_streak=1 + this_streak=0 fi else - this_streak=0 + # Delivery failed: keep prior fp/backoff so the next + # window retries instead of sleeping for hours/days. + store_fp="$prev_fp" + Q_PINGED+=("$sid8 $combined #$(echo "$prs" | tr ' ' ',')${ping_note} [ping-failed]") fi fi fi @@ -1243,11 +1253,13 @@ main() { fi # Actuator state always advances independently of emit success. + # store_fp may intentionally lag action_fp when a due ping was + # skipped (thinking) or failed delivery — see thinking/fp gate above. if [[ "$combined" != "⚠️" ]]; then this_streak=0 fi new_state="$(printf '%s' "$new_state" | jq -c \ - --arg s "$sid" --arg e "$combined" --arg f "$action_fp" \ + --arg s "$sid" --arg e "$combined" --arg f "$store_fp" \ --argjson lp "${this_ping:-0}" --argjson ps "${this_streak:-0}" --arg t "$new_title" \ '.sessions[$s] = ((.sessions[$s] // {}) + {emoji:$e, fp:$f, last_ping:$lp, ping_streak:$ps, title:$t})')" fi @@ -1634,7 +1646,11 @@ Canon: docs/operator/AGENTS.md § Meta PR watcher + feature-work-lifecycle.md § echo " [dry-run] ping $sid8 ($emoji)" >&2 return 0 fi - "$PING_BIN" "$sid8" "$msg" >/dev/null 2>&1 || err "ping failed for $sid8" + if ! "$PING_BIN" "$sid8" "$msg" >/dev/null 2>&1; then + err "ping failed for $sid8" + return 1 + fi + return 0 } _print_section() { # <array-name> diff --git a/scripts/tooling/hapi-meta-daily.test.sh b/scripts/tooling/hapi-meta-daily.test.sh index d4b7bcd035..9888354eb7 100755 --- a/scripts/tooling/hapi-meta-daily.test.sh +++ b/scripts/tooling/hapi-meta-daily.test.sh @@ -171,6 +171,10 @@ check "run1: pinged #300 merged (cccccccc)" "grep -q '^cccccccc' <<<\"\$pings\"" check "run1: ✅ #200 first-sight transition pings (bbbbbbbb)" "grep -q '^bbbbbbbb' <<<\"\$pings\"" check "run1: #400 asleep resume-pinged (C)" "grep -q '^dddddddd' <<<\"\$pings\"" check "run1: thinking ⚠️ #600 not pinged (ffffffff)" "! grep -q '^ffffffff' <<<\"\$pings\"" +# Due ping skipped while thinking must not latch the new fingerprint — otherwise +# the next idle window treats the blocker as unchanged and exponential-backs off. +think_fp="$(jq -r '.sessions["ffffffff-6666"].fp // empty' "$WORK/state.json")" +check "run1: thinking skip does not latch undelivered fingerprint" "[[ -z \"\$think_fp\" ]]" # ============ 3. second run: 🔧 still hourly; same-fp ⚠️ backs off ============ out="$(run 2>&1)" @@ -180,6 +184,8 @@ check "run2: window-rouse re-pings 🔧 #300" "grep -q '^cccccccc' <<<\"\$pings2 check "run2: same-fp asleep ⚠️ #400 does NOT re-ping (backoff)" "! grep -q '^dddddddd' <<<\"\$pings2\"" check "run2: thinking ⚠️ #600 still not pinged" "! grep -q '^ffffffff' <<<\"\$pings2\"" check "run2: thinking skip listed" "grep -q 'ffffffff' <<<\"\$out\" && grep -qiE 'thinking|SKIPPED' <<<\"\$out\"" +think_fp2="$(jq -r '.sessions["ffffffff-6666"].fp // empty' "$WORK/state.json")" +check "run2: thinking skip still leaves fingerprint undelivered" "[[ -z \"\$think_fp2\" ]]" check "run2: backoff skip listed" "grep -qi 'backoff' <<<\"\$out\"" check "run2: ✅ #200 stays silent (not work-state)" "! grep -q '^bbbbbbbb' <<<\"\$pings2\"" check "run2: still lists warn #100 in queue" "grep -q '#100' <<<\"\$out\"" diff --git a/scripts/tooling/install-hapi-systemd-units.sh b/scripts/tooling/install-hapi-systemd-units.sh index ca725ac9b1..38b3bbd331 100755 --- a/scripts/tooling/install-hapi-systemd-units.sh +++ b/scripts/tooling/install-hapi-systemd-units.sh @@ -18,6 +18,11 @@ # --hapi-user hapi --workspace-root /work # bash scripts/tooling/install-hapi-systemd-units.sh --profile user-pet # +# Also installs the Claude OAuth EnvironmentFile drop-in (42-claude-oauth-token.conf) +# so runner-spawned Claude sessions inherit CLAUDE_CODE_OAUTH_TOKEN. UI spawn does +# not inject options.token — see lib/hapi-claude-oauth-dropin.sh. +# System profiles require python3 on PATH (O_NOFOLLOW migrate/chmod of /etc/hapi). +# # Estate-local drop-ins (cursor auth, work-cache, upload-heal) stay in # /etc/systemd/system/*.service.d/ and are never overwritten by this script. @@ -28,6 +33,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" source "$REPO_ROOT/scripts/tooling/lib/render-hapi-systemd-unit.sh" # shellcheck source=lib/hapi-systemd-units.sh source "$REPO_ROOT/scripts/tooling/lib/hapi-systemd-units.sh" +# shellcheck source=lib/hapi-claude-oauth-dropin.sh +source "$REPO_ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh" PROFILE="" UNITS_ONLY=0 @@ -150,6 +157,13 @@ if [[ "$NEED_ROOT" -eq 1 ]] && [[ "$(id -u)" -ne 0 ]]; then exit 1 fi +# Fail before touching units: system OAuth migrate/chmod needs python3 O_NOFOLLOW. +if [[ "$NEED_ROOT" -eq 1 ]] && ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required for profile $PROFILE (Claude OAuth drop-in under /etc/hapi)" >&2 + echo " Install python3, then re-run: sudo bash $0 --profile $PROFILE ..." >&2 + exit 1 +fi + user_home="$(getent passwd "$HAPI_USER" | cut -d: -f6 || echo "$HOME")" HAPI_PATH="${HAPI_PATH:-$(hapi_systemd_default_path "$user_home")}" WORKSPACE_ROOT_ARGS="$(hapi_systemd_format_workspace_args "${WORKSPACE_ROOTS[@]}")" @@ -212,10 +226,62 @@ render_pair() { TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT +# True when MainPID still has CLAUDE_CODE_OAUTH_TOKEN but $1 (canonical token +# file) has no effective assignment. Used before enabling/restoring watchdog. +hapi_system_runner_ambient_oauth_unpersisted() { + local token_file="${1:?token_file}" + local unit="${2:?unit}" + if hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + return 1 + fi + local runner_main_pid inspect_rc + runner_main_pid="$(systemctl show -p MainPID --value "$unit" 2>/dev/null || echo 0)" + set +e + hapi_claude_oauth_mainpid_has_oauth_token "$runner_main_pid" + inspect_rc=$? + set -e + case "$inspect_rc" in + 0) return 0 ;; + 2) + echo "ERROR: cannot inspect runner MainPID=$runner_main_pid environ for ambient OAuth — refusing restart" >&2 + return 0 + ;; + *) return 1 ;; + esac +} + case "$PROFILE" in primary-soup|fleet-binary) HUB_DST="/etc/systemd/system/$HUB_UNIT" RUNNER_DST="/etc/systemd/system/$RUNNER_UNIT" + # Quiesce a pre-existing watchdog before rewriting units / migrate. + # stop (not disable): the timer stays enabled; we only prevent a fire + # during the window where EnvironmentFile may be unwired. A failed + # migrate exits with the timer left stopped (fail-closed). + WD_TIMER_WAS_ACTIVE=0 + if systemctl is-active --quiet hapi-runner-watchdog.timer 2>/dev/null; then + WD_TIMER_WAS_ACTIVE=1 + fi + if systemctl cat hapi-runner-watchdog.timer >/dev/null 2>&1; then + if ! systemctl stop hapi-runner-watchdog.timer; then + echo "ERROR: failed to stop hapi-runner-watchdog.timer before unit rewrite" >&2 + exit 1 + fi + if systemctl is-active --quiet hapi-runner-watchdog.timer; then + echo "ERROR: hapi-runner-watchdog.timer still active after stop" >&2 + exit 1 + fi + fi + if systemctl cat hapi-runner-watchdog.service >/dev/null 2>&1; then + if ! systemctl stop hapi-runner-watchdog.service; then + echo "ERROR: failed to stop hapi-runner-watchdog.service before unit rewrite" >&2 + exit 1 + fi + if systemctl is-active --quiet hapi-runner-watchdog.service; then + echo "ERROR: hapi-runner-watchdog.service still active after stop" >&2 + exit 1 + fi + fi render_pair \ "$TEMPLATE_DIR/$HUB_UNIT.in" \ "$TEMPLATE_DIR/$RUNNER_UNIT.in" \ @@ -229,6 +295,51 @@ case "$PROFILE" in if [[ "$DO_ENABLE" -eq 1 ]]; then systemctl enable "$HUB_UNIT" "$RUNNER_UNIT" fi + # System-scope token is always root-controlled /etc/hapi/claude-setup-token.env + # (NOT under service-writable HAPI_HOME or operator ~/.hapi). Compromised + # runner + passwordless Restart must not retarget EnvironmentFile at other + # root-readable secrets. primary-soup 2026-08-25 hand-install under + # $OPERATOR_HOME/.hapi/ is a one-time migrate (WARN from the drop-in). + # Run migrate/drop-in BEFORE Tier-1: the watchdog timer can restart the + # runner; a failed migrate must not leave watchdog able to discard ambient + # auth before EnvironmentFile exists. + CLAUDE_TOKEN_FILE="$(hapi_claude_oauth_system_token_file)" + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=0 + # primary-soup: pass configured operator home (not sudoer's HOME=/root). + DROPIN_ARGS=( + --scope system + --runner-unit "$RUNNER_UNIT" + --token-file "$CLAUDE_TOKEN_FILE" + --migrate-profile "$PROFILE" + --migrate-hapi-home "$HAPI_HOME" + ) + if [[ "$PROFILE" == primary-soup ]]; then + DROPIN_ARGS+=(--migrate-operator-home "${OOS_OPERATOR_HOME:-/home/heavygee}") + fi + set +e + hapi_install_claude_oauth_dropin "${DROPIN_ARGS[@]}" + dropin_rc=$? + set -e + # Fail closed on config-only installs too: pending migrate must not leave + # a drop-in pointed at an empty/missing /etc/hapi token. + if [[ "$dropin_rc" -ne 0 || "${HAPI_CLAUDE_OAUTH_MIGRATE_PENDING:-0}" -eq 1 ]]; then + echo "ERROR: Claude OAuth drop-in install failed or migrate still pending ($CLAUDE_TOKEN_FILE)" >&2 + echo " Resolve legacy token ambiguity / symlink, then re-run." >&2 + echo " hapi-runner-watchdog.timer was stopped for this upgrade and left stopped." >&2 + exit 1 + fi + if hapi_system_runner_ambient_oauth_unpersisted "$CLAUDE_TOKEN_FILE" "$RUNNER_UNIT"; then + echo "ERROR: runner has ambient CLAUDE_CODE_OAUTH_TOKEN but $CLAUDE_TOKEN_FILE is missing/empty" >&2 + echo " Persist the token before enabling the watchdog or you will discard the only credential." >&2 + echo " hapi-runner-watchdog.timer was stopped for this upgrade and left stopped." >&2 + exit 1 + fi + if hapi_claude_oauth_merged_env_discards_token "$RUNNER_UNIT"; then + echo "ERROR: merged $RUNNER_UNIT environment would override or unset CLAUDE_CODE_OAUTH_TOKEN" >&2 + echo " Fix later EnvironmentFiles / UnsetEnvironment before enabling the watchdog." >&2 + echo " hapi-runner-watchdog.timer was stopped for this upgrade and left stopped." >&2 + exit 1 + fi if [[ "$UNITS_ONLY" -eq 0 ]]; then # Pass the binary this profile just installed, so Tier-1's # ExecStartPre stop is valid on THIS host. Without it Tier-1 would @@ -260,12 +371,65 @@ case "$PROFILE" in bash "$REPO_ROOT/scripts/tooling/install-hapi-primary-hub-tier1.sh" "${TIER1_ARGS[@]}" fi if [[ "$DO_RESTART" -eq 1 ]]; then + # Fail closed: missing/ineffective canonical while a legacy token still + # exists, OR an empty canonical file that systemd would load as blank. + block_restart=0 + op_home="" + [[ "$PROFILE" == primary-soup ]] && op_home="${OOS_OPERATOR_HOME:-/home/heavygee}" + if hapi_claude_oauth_has_effective_token "$CLAUDE_TOKEN_FILE" 2>/dev/null; then + : + else + if [[ -e "$CLAUDE_TOKEN_FILE" || -L "$CLAUDE_TOKEN_FILE" ]]; then + block_restart=1 + fi + while IFS= read -r legacy_probe; do + if [[ -e "$legacy_probe" || -L "$legacy_probe" ]]; then + block_restart=1 + break + fi + done < <(hapi_claude_oauth_legacy_system_token_candidates "$PROFILE" "$HAPI_HOME" "$op_home") + if hapi_system_runner_ambient_oauth_unpersisted "$CLAUDE_TOKEN_FILE" "$RUNNER_UNIT"; then + block_restart=1 + echo "ERROR: runner has ambient CLAUDE_CODE_OAUTH_TOKEN but $CLAUDE_TOKEN_FILE is missing/empty" >&2 + echo " Persist the token to $CLAUDE_TOKEN_FILE before --restart or you will discard the only credential." >&2 + fi + fi + if [[ "$block_restart" -eq 1 ]]; then + echo "ERROR: refusing --restart until Claude OAuth token is effective at $CLAUDE_TOKEN_FILE" >&2 + echo " Legacy path still present, empty canonical, ambient-only token, or migrate failed." >&2 + echo " Migrate with: hapi_claude_oauth_secure_copy_regular_file <legacy> $CLAUDE_TOKEN_FILE" >&2 + exit 1 + fi if [[ -x /home/heavygee/.local/bin/hapi-restart-hub ]]; then sudo -u heavygee -H /home/heavygee/.local/bin/hapi-restart-hub else systemctl restart "$HUB_UNIT" "$RUNNER_UNIT" fi fi + # --units-only skips Tier-1 (which restarts the timer). After a successful + # migrate, put back a timer we quiesced. + if [[ "$UNITS_ONLY" -eq 1 && "${WD_TIMER_WAS_ACTIVE:-0}" -eq 1 ]]; then + if hapi_system_runner_ambient_oauth_unpersisted "$CLAUDE_TOKEN_FILE" "$RUNNER_UNIT"; then + echo "ERROR: runner has ambient CLAUDE_CODE_OAUTH_TOKEN but $CLAUDE_TOKEN_FILE is missing/empty" >&2 + echo " Persist the token before restoring the watchdog timer." >&2 + echo " hapi-runner-watchdog.timer was left stopped." >&2 + exit 1 + fi + if hapi_claude_oauth_merged_env_discards_token "$RUNNER_UNIT"; then + echo "ERROR: merged $RUNNER_UNIT environment would override or unset CLAUDE_CODE_OAUTH_TOKEN" >&2 + echo " hapi-runner-watchdog.timer was left stopped." >&2 + exit 1 + fi + if ! systemctl start hapi-runner-watchdog.timer; then + echo "ERROR: failed to restore hapi-runner-watchdog.timer after --units-only" >&2 + echo " Timer was active before this upgrade and was left stopped." >&2 + exit 1 + fi + if ! systemctl is-active --quiet hapi-runner-watchdog.timer; then + echo "ERROR: hapi-runner-watchdog.timer did not become active after restore" >&2 + exit 1 + fi + fi ;; user-pet) USER_UNIT_DIR="$HOME/.config/systemd/user" @@ -280,14 +444,77 @@ case "$PROFILE" in systemctl --user daemon-reload echo "Installed: $USER_UNIT_DIR/hapi-hub.service" echo "Installed: $USER_UNIT_DIR/hapi-runner.service" + CLAUDE_TOKEN_FILE="$(hapi_claude_oauth_default_token_file "$HAPI_HOME")" + hapi_install_claude_oauth_dropin \ + --scope user \ + --runner-unit hapi-runner.service \ + --token-file "$CLAUDE_TOKEN_FILE" \ + --unit-dir "$USER_UNIT_DIR" + # Ambient-only guard (mirrors system-profile --restart): if the runner + # still carries CLAUDE_CODE_OAUTH_TOKEN in MainPID environ but no durable + # file exists to reload, refuse restart so we do not force /login. + hapi_user_pet_refuse_ambient_only_restart() { + local token_file="$1" + if hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + return 0 + fi + local legacy_token + legacy_token="$(dirname "$token_file")/.hapi/claude-setup-token.env" + if hapi_claude_oauth_has_effective_token "$legacy_token" 2>/dev/null; then + return 0 + fi + local runner_main_pid inspect_rc + runner_main_pid="$(systemctl --user show -p MainPID --value hapi-runner.service 2>/dev/null || echo 0)" + set +e + hapi_claude_oauth_mainpid_has_oauth_token "$runner_main_pid" + inspect_rc=$? + set -e + case "$inspect_rc" in + 0) + echo "ERROR: user-pet runner MainPID=$runner_main_pid has ambient CLAUDE_CODE_OAUTH_TOKEN but $token_file is missing/empty" >&2 + echo " Persist the token to $token_file before restart or you will discard the only credential." >&2 + return 1 + ;; + 2) + echo "ERROR: cannot inspect user-pet runner MainPID=$runner_main_pid environ for ambient OAuth — refusing restart" >&2 + return 1 + ;; + esac + return 0 + } if [[ "$DO_ENABLE" -eq 1 ]]; then loginctl enable-linger "$(id -un)" 2>/dev/null || true systemctl --user enable hapi-hub.service hapi-runner.service - systemctl --user start hapi-hub.service hapi-runner.service + # Same race as the embedded pet path: pgrep kill + Restart=always can + # respawn the old hub/runner binary before INSTALL_DIR is swapped; + # systemctl start is then a no-op for already-active units. + hapi_user_pet_refuse_ambient_only_restart "$CLAUDE_TOKEN_FILE" || exit 1 + if hapi_claude_oauth_merged_env_discards_token hapi-runner.service "$CLAUDE_TOKEN_FILE" --user; then + echo "ERROR: merged user hapi-runner.service environment would override or unset CLAUDE_CODE_OAUTH_TOKEN" >&2 + exit 1 + fi + systemctl --user restart hapi-hub.service + systemctl --user restart hapi-runner.service + elif [[ "$DO_RESTART" -eq 1 ]]; then + # systemctl restart starts inactive units (try-restart would skip). + hapi_user_pet_refuse_ambient_only_restart "$CLAUDE_TOKEN_FILE" || exit 1 + if hapi_claude_oauth_merged_env_discards_token hapi-runner.service "$CLAUDE_TOKEN_FILE" --user; then + echo "ERROR: merged user hapi-runner.service environment would override or unset CLAUDE_CODE_OAUTH_TOKEN" >&2 + exit 1 + fi + systemctl --user restart hapi-hub.service + systemctl --user restart hapi-runner.service fi + unset -f hapi_user_pet_refuse_ambient_only_restart ;; esac echo echo "Profile: $PROFILE" echo "Verify: bash $REPO_ROOT/scripts/tooling/verify-hapi-systemd-units.sh" +if [[ "$PROFILE" == user-pet ]]; then + echo " bash $REPO_ROOT/scripts/tooling/verify-hapi-install.sh --skip-restart" +else + echo " sudo bash $REPO_ROOT/scripts/tooling/verify-hapi-install.sh --skip-restart" + echo " (system-scope OAuth file is root:root 0600; verify as root)" +fi diff --git a/scripts/tooling/lib/hapi-claude-oauth-dropin.sh b/scripts/tooling/lib/hapi-claude-oauth-dropin.sh new file mode 100755 index 0000000000..60e05be0d5 --- /dev/null +++ b/scripts/tooling/lib/hapi-claude-oauth-dropin.sh @@ -0,0 +1,2251 @@ +#!/usr/bin/env bash +# hapi-claude-oauth-dropin.sh — install the runner Claude OAuth EnvironmentFile drop-in. +# +# UI machine spawn does NOT send options.token. New Claude sessions inherit the +# runner process environment. Without CLAUDE_CODE_OAUTH_TOKEN in that env (via +# systemd EnvironmentFile), fresh sessions print "Not logged in · Please run /login" +# while long-lived --resume children keep working and hide the gap. +# +# Canon: docs/plans/2026-09-04-fleet-vm-swap-strategy.md §1 (2026-10-01 row). +# oos-linux's 42-claude-oauth-token.conf (2026-08-25) was hand-installed; this +# library makes the same shape a first-class install step for fleet + pet. +# +# Usage (sourced): +# source scripts/tooling/lib/hapi-claude-oauth-dropin.sh +# hapi_install_claude_oauth_dropin \ +# --scope system|user \ +# --runner-unit hapi-runner.service \ +# --token-file /etc/hapi/claude-setup-token.env \ +# [--owner hapi:hapi] # only for non-/etc paths; ignored under /etc/ +# +# Idempotent. Does not mint Anthropic tokens. If the env file is missing, prints +# a clear one-time setup instruction and still installs the drop-in (systemd's +# EnvironmentFile=- form ignores a missing file so the unit still starts). + +# System-scope fleet: root-controlled path. systemd reads EnvironmentFile as root +# before dropping to User=; the service account must NOT be able to replace this +# file with a symlink to other root-readable secrets (Codex P1). +hapi_claude_oauth_system_token_file() { + printf '%s' "/etc/hapi/claude-setup-token.env" +} + +# User-scope / pet: token lives next to HAPI_HOME (operator-owned tree). +hapi_claude_oauth_default_token_file() { + local hapi_home="${1:?hapi_home}" + printf '%s' "$hapi_home/claude-setup-token.env" +} + +hapi_print_claude_oauth_setup_instructions() { + local token_file="${1:?token_file}" + local runner_unit="${2:-hapi-runner.service}" + local scope="${3:-system}" + local restart_cmd + case "$scope" in + user) restart_cmd="systemctl --user restart ${runner_unit}" ;; + *) restart_cmd="sudo systemctl restart ${runner_unit}" ;; + esac + if [[ "$scope" == system ]]; then + cat <<EOF + +==> Claude OAuth for runner-spawned sessions is NOT configured yet. + New UI / machine-spawn Claude sessions will print: + Not logged in · Please run /login + Existing --resume sessions can keep working and hide this gap. + + One-time setup (privileged write - file stays root:root 0600 under /etc): + claude setup-token + sudo install -d -m 0755 "$(dirname "$token_file")" + printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\\n' '<token>' \\ + | sudo install -m 0600 /dev/stdin '$token_file' + And reload the runner: + $restart_cmd + + Drop-in already points at: $token_file + Prerequisite: python3 on PATH (system install uses it for O_NOFOLLOW migrate/chmod). +EOF + else + cat <<EOF + +==> Claude OAuth for runner-spawned sessions is NOT configured yet. + New UI / machine-spawn Claude sessions will print: + Not logged in · Please run /login + Existing --resume sessions can keep working and hide this gap. + + One-time setup (as the runner account, with a browser available): + claude setup-token + Then write the printed token (do not commit it): + umask 077 + printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\\n' '<token>' > '$token_file' + chmod 600 '$token_file' + And reload the runner: + $restart_cmd + + Drop-in already points at: $token_file +EOF + fi +} + +hapi_claude_oauth_assert_safe_token_file() { + # Refuse symlinks / non-regular files before root chmod/chown. A compromised + # runner account could point claude-setup-token.env at /etc/sudoers; [[ -f ]], + # chmod, and chown all follow symlinks. + local token_file="${1:?token_file}" + if [[ -L "$token_file" ]]; then + echo "ERROR: refusing symlink token file: $token_file" >&2 + echo " Remove the symlink and write a regular file (0600)." >&2 + return 1 + fi + if [[ -e "$token_file" && ! -f "$token_file" ]]; then + echo "ERROR: refusing non-regular token file: $token_file" >&2 + return 1 + fi + return 0 +} + +# O_NOFOLLOW only protects the final component. Reject any symlink ancestor so a +# fleet account cannot point .../.hapi at an operator directory before migrate. +hapi_claude_oauth_assert_no_symlink_ancestors() { + local path="${1:?path}" + local cur="" part + if [[ "$path" != /* ]]; then + path="$(pwd -P 2>/dev/null || pwd)/$path" + fi + cur="" + IFS=/ read -r -a _hapi_oauth_parts <<<"${path#/}" || true + for part in "${_hapi_oauth_parts[@]}"; do + [[ -n "$part" ]] || continue + cur="${cur}/${part}" + if [[ -L "$cur" ]]; then + echo "ERROR: refusing symlink path component: $cur (in $path)" >&2 + unset _hapi_oauth_parts + return 1 + fi + done + unset _hapi_oauth_parts + return 0 +} + +# Copy src → dst without preserving symlinks. Opens src with O_NOFOLLOW, writes a +# brand-new regular file at dst (O_CREAT|O_EXCL|O_NOFOLLOW on a temp, then rename). +# Never use `cp -a` for legacy migrate — that re-creates attacker symlinks under /etc. +hapi_claude_oauth_secure_copy_regular_file() { + local src="${1:?src}" + local dst="${2:?dst}" + if [[ -L "$src" ]]; then + echo "ERROR: refusing to copy symlink source token: $src" >&2 + return 1 + fi + if [[ ! -f "$src" ]]; then + echo "ERROR: source token is not a regular file: $src" >&2 + return 1 + fi + hapi_claude_oauth_assert_no_symlink_ancestors "$src" || return 1 + if command -v python3 >/dev/null 2>&1; then + python3 - "$src" "$dst" <<'PY' +import os, stat, sys + +# Credential-sized bound: OAuth setup-tokens are a few KB; refuse multi-MB traps. +MAX_TOKEN_BYTES = 64 * 1024 + +def write_all(fd, data): + mv = memoryview(data) + while len(mv): + n = os.write(fd, mv) + if n <= 0: + raise OSError("write returned %d with %d bytes remaining" % (n, len(mv))) + mv = mv[n:] + +def open_via_nofollow_dirfds(path, flags, mode=0o600): + """Open path relative to no-follow directory descriptors (no symlink TOCTOU).""" + path = os.path.abspath(path) + parts = [p for p in path.split(os.sep) if p] + if not parts: + raise OSError("empty path") + if any(p in (".", "..") for p in parts): + raise OSError("refusing . or .. path component in %s" % path) + o_nofollow = getattr(os, "O_NOFOLLOW", 0) + o_directory = getattr(os, "O_DIRECTORY", 0) + o_path = getattr(os, "O_PATH", 0) + dir_fd = os.open("/", os.O_RDONLY | o_directory) + try: + for part in parts[:-1]: + # O_PATH|O_NOFOLLOW opens the component itself; fstat catches symlinks + # even when the target is a directory (O_DIRECTORY|O_NOFOLLOW alone + # can still follow a symlink-to-dir on some kernels). + if o_path: + next_fd = os.open(part, o_path | o_nofollow, dir_fd=dir_fd) + else: + next_fd = os.open(part, os.O_RDONLY | o_directory | o_nofollow, dir_fd=dir_fd) + try: + st = os.fstat(next_fd) + if stat.S_ISLNK(st.st_mode): + raise OSError("refusing symlink path component: %s (in %s)" % (part, path)) + if not stat.S_ISDIR(st.st_mode): + raise OSError("path component is not a directory: %s (in %s)" % (part, path)) + except Exception: + os.close(next_fd) + raise + os.close(dir_fd) + dir_fd = next_fd + leaf = parts[-1] + return os.open(leaf, flags | o_nofollow, mode, dir_fd=dir_fd), dir_fd + except Exception: + os.close(dir_fd) + raise + +src, dst = sys.argv[1], sys.argv[2] +rd_flags = os.O_RDONLY +if hasattr(os, "O_NONBLOCK"): + rd_flags |= os.O_NONBLOCK +try: + sfd, src_dir_fd = open_via_nofollow_dirfds(src, rd_flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open source token without following symlink: %s: %s\n" % (src, exc)) + sys.exit(1) +try: + mode = os.fstat(sfd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular source token: %s\n" % src) + sys.exit(1) + size = os.fstat(sfd).st_size + if size > MAX_TOKEN_BYTES: + sys.stderr.write( + "ERROR: legacy token too large (%d bytes > %d); refusing migrate of %s\n" + % (size, MAX_TOKEN_BYTES, src) + ) + sys.exit(1) + data = b"" + while True: + chunk = os.read(sfd, 8192) + if not chunk: + break + if len(data) + len(chunk) > MAX_TOKEN_BYTES: + sys.stderr.write( + "ERROR: legacy token read exceeded %d bytes; refusing migrate of %s\n" + % (MAX_TOKEN_BYTES, src) + ) + sys.exit(1) + data += chunk +finally: + os.close(sfd) + os.close(src_dir_fd) + +parent = os.path.dirname(dst) or "." +os.makedirs(parent, mode=0o755, exist_ok=True) +tmp = dst + ".tmp.%d" % os.getpid() +try: + if os.path.lexists(tmp): + os.unlink(tmp) +except OSError: + pass +wr_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL +if hasattr(os, "O_NOFOLLOW"): + wr_flags |= os.O_NOFOLLOW +try: + dfd = os.open(tmp, wr_flags, 0o600) +except OSError as exc: + sys.stderr.write("ERROR: cannot create destination token: %s: %s\n" % (tmp, exc)) + sys.exit(1) +try: + write_all(dfd, data) + os.fchmod(dfd, 0o600) +except OSError as exc: + os.close(dfd) + try: + os.unlink(tmp) + except OSError: + pass + sys.stderr.write("ERROR: incomplete write to %s: %s\n" % (tmp, exc)) + sys.exit(1) +os.close(dfd) +os.replace(tmp, dst) +sys.exit(0) +PY + return $? + fi + + # Bash fallback for same-user pet hosts without python3. Never for /etc + # (system migrate requires python3 for O_NOFOLLOW root writes). + case "$dst" in + /etc/*) + echo "ERROR: python3 required for secure token copy into /etc ($src -> $dst)" >&2 + return 1 + ;; + esac + if [[ -L "$dst" ]]; then + echo "ERROR: refusing to overwrite symlink destination: $dst" >&2 + return 1 + fi + local parent tmp + parent="${dst%/*}" + if [[ -z "$parent" || "$parent" == "$dst" ]]; then + parent="." + fi + # Absolute coreutils: pet PATH may omit /usr/bin while still lacking python3. + /bin/mkdir -p "$parent" || return 1 + tmp="$(/usr/bin/mktemp "${parent}/.claude-oauth-copy.XXXXXX")" || return 1 + if ! /bin/cp -f -- "$src" "$tmp"; then + /bin/rm -f -- "$tmp" + return 1 + fi + if [[ -L "$tmp" ]]; then + /bin/rm -f -- "$tmp" + echo "ERROR: temp copy became a symlink: $tmp" >&2 + return 1 + fi + /bin/chmod 600 "$tmp" || { /bin/rm -f -- "$tmp"; return 1; } + if ! /bin/mv -f -- "$tmp" "$dst"; then + /bin/rm -f -- "$tmp" + return 1 + fi + return 0 +} + +# After a verified migrate, archive the legacy source so a later empty/missing +# canonical cannot silently re-import a revoked credential on reinstall. +hapi_claude_oauth_retire_legacy_token_source() { + local src="${1:?src}" + if [[ -L "$src" ]]; then + echo "ERROR: refusing to retire symlink legacy token: $src" >&2 + return 1 + fi + if [[ ! -f "$src" ]]; then + return 0 + fi + hapi_claude_oauth_assert_no_symlink_ancestors "$src" || return 1 + local dest="${src}.migrated.$(date +%s)" + if command -v python3 >/dev/null 2>&1; then + # Explicit status check: callers may invoke us under `cmd || warn`, which + # suppresses errexit so a failed rename must not fall through to success. + if ! python3 - "$src" "$dest" <<'PY' +import os, stat, sys + +def open_parent_dirfd(path): + """Return (dir_fd, leaf) after walking ancestors with no-follow dirfds.""" + path = os.path.abspath(path) + parts = [p for p in path.split(os.sep) if p] + if not parts: + raise OSError("empty path") + if any(p in (".", "..") for p in parts): + raise OSError("refusing . or .. path component in %s" % path) + o_nofollow = getattr(os, "O_NOFOLLOW", 0) + o_directory = getattr(os, "O_DIRECTORY", 0) + o_path = getattr(os, "O_PATH", 0) + dir_fd = os.open("/", os.O_RDONLY | o_directory) + try: + for part in parts[:-1]: + if o_path: + next_fd = os.open(part, o_path | o_nofollow, dir_fd=dir_fd) + else: + next_fd = os.open(part, os.O_RDONLY | o_directory | o_nofollow, dir_fd=dir_fd) + try: + st = os.fstat(next_fd) + if stat.S_ISLNK(st.st_mode): + raise OSError("refusing symlink path component: %s (in %s)" % (part, path)) + if not stat.S_ISDIR(st.st_mode): + raise OSError("path component is not a directory: %s (in %s)" % (part, path)) + except Exception: + os.close(next_fd) + raise + os.close(dir_fd) + dir_fd = next_fd + return dir_fd, parts[-1] + except Exception: + os.close(dir_fd) + raise + +src, dest = sys.argv[1], sys.argv[2] +try: + dir_fd, leaf = open_parent_dirfd(src) +except OSError as exc: + sys.stderr.write("ERROR: cannot open legacy token to retire: %s: %s\n" % (src, exc)) + sys.exit(1) +try: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(leaf, flags, dir_fd=dir_fd) + except OSError as exc: + sys.stderr.write("ERROR: cannot open legacy token to retire: %s: %s\n" % (src, exc)) + sys.exit(1) + try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular legacy token: %s\n" % src) + sys.exit(1) + finally: + os.close(fd) + dest_leaf = os.path.basename(os.path.abspath(dest)) + # Dest must stay in the same verified parent (no cross-dir retire). + if os.path.dirname(os.path.abspath(dest)) != os.path.dirname(os.path.abspath(src)): + sys.stderr.write("ERROR: retire destination must share parent with source: %s\n" % dest) + sys.exit(1) + try: + os.stat(dest_leaf, dir_fd=dir_fd, follow_symlinks=False) + sys.stderr.write("ERROR: retire destination already exists: %s\n" % dest) + sys.exit(1) + except FileNotFoundError: + pass + os.rename(leaf, dest_leaf, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) + try: + dest_fd = os.open(dest_leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=dir_fd) + except OSError as exc: + sys.stderr.write("ERROR: cannot chmod retired legacy token: %s: %s\n" % (dest, exc)) + sys.exit(1) + try: + os.fchmod(dest_fd, 0o600) + except OSError as exc: + sys.stderr.write("ERROR: cannot chmod retired legacy token: %s: %s\n" % (dest, exc)) + sys.exit(1) + finally: + os.close(dest_fd) +finally: + os.close(dir_fd) +sys.exit(0) +PY + then + return 1 + fi + else + # Pet / minimal hosts: already refused symlinks; mv is enough. + mv -n -- "$src" "$dest" || return 1 + /bin/chmod 600 "$dest" || return 1 + fi + echo "Retired legacy Claude OAuth token: $src -> $dest" + return 0 +} + +# Unlink a regular file without following a final-component symlink. +# Used to roll back a newly created canonical token when no backup exists. +hapi_claude_oauth_secure_unlink_regular_file() { + local path="${1:?path}" + if [[ -L "$path" ]]; then + echo "ERROR: refusing to unlink symlink token: $path" >&2 + return 1 + fi + if [[ ! -e "$path" ]]; then + return 0 + fi + if [[ ! -f "$path" ]]; then + echo "ERROR: refusing to unlink non-regular token: $path" >&2 + return 1 + fi + python3 - "$path" <<'PY' +import os, stat, sys +path = sys.argv[1] +flags = 0 +if hasattr(os, "O_PATH"): + flags |= os.O_PATH +elif hasattr(os, "O_RDONLY"): + flags |= os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open token to unlink: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular token unlink: %s\n" % path) + sys.exit(1) +finally: + os.close(fd) +try: + os.unlink(path) +except OSError as exc: + sys.stderr.write("ERROR: cannot unlink token %s: %s\n" % (path, exc)) + sys.exit(1) +sys.exit(0) +PY +} + +# Privileged unlink: absolute python3 + sanitized env (same constraints as install). +hapi_claude_oauth_secure_unlink_regular_file_via_sudo() { + local path="${1:?path}" + local py + py="$(hapi_claude_oauth_absolute_python3)" || { + echo "ERROR: absolute python3 required for privileged token unlink" >&2 + return 1 + } + sudo /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$py" - "$path" <<'PY' +import os, stat, sys +path = sys.argv[1] +if os.path.islink(path): + sys.stderr.write("ERROR: refusing to unlink symlink token: %s\n" % path) + sys.exit(1) +if not os.path.exists(path): + sys.exit(0) +flags = 0 +if hasattr(os, "O_PATH"): + flags |= os.O_PATH +elif hasattr(os, "O_RDONLY"): + flags |= os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open token to unlink: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular token unlink: %s\n" % path) + sys.exit(1) +finally: + os.close(fd) +try: + os.unlink(path) +except OSError as exc: + sys.stderr.write("ERROR: cannot unlink token %s: %s\n" % (path, exc)) + sys.exit(1) +sys.exit(0) +PY +} + +# Emit src bytes to stdout (O_NOFOLLOW). Used so privileged writers consume an +# already-opened pipe instead of reopening a mutable pathname as root. +hapi_claude_oauth_cat_regular_file() { + local src="${1:?src}" + python3 - "$src" <<'PY' +import os, stat, sys +path = sys.argv[1] +flags = os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +if hasattr(os, "O_NONBLOCK"): + flags |= os.O_NONBLOCK +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open token without following symlink: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular token: %s\n" % path) + sys.exit(1) + while True: + chunk = os.read(fd, 65536) + if not chunk: + break + sys.stdout.buffer.write(chunk) +finally: + os.close(fd) +sys.exit(0) +PY +} + +# Python program for install_bytes (shared by unprivileged + sudo paths). +# Kept as a function so privileged install never re-sources this checkout. +_hapi_claude_oauth_install_bytes_py() { + cat <<'PY' +import os, sys +dst = sys.argv[1] +data = sys.stdin.buffer.read() +if not data: + sys.stderr.write("ERROR: empty token payload; refusing to replace %s\n" % dst) + sys.exit(1) + +def parse_value(raw): + # systemd env-file.c: PRE_VALUE skips whitespace; quotes concatenate + # with following unquoted segments ("abc"def -> abcdef). + if raw is None: + return None + val = raw.strip() + if not val: + return None + out = bytearray() + i = 0 + state = "pre" + while i < len(val): + c = val[i:i+1] + if state == "pre": + if c in b" \t\r": + i += 1 + continue + if c == b"'": + state = "sq" + i += 1 + continue + if c == b'"': + state = "dq" + i += 1 + continue + if c == b"\\": + state = "val" + if i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + else: + out.extend(c) + i += 1 + continue + state = "val" + out.extend(c) + i += 1 + continue + if state == "sq": + if c == b"'": + state = "pre" + else: + out.extend(c) + i += 1 + continue + if state == "dq": + if c == b"\\" and i + 1 < len(val): + nxt = val[i+1:i+2] + if nxt in (b"\\", b'"', b"`", b"$"): + out.extend(nxt) + else: + out.extend(b"\\") + out.extend(nxt) + i += 2 + continue + if c == b'"': + state = "pre" + i += 1 + continue + out.extend(c) + i += 1 + continue + if c == b"\\" and i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + continue + out.extend(c) + i += 1 + if state in ("sq", "dq"): + return None + return bytes(out) or None + +def env_line_state(buf): + eq = buf.find(b"=") + if eq < 0: + return None, False + val = buf[eq + 1:].lstrip(b" \t") + quote = None + i = 0 + pre = True + while i < len(val): + c = val[i:i+1] + if quote is None: + if pre: + if c in b" \t": + i += 1 + continue + if c == b"'": + quote = b"'" + i += 1 + continue + if c == b'"': + quote = b'"' + i += 1 + continue + if c == b"\\": + pre = False + if i + 1 >= len(val): + return None, True + i += 2 + continue + pre = False + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + return None, True + i += 2 + continue + i += 1 + continue + if quote == b"'": + if c == b"'": + quote = None + pre = True + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + return b'"', True + i += 2 + continue + if c == b'"': + quote = None + pre = True + i += 1 + return quote, False + +def coalesce_env_lines(blob): + physical = [ln.rstrip(b"\r") for ln in blob.splitlines()] + logical = [] + buf = b"" + join_nl = False + for piece in physical: + if not buf: + buf = piece + elif join_nl: + buf += b"\n" + piece + else: + buf += piece + quote, cont_bs = env_line_state(buf) + if cont_bs and buf.endswith(b"\\"): + buf = buf[:-1] + join_nl = False + continue + if quote is not None: + join_nl = True + continue + logical.append(buf) + buf = b"" + join_nl = False + if buf: + logical.append(buf) + return logical + +last = None +for line in coalesce_env_lines(data): + s = line.lstrip(b" \t") + if s.startswith(b"#") or s.startswith(b";"): + continue + eq = s.find(b"=") + if eq < 0: + continue + key = s[:eq].rstrip(b" \t") + if key == b"CLAUDE_CODE_OAUTH_TOKEN": + last = parse_value(s[eq + 1 :]) +if last is None: + sys.stderr.write( + "ERROR: piped payload has no nonempty CLAUDE_CODE_OAUTH_TOKEN=; " + "refusing to replace %s\n" % dst + ) + sys.exit(1) + +parent = os.path.dirname(dst) or "." +os.makedirs(parent, mode=0o755, exist_ok=True) +tmp = dst + ".tmp.%d" % os.getpid() +try: + if os.path.lexists(tmp): + os.unlink(tmp) +except OSError: + pass +flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(tmp, flags, 0o600) +except OSError as exc: + sys.stderr.write("ERROR: cannot create destination token: %s: %s\n" % (tmp, exc)) + sys.exit(1) +try: + mv = memoryview(data) + while len(mv): + n = os.write(fd, mv) + if n <= 0: + raise OSError("write returned %d with %d bytes remaining" % (n, len(mv))) + mv = mv[n:] + os.fchmod(fd, 0o600) +except OSError as exc: + os.close(fd) + try: + os.unlink(tmp) + except OSError: + pass + sys.stderr.write("ERROR: incomplete write to %s: %s\n" % (tmp, exc)) + sys.exit(1) +os.close(fd) +os.replace(tmp, dst) +sys.exit(0) +PY +} + +# Absolute python3 for privileged paths (never PATH-resolve under sudo). +hapi_claude_oauth_absolute_python3() { + local cand + for cand in /usr/bin/python3 /bin/python3; do + if [[ -x "$cand" ]]; then + printf '%s' "$cand" + return 0 + fi + done + return 1 +} + +# Install stdin bytes at dst (temp + os.replace, 0600). Reads already-opened +# stdin so a privileged caller never reopens a user-controlled source path. +# Uses python3 -c (not /dev/fd/N) so `sudo` can close fds >= 3 and still work. +# Refuses empty / ineffective payloads before os.replace so a failed producer +# cannot wipe a valid destination. +hapi_claude_oauth_install_bytes() { + local dst="${1:?dst}" + local prog + prog="$(_hapi_claude_oauth_install_bytes_py)" + python3 -c "$prog" "$dst" +} + +# Privileged install: absolute python3 + sanitized env. Never PATH-resolve bash +# and never source the operator-writable checkout as root. +hapi_claude_oauth_install_bytes_via_sudo() { + local dst="${1:?dst}" + local py prog + py="$(hapi_claude_oauth_absolute_python3)" || { + echo "ERROR: absolute python3 (/usr/bin/python3 or /bin/python3) required for privileged token install" >&2 + return 1 + } + prog="$(_hapi_claude_oauth_install_bytes_py)" + # stdin (token bytes) is preserved across sudo; env is scrubbed. + sudo /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$py" -c "$prog" "$dst" +} + +# Restore stdin bytes at dst without requiring a nonempty OAuth assignment. +# Used for toggle rollback of empty/ineffective prior canonical files. +_hapi_claude_oauth_restore_bytes_py() { + cat <<'PY' +import os, sys +dst = sys.argv[1] +data = sys.stdin.buffer.read() +parent = os.path.dirname(dst) or "." +os.makedirs(parent, mode=0o755, exist_ok=True) +tmp = dst + ".tmp.%d" % os.getpid() +try: + if os.path.lexists(tmp): + os.unlink(tmp) +except OSError: + pass +flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(tmp, flags, 0o600) +except OSError as exc: + sys.stderr.write("ERROR: cannot create restore destination: %s: %s\n" % (tmp, exc)) + sys.exit(1) +try: + if data: + mv = memoryview(data) + while len(mv): + n = os.write(fd, mv) + if n <= 0: + raise OSError("write returned %d with %d bytes remaining" % (n, len(mv))) + mv = mv[n:] + os.fchmod(fd, 0o600) +except OSError as exc: + os.close(fd) + try: + os.unlink(tmp) + except OSError: + pass + sys.stderr.write("ERROR: incomplete restore write to %s: %s\n" % (tmp, exc)) + sys.exit(1) +os.close(fd) +os.replace(tmp, dst) +sys.exit(0) +PY +} + +hapi_claude_oauth_restore_bytes() { + local dst="${1:?dst}" + local prog + prog="$(_hapi_claude_oauth_restore_bytes_py)" + python3 -c "$prog" "$dst" +} + +hapi_claude_oauth_restore_bytes_via_sudo() { + local dst="${1:?dst}" + local py prog + py="$(hapi_claude_oauth_absolute_python3)" || { + echo "ERROR: absolute python3 required for privileged token restore" >&2 + return 1 + } + prog="$(_hapi_claude_oauth_restore_bytes_py)" + sudo /usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$py" -c "$prog" "$dst" +} + +# Known pre-/etc locations, scoped by install profile so fleet cannot silently +# import the primary-soup operator token (and vice versa). Optional second arg is +# the configured HAPI_HOME (fleet --hapi-home). Optional third is the selected +# operator home (primary-soup OOS_OPERATOR_HOME) — never the sudoer's HOME=/root. +hapi_claude_oauth_legacy_system_token_candidates() { + local profile="${1:-}" + local hapi_home="${2:-}" + local operator_home="${3:-}" + local -a paths=() + case "$profile" in + fleet-binary|fleet) + paths=( + /var/lib/hapi/claude-setup-token.env + /var/lib/hapi/.hapi/claude-setup-token.env + ) + if [[ -n "$hapi_home" && "$hapi_home" != /var/lib/hapi ]]; then + paths+=( + "$hapi_home/claude-setup-token.env" + "$hapi_home/.hapi/claude-setup-token.env" + ) + fi + ;; + primary-soup|soup) + local soup_op="${operator_home:-/home/heavygee}" + paths=("${soup_op}/.hapi/claude-setup-token.env") + # Soup HAPI_HOME defaults to /var/lib/hapi (fleet service home). Never + # treat that default as a soup migrate source — only a genuinely + # custom soup home (distinct from operator home AND fleet default). + if [[ -n "$hapi_home" \ + && "$hapi_home" != /var/lib/hapi \ + && "$hapi_home" != "${soup_op}/.hapi" \ + && "$hapi_home" != "$soup_op" ]]; then + paths+=( + "$hapi_home/claude-setup-token.env" + "$hapi_home/.hapi/claude-setup-token.env" + ) + fi + ;; + ""|*) + # No profile: emit both classes. Caller must fail closed on ambiguity. + paths=( + /var/lib/hapi/claude-setup-token.env + /var/lib/hapi/.hapi/claude-setup-token.env + "${operator_home:-/home/heavygee}/.hapi/claude-setup-token.env" + ) + if [[ -n "$hapi_home" ]]; then + paths+=( + "$hapi_home/claude-setup-token.env" + "$hapi_home/.hapi/claude-setup-token.env" + ) + fi + ;; + esac + # De-dupe while preserving order (custom home may equal a default). + local p seen=$'\n' + for p in "${paths[@]}"; do + [[ -n "$p" ]] || continue + if [[ "$seen" != *$'\n'"$p"$'\n'* ]]; then + printf '%s\n' "$p" + seen+="$p"$'\n' + fi + done +} + +# Mirror systemd EnvironmentFile value parsing (systemd.exec(5) / env-file.c): +# PRE_VALUE skips whitespace; quoted segments concatenate with later unquoted +# text ("abc"def -> abcdef). Unquoted quotes after the first non-WS stay literal. +hapi_claude_oauth_parse_env_file_value() { + local raw="${1-}" + raw="${raw%$'\r'}" + raw="${raw#"${raw%%[![:space:]]*}"}" + raw="${raw%"${raw##*[![:space:]]}"}" + [[ -n "$raw" ]] || return 1 + + local out="" i=0 c nxt state=pre + while (( i < ${#raw} )); do + c="${raw:i:1}" + case "$state" in + pre) + if [[ "$c" =~ [[:space:]] ]]; then + i=$((i + 1)) + continue + fi + if [[ "$c" == "'" ]]; then + state=sq + i=$((i + 1)) + continue + fi + if [[ "$c" == '"' ]]; then + state=dq + i=$((i + 1)) + continue + fi + if [[ "$c" == '\' ]]; then + state=val + if (( i + 1 < ${#raw} )); then + out+="${raw:i+1:1}" + i=$((i + 2)) + else + out+='\' + i=$((i + 1)) + fi + continue + fi + state=val + out+="$c" + i=$((i + 1)) + continue + ;; + sq) + if [[ "$c" == "'" ]]; then + state=pre + else + out+="$c" + fi + i=$((i + 1)) + continue + ;; + dq) + if [[ "$c" == '\' && $((i + 1)) -lt ${#raw} ]]; then + nxt="${raw:i+1:1}" + case "$nxt" in + "\\"|'"'|'`'|'$') out+="$nxt" ;; + *) out+="\\$nxt" ;; + esac + i=$((i + 2)) + continue + fi + if [[ "$c" == '"' ]]; then + state=pre + i=$((i + 1)) + continue + fi + out+="$c" + i=$((i + 1)) + continue + ;; + val) + if [[ "$c" == '\' && $((i + 1)) -lt ${#raw} ]]; then + out+="${raw:i+1:1}" + i=$((i + 2)) + continue + fi + out+="$c" + i=$((i + 1)) + continue + ;; + esac + done + [[ "$state" != sq && "$state" != dq ]] || return 1 + [[ -n "$out" ]] || return 1 + printf '%s' "$out" + return 0 +} + +# Bash EnvironmentFile line-state (mirrors env_line_state in the python parser): +# open quote + whether a trailing unquoted/double-quoted backslash continues. +hapi_claude_oauth_env_line_state() { + local buf="${1-}" + HAPI_EF_QUOTE="" + HAPI_EF_CONT_BS=0 + [[ "$buf" == *"="* ]] || return 0 + local val="${buf#*=}" + val="${val#"${val%%[![:space:]]*}"}" + local i=0 c quote="" pre=1 + while (( i < ${#val} )); do + c="${val:i:1}" + if [[ -z "$quote" ]]; then + if [[ "$pre" -eq 1 ]]; then + if [[ "$c" =~ [[:space:]] ]]; then + i=$((i + 1)) + continue + fi + if [[ "$c" == "'" ]]; then + quote="'" + i=$((i + 1)) + continue + fi + if [[ "$c" == '"' ]]; then + quote='"' + i=$((i + 1)) + continue + fi + if [[ "$c" == '\' ]]; then + pre=0 + if (( i + 1 >= ${#val} )); then + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)) + continue + fi + pre=0 + i=$((i + 1)) + continue + fi + if [[ "$c" == '\' ]]; then + if (( i + 1 >= ${#val} )); then + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)) + continue + fi + i=$((i + 1)) + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$c" == "'" ]]; then + quote="" + pre=1 + fi + i=$((i + 1)) + continue + fi + if [[ "$c" == '\' ]]; then + if (( i + 1 >= ${#val} )); then + HAPI_EF_QUOTE='"' + HAPI_EF_CONT_BS=1 + return 0 + fi + i=$((i + 2)) + continue + fi + if [[ "$c" == '"' ]]; then + quote="" + pre=1 + fi + i=$((i + 1)) + done + HAPI_EF_QUOTE="$quote" + HAPI_EF_CONT_BS=0 +} + +# systemd.env-file.c: skip pre-key whitespace, strip trailing key whitespace. +# Sets HAPI_EF_KEY and HAPI_EF_RAW. Return 1 for comments / no assignment. +hapi_claude_oauth_env_line_key() { + local s="${1-}" + HAPI_EF_KEY="" + HAPI_EF_RAW="" + s="${s#"${s%%[![:space:]]*}"}" + case "$s" in + ''|\#*|\;*) return 1 ;; + esac + [[ "$s" == *=* ]] || return 1 + HAPI_EF_KEY="${s%%=*}" + HAPI_EF_KEY="${HAPI_EF_KEY%"${HAPI_EF_KEY##*[![:space:]]}"}" + HAPI_EF_RAW="${s#*=}" + return 0 +} + +# Last CLAUDE_CODE_OAUTH_TOKEN via bash coalesce (quoted newlines + trailing \). +hapi_claude_oauth_bash_last_oauth_token() { + local token_file="${1:?token_file}" + local line raw last="" parsed logical="" join_nl=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + if [[ -z "$logical" ]]; then + logical="$line" + elif [[ "$join_nl" -eq 1 ]]; then + logical+=$'\n'"$line" + else + logical+="$line" + fi + hapi_claude_oauth_env_line_state "$logical" + if [[ "$HAPI_EF_CONT_BS" -eq 1 && "$logical" == *\\ ]]; then + logical="${logical%\\}" + join_nl=0 + continue + fi + if [[ -n "$HAPI_EF_QUOTE" ]]; then + join_nl=1 + continue + fi + if hapi_claude_oauth_env_line_key "$logical" && [[ "$HAPI_EF_KEY" == "CLAUDE_CODE_OAUTH_TOKEN" ]]; then + raw="$HAPI_EF_RAW" + if parsed="$(hapi_claude_oauth_parse_env_file_value "$raw")"; then + last="$parsed" + else + last="" + fi + fi + logical="" + join_nl=0 + done <"$token_file" + if [[ -n "$logical" ]]; then + if hapi_claude_oauth_env_line_key "$logical" && [[ "$HAPI_EF_KEY" == "CLAUDE_CODE_OAUTH_TOKEN" ]]; then + raw="$HAPI_EF_RAW" + if parsed="$(hapi_claude_oauth_parse_env_file_value "$raw")"; then + last="$parsed" + else + last="" + fi + fi + fi + if [[ -z "$last" ]]; then + return 2 + fi + printf '%s' "$last" + return 0 +} + +# Last effective CLAUDE_CODE_OAUTH_TOKEN= value (systemd last-assignment-wins), +# after systemd-compatible unquote/unescape. Empty / whitespace-only → unset (exit 2). +hapi_claude_oauth_effective_token_value() { + local token_file="${1:?token_file}" + if [[ -L "$token_file" ]]; then + echo "ERROR: refusing symlink token file: $token_file" >&2 + return 1 + fi + if [[ ! -f "$token_file" ]]; then + return 1 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$token_file" <<'PY' +import os, stat, sys + +path = sys.argv[1] +flags = os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +if hasattr(os, "O_NONBLOCK"): + flags |= os.O_NONBLOCK +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open token file: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular token file: %s\n" % path) + sys.exit(1) + data = b"" + while True: + chunk = os.read(fd, 65536) + if not chunk: + break + data += chunk +finally: + os.close(fd) + +def parse_value(raw): + # systemd env-file.c: PRE_VALUE skips whitespace; quotes concatenate + # with following unquoted segments ("abc"def -> abcdef). + if raw is None: + return None + val = raw.strip() + if not val: + return None + out = bytearray() + i = 0 + state = "pre" + while i < len(val): + c = val[i:i+1] + if state == "pre": + if c in b" \t\r": + i += 1 + continue + if c == b"'": + state = "sq" + i += 1 + continue + if c == b'"': + state = "dq" + i += 1 + continue + if c == b"\\": + state = "val" + if i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + else: + out.extend(c) + i += 1 + continue + state = "val" + out.extend(c) + i += 1 + continue + if state == "sq": + if c == b"'": + state = "pre" + else: + out.extend(c) + i += 1 + continue + if state == "dq": + if c == b"\\" and i + 1 < len(val): + nxt = val[i+1:i+2] + if nxt in (b"\\", b'"', b"`", b"$"): + out.extend(nxt) + else: + out.extend(b"\\") + out.extend(nxt) + i += 2 + continue + if c == b'"': + state = "pre" + i += 1 + continue + out.extend(c) + i += 1 + continue + if c == b"\\" and i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + continue + out.extend(c) + i += 1 + if state in ("sq", "dq"): + return None + return bytes(out) or None + +def env_line_state(buf): + """(open_quote or None, trailing unquoted/double-quote backslash continuation).""" + eq = buf.find(b"=") + if eq < 0: + return None, False + val = buf[eq + 1:].lstrip(b" \t") + quote = None + i = 0 + pre = True + while i < len(val): + c = val[i:i+1] + if quote is None: + if pre: + if c in b" \t": + i += 1 + continue + if c == b"'": + quote = b"'" + i += 1 + continue + if c == b'"': + quote = b'"' + i += 1 + continue + if c == b"\\": + pre = False + if i + 1 >= len(val): + return None, True + i += 2 + continue + pre = False + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + return None, True + i += 2 + continue + i += 1 + continue + if quote == b"'": + if c == b"'": + quote = None + pre = True + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + return b'"', True + i += 2 + continue + if c == b'"': + quote = None + pre = True + i += 1 + return quote, False + +def coalesce_env_lines(blob): + # systemd.exec(5): unquoted \\newline and quoted \\newline eat the newline; + # unclosed quotes span physical lines (newline kept). + physical = [ln.rstrip(b"\r") for ln in blob.splitlines()] + logical = [] + buf = b"" + join_nl = False + for piece in physical: + if not buf: + buf = piece + elif join_nl: + buf += b"\n" + piece + else: + buf += piece + quote, cont_bs = env_line_state(buf) + if cont_bs and buf.endswith(b"\\"): + buf = buf[:-1] + join_nl = False + continue + if quote is not None: + join_nl = True + continue + logical.append(buf) + buf = b"" + join_nl = False + if buf: + logical.append(buf) + return logical + +def env_assignment(line): + s = line.lstrip(b" \t") + if not s or s.startswith(b"#") or s.startswith(b";"): + return None, None + eq = s.find(b"=") + if eq < 0: + return None, None + key = s[:eq].rstrip(b" \t") + return key, s[eq + 1:] + +last = None +for line in coalesce_env_lines(data): + key, raw = env_assignment(line) + if key == b"CLAUDE_CODE_OAUTH_TOKEN": + last = parse_value(raw) +if last is None: + sys.exit(2) +sys.stdout.buffer.write(last) +sys.exit(0) +PY + return $? + fi + + # Bash fallback (pet / minimal hosts without python3): quoted newlines and + # trailing-backslash continuations (systemd.exec(5)). + hapi_claude_oauth_bash_last_oauth_token "$token_file" +} + +# True when the final EnvironmentFile assignment is a nonempty token. +hapi_claude_oauth_has_effective_token() { + local token_file="${1:?token_file}" + local eff="" + set +e + eff="$(hapi_claude_oauth_effective_token_value "$token_file" 2>/dev/null)" + local rc=$? + set -e + [[ "$rc" -eq 0 && -n "$eff" ]] +} + +# Inspect /proc/<pid>/environ for CLAUDE_CODE_OAUTH_TOKEN. +# 0 = nonempty token; 1 = pid 0 / gone / inspected with no token; +# 2 = live pid but environ unreadable (fail closed — do not treat as "no token"). +hapi_claude_oauth_mainpid_has_oauth_token() { + local pid="${1:-0}" tok="" line efd + [[ "$pid" =~ ^[0-9]+$ ]] || return 2 + if [[ "$pid" == "0" ]]; then + return 1 + fi + if [[ ! -d "/proc/$pid" ]]; then + return 1 + fi + if [[ ! -r "/proc/$pid/environ" ]]; then + return 2 + fi + exec {efd}<"/proc/$pid/environ" || return 2 + while IFS= read -r -d '' -u "$efd" line || [[ -n "$line" ]]; do + case "$line" in + CLAUDE_CODE_OAUTH_TOKEN=*) tok="${line#CLAUDE_CODE_OAUTH_TOKEN=}" ;; + esac + done + exec {efd}<&- + [[ -n "$tok" ]] +} + +# Split `systemctl show -p EnvironmentFiles` into path + ignore_errors (yes|no). +# Missing marker is treated as required (ignore_errors=no). +hapi_claude_oauth_iter_environment_files() { + local files="${1-}" line path ign + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -n "$line" ]] || continue + ign="no" + path="$line" + if [[ "$line" == *$'\t'* ]]; then + path="${line%%$'\t'*}" + ign="${line#*$'\t'}" + fi + path="${path#"${path%%[![:space:]]*}"}" + path="${path%"${path##*[![:space:]]}"}" + [[ -n "$path" ]] || continue + if [[ "$ign" != "yes" && "$ign" != "no" ]]; then + ign="no" + fi + printf '%s\t%s\n' "$path" "$ign" + done < <(printf '%s' "$files" | sed -E 's/ \(ignore_errors=(yes|no)\)/\t\1\n/g') +} + +# True when merged unit EnvironmentFiles / UnsetEnvironment would drop or +# override CLAUDE_CODE_OAUTH_TOKEN after the canonical token file. +# Usage: hapi_claude_oauth_merged_env_discards_token <unit> [canon-path] [--user] +# Empty EnvironmentFile= resets the list (systemd.exec); missing canon is unsafe. +hapi_claude_oauth_merged_env_discards_token() { + local unit="${1:?unit}" + shift + local canon="/etc/hapi/claude-setup-token.env" + local ctl=(systemctl) + local later_root=1 + while [[ $# -gt 0 ]]; do + case "$1" in + --user) ctl=(systemctl --user); later_root=0; shift ;; + *) canon="$1"; shift ;; + esac + done + local files unset_env ef_path saw_canon=0 grep_rc + files="$("${ctl[@]}" show "$unit" -p EnvironmentFiles --value 2>/dev/null || true)" + unset_env="$("${ctl[@]}" show "$unit" -p UnsetEnvironment --value 2>/dev/null || true)" + if [[ "$unset_env" =~ (^|[[:space:]])CLAUDE_CODE_OAUTH_TOKEN(=|[[:space:]]|$) ]]; then + echo "ERROR: $unit UnsetEnvironment removes CLAUDE_CODE_OAUTH_TOKEN" >&2 + return 0 + fi + local unset_name + for unset_name in $unset_env; do + unset_name="${unset_name#\"}" + unset_name="${unset_name%\"}" + if [[ "$unset_name" == "CLAUDE_CODE_OAUTH_TOKEN" || "$unset_name" == CLAUDE_CODE_OAUTH_TOKEN=* ]]; then + echo "ERROR: $unit UnsetEnvironment removes CLAUDE_CODE_OAUTH_TOKEN" >&2 + return 0 + fi + done + local ef_ignore ef_missing + while IFS=$'\t' read -r ef_path ef_ignore || [[ -n "${ef_path-}" ]]; do + [[ -n "$ef_path" ]] || continue + ef_missing=0 + [[ -e "$ef_path" || -L "$ef_path" ]] || ef_missing=1 + if [[ "$ef_missing" -eq 1 && "$ef_ignore" == "no" ]]; then + echo "ERROR: required EnvironmentFile is missing ($ef_path)" >&2 + return 0 + fi + if [[ "$ef_missing" -eq 1 ]]; then + if [[ "$ef_path" == "$canon" ]]; then + saw_canon=1 + continue + fi + # Optional (-) absence is non-fatal, but a later path under a + # runner-writable home can appear after install and override TOKEN=. + # Validate ancestors even when the leaf is currently missing. + if [[ "$saw_canon" -eq 1 && "$later_root" -eq 1 ]]; then + if ! hapi_claude_oauth_assert_root_controlled_ancestors "$ef_path" >/dev/null 2>&1; then + echo "ERROR: later EnvironmentFile parent is not root-controlled ($ef_path)" >&2 + return 0 + fi + fi + continue + fi + if [[ "$ef_ignore" == "no" ]]; then + if [[ -L "$ef_path" && ! -f "$ef_path" ]]; then + echo "ERROR: required EnvironmentFile is not a regular file ($ef_path)" >&2 + return 0 + fi + if [[ ! -f "$ef_path" ]]; then + echo "ERROR: required EnvironmentFile is not a regular file ($ef_path)" >&2 + return 0 + fi + if [[ ! -r "$ef_path" ]]; then + echo "ERROR: required EnvironmentFile is unreadable ($ef_path)" >&2 + return 0 + fi + fi + if [[ "$ef_path" == "$canon" ]]; then + saw_canon=1 + continue + fi + [[ "$saw_canon" -eq 1 ]] || continue + if [[ "$later_root" -eq 1 ]]; then + if ! hapi_claude_oauth_assert_root_controlled_ancestors "$ef_path" >/dev/null 2>&1; then + echo "ERROR: later EnvironmentFile parent is not root-controlled ($ef_path)" >&2 + return 0 + fi + fi + # systemd follows EnvironmentFile symlinks; later TOKEN= would override canon. + if [[ -L "$ef_path" ]]; then + echo "ERROR: later EnvironmentFile is a symlink ($ef_path)" >&2 + return 0 + fi + if [[ "$later_root" -eq 1 ]]; then + if [[ ! -f "$ef_path" ]]; then + echo "ERROR: later EnvironmentFile is not a regular file ($ef_path)" >&2 + return 0 + fi + local later_uid later_mode + later_uid="$(stat -c '%u' "$ef_path" 2>/dev/null || true)" + later_mode="$(stat -c '%a' "$ef_path" 2>/dev/null || true)" + if [[ "$later_uid" != "0" ]]; then + echo "ERROR: later EnvironmentFile is not root-owned ($ef_path)" >&2 + return 0 + fi + if [[ -n "$later_mode" ]] && (( (8#$later_mode & 022) != 0 )); then + echo "ERROR: later EnvironmentFile is group/other-writable ($ef_path mode $later_mode)" >&2 + return 0 + fi + fi + if [[ -f "$ef_path" ]]; then + if [[ ! -r "$ef_path" ]]; then + echo "ERROR: later EnvironmentFile is unreadable ($ef_path)" >&2 + return 0 + fi + local grep_rc=0 + set +e + grep -qE '^[[:space:]]*CLAUDE_CODE_OAUTH_TOKEN[[:space:]]*=' "$ef_path" + grep_rc=$? + set -e + if [[ "$grep_rc" -eq 0 ]]; then + echo "ERROR: later EnvironmentFile $ef_path overrides CLAUDE_CODE_OAUTH_TOKEN" >&2 + return 0 + fi + if [[ "$grep_rc" -ne 1 ]]; then + echo "ERROR: later EnvironmentFile is unreadable ($ef_path)" >&2 + return 0 + fi + fi + done < <(hapi_claude_oauth_iter_environment_files "$files") + if [[ "$saw_canon" -ne 1 ]]; then + echo "ERROR: canonical EnvironmentFile $canon is not in merged $unit EnvironmentFiles" >&2 + return 0 + fi + return 1 +} + +# chown + chmod without following a symlink final component (drop-in is 0644). +hapi_claude_oauth_secure_chown_mode() { + local path="${1:?path}" + local owner="${2:?owner}" + local mode="${3:?mode}" + if [[ -L "$path" ]]; then + echo "ERROR: refusing symlink: $path" >&2 + return 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 required to securely chown/chmod $path" >&2 + return 1 + fi + python3 - "$path" "$owner" "$mode" <<'PY' +import grp, os, pwd, stat, sys +path, owner, mode_s = sys.argv[1], sys.argv[2], sys.argv[3] +flags = os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +if hasattr(os, "O_NONBLOCK"): + flags |= os.O_NONBLOCK +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open without following symlink: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + sys.stderr.write("ERROR: refusing non-regular file: %s\n" % path) + sys.exit(1) + user, sep, group = owner.partition(":") + try: + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid if sep and group else pwd.getpwnam(user).pw_gid + except KeyError as exc: + sys.stderr.write("ERROR: unknown owner %r: %s\n" % (owner, exc)) + sys.exit(1) + os.fchown(fd, uid, gid) + os.fchmod(fd, int(mode_s, 8)) +finally: + os.close(fd) +sys.exit(0) +PY +} + +# Write stdin to dst as a brand-new regular file (O_CREAT|O_EXCL|O_NOFOLLOW on the +# final path). Never follows a pre-planted symlink at dst — used for operator-owned +# backups of root-read tokens (privileged process must not write into auth-bak). +# Program is on fd 3 so caller stdin (piped token bytes) stays available. +hapi_claude_oauth_secure_write_new_file() { + local dst="${1:?dst}" + python3 /dev/fd/3 "$dst" 3<<'PY' +import os, sys + +dst = sys.argv[1] +parent = os.path.dirname(dst) or "." +os.makedirs(parent, mode=0o700, exist_ok=True) +data = sys.stdin.buffer.read() +# POSIX: O_CREAT|O_EXCL fails if dst exists OR is a dangling/present symlink — +# privileged apps writing into user-writable dirs must use this, not check-then-create. +flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(dst, flags, 0o600) +except OSError as exc: + sys.stderr.write("ERROR: cannot create exclusive backup file: %s: %s\n" % (dst, exc)) + sys.exit(1) +try: + mv = memoryview(data) + while len(mv): + n = os.write(fd, mv) + if n <= 0: + raise OSError("write returned %d with %d bytes remaining" % (n, len(mv))) + mv = mv[n:] + os.fchmod(fd, 0o600) +except OSError as exc: + os.close(fd) + try: + os.unlink(dst) + except OSError: + pass + sys.stderr.write("ERROR: incomplete write to %s: %s\n" % (dst, exc)) + sys.exit(1) +os.close(fd) +sys.exit(0) +PY +} + +# Assert /etc/... token parent is root-owned and not group/other-writable. +# Prefer python3 lstat; bash `stat -c` fallback when python3 is absent (preflight +# should still require python3 for system migrate/chmod before units change). +hapi_claude_oauth_assert_root_controlled_parent() { + local parent_dir="${1:?parent_dir}" + if [[ -L "$parent_dir" ]]; then + echo "ERROR: token parent is a symlink: $parent_dir" >&2 + return 1 + fi + if [[ ! -d "$parent_dir" ]]; then + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + python3 - "$parent_dir" <<'PY' +import os, stat, sys +path = sys.argv[1] +st = os.lstat(path) +if stat.S_ISLNK(st.st_mode): + sys.stderr.write("ERROR: token parent is a symlink: %s\n" % path) + sys.exit(1) +if not stat.S_ISDIR(st.st_mode): + sys.stderr.write("ERROR: token parent is not a directory: %s\n" % path) + sys.exit(1) +if st.st_uid != 0: + sys.stderr.write( + "ERROR: token parent %s is owned by uid %d (must be root) — " + "service-writable /etc/hapi defeats EnvironmentFile root-control\n" + % (path, st.st_uid) + ) + sys.exit(1) +if st.st_mode & 0o022: + sys.stderr.write( + "ERROR: token parent %s is group/other-writable (mode %04o) — " + "fix ownership/mode before installing the OAuth drop-in\n" + % (path, stat.S_IMODE(st.st_mode)) + ) + sys.exit(1) +sys.exit(0) +PY + return $? + fi + local uid mode + uid="$(stat -c '%u' "$parent_dir" 2>/dev/null || true)" + mode="$(stat -c '%a' "$parent_dir" 2>/dev/null || true)" + if [[ -z "$uid" || -z "$mode" ]]; then + echo "ERROR: cannot stat token parent $parent_dir (need python3 or GNU stat)" >&2 + return 1 + fi + if [[ "$uid" != 0 ]]; then + echo "ERROR: token parent $parent_dir is owned by uid $uid (must be root) — service-writable /etc/hapi defeats EnvironmentFile root-control" >&2 + return 1 + fi + # mode is octal like 755; reject group/other write (022). + if (( (8#$mode & 8#022) != 0 )); then + echo "ERROR: token parent $parent_dir is group/other-writable (mode $mode) — fix ownership/mode before installing the OAuth drop-in" >&2 + return 1 + fi + return 0 +} + +# Walk dirname(path) up through the systemd unit-file search root (or /etc/hapi +# for EnvironmentFiles). Each ancestor must be a non-symlink, root-owned +# directory that is not group/other-writable — otherwise the runner can +# replace the leaf before restart/watchdog kick. +hapi_claude_oauth_assert_root_controlled_ancestors() { + local node="${1:?path}" + local dir + dir="$(dirname -- "$node")" + while [[ -n "$dir" && "$dir" != "/" && "$dir" != "." ]]; do + hapi_claude_oauth_assert_root_controlled_parent "$dir" || return 1 + case "$dir" in + /etc/systemd/system|/etc/systemd/user|/run/systemd/system|/usr/lib/systemd/system|/lib/systemd/system|/usr/local/lib/systemd/system|/etc/hapi) + return 0 + ;; + esac + dir="$(dirname -- "$dir")" + done + return 0 +} + +# User-scope token parents must be runner-owned and not group/other-writable. +# Otherwise another local account can unlink a 0600 token and Restart=always +# loads attacker credentials. +hapi_claude_oauth_assert_runner_owned_dir() { + local d="${1:?dir}" + local want="${2:?uid}" + local uid mode + if [[ -L "$d" ]]; then + echo "ERROR: user token parent is a symlink: $d" >&2 + return 1 + fi + if [[ ! -d "$d" ]]; then + echo "ERROR: user token parent is not a directory: $d" >&2 + return 1 + fi + uid="$(stat -c '%u' "$d" 2>/dev/null || true)" + mode="$(stat -c '%a' "$d" 2>/dev/null || true)" + if [[ "$uid" != "$want" ]]; then + echo "ERROR: user token parent $d is owned by uid ${uid:-unknown} (must be runner uid $want)" >&2 + return 1 + fi + if [[ -z "$mode" ]] || (( (8#$mode & 022) != 0 )); then + echo "ERROR: user token parent $d is group/other-writable (mode ${mode:-unknown})" >&2 + return 1 + fi + return 0 +} + +hapi_claude_oauth_assert_trusted_user_token_ancestor() { + local d="${1:?dir}" + local want="${2:?uid}" + local uid mode + if [[ -L "$d" ]]; then + echo "ERROR: user token ancestor is a symlink: $d" >&2 + return 1 + fi + if [[ ! -d "$d" ]]; then + echo "ERROR: user token ancestor is not a directory: $d" >&2 + return 1 + fi + uid="$(stat -c '%u' "$d" 2>/dev/null || true)" + mode="$(stat -c '%a' "$d" 2>/dev/null || true)" + if [[ "$uid" != "$want" && "$uid" != "0" ]]; then + echo "ERROR: user token ancestor $d is owned by uid ${uid:-unknown} (must be runner uid $want or root)" >&2 + return 1 + fi + if [[ -z "$mode" ]] || (( (8#$mode & 022) != 0 )); then + echo "ERROR: user token ancestor $d is group/other-writable (mode ${mode:-unknown})" >&2 + return 1 + fi + return 0 +} + +hapi_claude_oauth_assert_user_token_parents() { + local token_file="${1:?token_file}" + local want_uid="${2:-$(id -u)}" + local home="${HOME:-}" + local cur nxt + cur="$(dirname -- "$token_file")" + hapi_claude_oauth_assert_runner_owned_dir "$cur" "$want_uid" || return 1 + while true; do + nxt="$(dirname -- "$cur")" + if [[ "$nxt" == "/" || "$nxt" == "/tmp" || "$nxt" == "/var/tmp" ]]; then + return 0 + fi + if [[ -n "$home" && "$nxt" == "$home" ]]; then + hapi_claude_oauth_assert_runner_owned_dir "$nxt" "$want_uid" || return 1 + return 0 + fi + hapi_claude_oauth_assert_trusted_user_token_ancestor "$nxt" "$want_uid" || return 1 + cur="$nxt" + done +} + +# chmod 0600 (+ optional chown) without following a symlink final component. +# Prefer python3 O_NOFOLLOW + fchmod/fchown. Path-based shell chmod/chown is +# only allowed for user-scope (no --owner): system-profile as root must fail +# closed without python3 rather than risk a TOCTOU symlink retarget. +hapi_claude_oauth_secure_chmod_chown() { + local token_file="${1:?token_file}" + local owner="${2:-}" + + hapi_claude_oauth_assert_safe_token_file "$token_file" || return 1 + + if [[ -z "${HAPI_CLAUDE_OAUTH_FORCE_SHELL:-}" ]] && command -v python3 >/dev/null 2>&1; then + python3 - "$token_file" "$owner" <<'PY' +import grp, os, pwd, stat, sys + +path = sys.argv[1] +owner = sys.argv[2] if len(sys.argv) > 2 else "" + +flags = os.O_RDONLY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +# FIFO/socket races: O_RDONLY alone can block forever on a FIFO planted after +# the regular-file check; NONBLOCK lets fstat reject non-regular files. +if hasattr(os, "O_NONBLOCK"): + flags |= os.O_NONBLOCK + +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write( + "ERROR: cannot open token file without following symlink: %s: %s\n" % (path, exc) + ) + sys.exit(1) + +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISREG(mode): + sys.stderr.write("ERROR: refusing non-regular token file: %s\n" % path) + sys.exit(1) + os.fchmod(fd, 0o600) + if owner: + user, sep, group = owner.partition(":") + try: + uid = pwd.getpwnam(user).pw_uid + if sep and group: + gid = grp.getgrnam(group).gr_gid + else: + gid = pwd.getpwnam(user).pw_gid + except KeyError as exc: + sys.stderr.write("ERROR: unknown owner %r: %s\n" % (owner, exc)) + sys.exit(1) + os.fchown(fd, uid, gid) +finally: + os.close(fd) +sys.exit(0) +PY + return $? + fi + + # System profile (owner set) runs as root — never path-based chmod/chown. + if [[ -n "$owner" ]]; then + echo "ERROR: python3 required to securely chmod/chown token file as root: $token_file" >&2 + echo " Install python3, or write the token as the runner user after drop-in install." >&2 + return 1 + fi + + # User-scope pet fallback (not root): operator owns the file; TOCTOU cannot + # retarget a root-owned path. Still refuse symlinks up front. + chmod 600 "$token_file" || return 1 + if [[ -L "$token_file" ]]; then + echo "ERROR: token file became a symlink during chmod: $token_file" >&2 + return 1 + fi + return 0 +} + +# Best-effort chown of the token parent dir via O_DIRECTORY|O_NOFOLLOW + fchown. +# Returns 1 when ownership cannot be applied (caller must fail closed on system +# installs so the runner account can still write the token file). +hapi_claude_oauth_secure_chown_parent() { + local parent_dir="${1:?parent_dir}" + local owner="${2:?owner}" + if [[ -L "$parent_dir" ]]; then + echo "ERROR: token parent dir is a symlink; refusing chown of $parent_dir" >&2 + return 1 + fi + if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to securely chown token parent $parent_dir" >&2 + echo " Install python3, or pre-create $parent_dir owned by $owner." >&2 + return 1 + fi + python3 - "$parent_dir" "$owner" <<'PY' +import grp, os, pwd, stat, sys + +path = sys.argv[1] +owner = sys.argv[2] +flags = os.O_RDONLY +if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY +if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW +try: + fd = os.open(path, flags) +except OSError as exc: + sys.stderr.write("ERROR: cannot open token parent without following symlink: %s: %s\n" % (path, exc)) + sys.exit(1) +try: + mode = os.fstat(fd).st_mode + if not stat.S_ISDIR(mode): + sys.stderr.write("ERROR: token parent is not a directory: %s\n" % path) + sys.exit(1) + user, sep, group = owner.partition(":") + try: + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(group).gr_gid if sep and group else pwd.getpwnam(user).pw_gid + except KeyError as exc: + sys.stderr.write("ERROR: unknown owner %r for parent chown: %s\n" % (owner, exc)) + sys.exit(1) + os.fchown(fd, uid, gid) +finally: + os.close(fd) +sys.exit(0) +PY +} + +# Install drop-in. Returns 0 always when drop-in written; prints instructions +# (and returns 0) when the token file is absent — absence is expected on a +# stranger pet install until the operator mints a token. +hapi_install_claude_oauth_dropin() { + local scope="" runner_unit="" token_file="" owner="" migrate_profile="" migrate_hapi_home="" migrate_operator_home="" unit_dir="" + while [[ $# -gt 0 ]]; do + case "$1" in + --scope) scope="${2:?}"; shift 2 ;; + --runner-unit) runner_unit="${2:?}"; shift 2 ;; + --token-file) token_file="${2:?}"; shift 2 ;; + --owner) owner="${2:?}"; shift 2 ;; + --unit-dir) unit_dir="${2:?}"; shift 2 ;; + --migrate-profile) migrate_profile="${2:?}"; shift 2 ;; + --migrate-hapi-home) migrate_hapi_home="${2:?}"; shift 2 ;; + --migrate-operator-home) migrate_operator_home="${2:?}"; shift 2 ;; + *) + echo "hapi_install_claude_oauth_dropin: unknown arg: $1" >&2 + return 2 + ;; + esac + done + [[ -n "$scope" && -n "$runner_unit" && -n "$token_file" ]] || { + echo "hapi_install_claude_oauth_dropin: --scope --runner-unit --token-file required" >&2 + return 2 + } + + local dropin_dir dropin + case "$scope" in + system) + dropin_dir="/etc/systemd/system/${runner_unit}.d" + ;; + user) + # Prefer --unit-dir from the companion installer (always $HOME/.config/...) + # so a stray XDG_CONFIG_HOME cannot land the drop-in beside a different + # search root than the unit files. + if [[ -n "$unit_dir" ]]; then + dropin_dir="${unit_dir}/${runner_unit}.d" + else + dropin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${runner_unit}.d" + fi + ;; + *) + echo "hapi_install_claude_oauth_dropin: --scope must be system|user" >&2 + return 2 + ;; + esac + dropin="$dropin_dir/42-claude-oauth-token.conf" + + # Parent of the token file (may not exist yet on a fresh box). + local token_parent root_controlled=0 + token_parent="$(dirname "$token_file")" + # /etc/hapi/... stays root-controlled - never chown to the service account. + if [[ "$scope" == system && "$token_file" == /etc/* ]]; then + root_controlled=1 + fi + + # System drop-in dir must be root-owned and not service-writable before we write. + # Explicit || return 1: callers may wrap us in `set +e` to capture status. + if [[ "$scope" == system ]]; then + if [[ ! -d "$dropin_dir" ]]; then + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "ERROR: creating $dropin_dir requires root" >&2 + return 1 + fi + mkdir -m 0755 -p "$dropin_dir" || return 1 + fi + hapi_claude_oauth_assert_root_controlled_parent "$dropin_dir" || return 1 + else + mkdir -p "$dropin_dir" || return 1 + fi + + if [[ ! -d "$token_parent" ]]; then + if [[ "$root_controlled" -eq 1 ]]; then + mkdir -m 0755 -p "$token_parent" || return 1 + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "ERROR: creating $token_parent requires root (got euid=${EUID:-$(id -u)})" >&2 + return 1 + fi + else + mkdir -m 0700 -p "$token_parent" || return 1 + fi + fi + if [[ "$root_controlled" -eq 1 ]]; then + hapi_claude_oauth_assert_root_controlled_parent "$token_parent" || return 1 + elif [[ "$scope" == user ]]; then + hapi_claude_oauth_assert_user_token_parents "$token_file" || return 1 + fi + if [[ -n "$owner" && "$scope" == system && "$root_controlled" -eq 0 ]]; then + hapi_claude_oauth_secure_chown_parent "$token_parent" "$owner" || return 1 + elif [[ -n "$owner" && "$root_controlled" -eq 1 ]]; then + echo "NOTE: ignoring --owner=$owner for root-controlled token path $token_file" >&2 + fi + + # Migrate when canonical is missing/ineffective, and always retry retirement + # of leftover legacy sources even when canon is already effective (a prior + # copy+failed-retire must not succeed on rerun while leaving the stale source). + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=0 + local need_migrate=0 + local canon_effective=0 + if [[ "$(basename "$token_file")" == "claude-setup-token.env" ]]; then + if hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + canon_effective=1 + else + need_migrate=1 + fi + fi + if [[ "$(basename "$token_file")" == "claude-setup-token.env" ]]; then + local legacy_token + if [[ "$root_controlled" -eq 1 ]]; then + local -a present=() + while IFS= read -r legacy_token; do + [[ -n "$legacy_token" ]] || continue + [[ "$legacy_token" == "$token_file" ]] && continue + if [[ -e "$legacy_token" || -L "$legacy_token" ]]; then + present+=("$legacy_token") + fi + done < <(hapi_claude_oauth_legacy_system_token_candidates "$migrate_profile" "$migrate_hapi_home" "$migrate_operator_home") + + if [[ "$need_migrate" -eq 1 && ${#present[@]} -gt 1 ]]; then + echo "ERROR: multiple legacy Claude OAuth tokens for profile '${migrate_profile:-unset}'; refuse to guess" >&2 + printf ' - %s\n' "${present[@]}" >&2 + echo " Pick one source, remove or rename the others, then re-run install." >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + elif [[ "$need_migrate" -eq 1 && ${#present[@]} -eq 1 ]]; then + legacy_token="${present[0]}" + if [[ -L "$legacy_token" ]]; then + echo "ERROR: refusing to migrate symlink legacy token: $legacy_token" >&2 + echo " Replace with a regular file, then re-run install (never sudo cp -a)." >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + elif hapi_claude_oauth_secure_copy_regular_file "$legacy_token" "$token_file"; then + echo "Migrated Claude OAuth token: $legacy_token -> $token_file (regular file, 0600)" + if hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + canon_effective=1 + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" || { + echo "ERROR: migrated but could not retire legacy source $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + } + fi + else + echo "WARN: could not migrate legacy token at $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + echo " Fix the source (must be a regular file), then:" >&2 + echo " source scripts/tooling/lib/hapi-claude-oauth-dropin.sh" >&2 + echo " hapi_claude_oauth_secure_copy_regular_file $(printf '%q' "$legacy_token") $(printf '%q' "$token_file")" >&2 + echo " then: sudo systemctl restart ${runner_unit}" >&2 + fi + elif [[ "$canon_effective" -eq 1 && ${#present[@]} -gt 0 ]]; then + # retry retirement: canon already good, archive every leftover legacy. + for legacy_token in "${present[@]}"; do + if [[ -L "$legacy_token" ]]; then + echo "ERROR: refusing to retire symlink legacy token: $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + continue + fi + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" || { + echo "ERROR: could not retire leftover legacy source $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + } + done + fi + if [[ ${#present[@]} -gt 0 ]] && ! hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + fi + else + legacy_token="${token_parent}/.hapi/claude-setup-token.env" + if [[ -e "$legacy_token" || -L "$legacy_token" ]]; then + if [[ -L "$legacy_token" ]]; then + echo "ERROR: refusing to migrate symlink legacy token: $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + elif [[ "$need_migrate" -eq 1 ]]; then + if hapi_claude_oauth_secure_copy_regular_file "$legacy_token" "$token_file"; then + echo "Migrated Claude OAuth token: $legacy_token -> $token_file" + if hapi_claude_oauth_has_effective_token "$token_file" 2>/dev/null; then + canon_effective=1 + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" || { + echo "ERROR: migrated but could not retire legacy source $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + } + fi + else + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + echo "WARN: could not migrate legacy token at $legacy_token" >&2 + fi + elif [[ "$canon_effective" -eq 1 ]]; then + # retry retirement when a prior migrate left the legacy source behind. + hapi_claude_oauth_retire_legacy_token_source "$legacy_token" || { + echo "ERROR: could not retire leftover legacy source $legacy_token" >&2 + HAPI_CLAUDE_OAUTH_MIGRATE_PENDING=1 + } + fi + fi + fi + fi + export HAPI_CLAUDE_OAUTH_MIGRATE_PENDING + + # Fail closed BEFORE installing the drop-in: pending migrate + new + # EnvironmentFile at missing/empty /etc/hapi breaks the next restart. + if [[ "${HAPI_CLAUDE_OAUTH_MIGRATE_PENDING:-0}" -eq 1 ]]; then + echo "ERROR: Claude OAuth token migrate still pending; refusing to install drop-in for ${runner_unit}" >&2 + return 1 + fi + + # Reject a symlink/non-regular canonical path before persisting the drop-in. + # systemd EnvironmentFile follows the symlink on later reload/reboot. + if [[ -L "$token_file" ]]; then + echo "ERROR: refusing symlink token file before drop-in: $token_file" >&2 + echo " Replace with a regular root:root 0600 file, then re-run install." >&2 + return 1 + fi + if [[ -e "$token_file" && ! -f "$token_file" ]]; then + echo "ERROR: refusing non-regular token file before drop-in: $token_file" >&2 + return 1 + fi + + local dropin_tmp + dropin_tmp="$(mktemp "${dropin_dir}/.42-claude-oauth-token.conf.XXXXXX")" || return 1 + if ! cat >"$dropin_tmp" <<EOF +# Installed by hapi_install_claude_oauth_dropin (scripts/tooling/lib/hapi-claude-oauth-dropin.sh). +# Leading '-' = ignore missing file so the unit still starts before the operator +# mints a setup-token. verify-hapi-install.sh fails the ambient-token check when +# live Claude children have a token and the runner does not. +[Service] +EnvironmentFile=-${token_file} +EOF + then + rm -f "$dropin_tmp" + echo "ERROR: failed to write temporary drop-in $dropin_tmp" >&2 + return 1 + fi + if [[ "$scope" == system ]]; then + # Atomic install as root so a prior service-owned drop-in cannot retain owner. + if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + install -m 0644 -o root -g root "$dropin_tmp" "$dropin" || { + rm -f "$dropin_tmp" + echo "ERROR: failed to install drop-in $dropin" >&2 + return 1 + } + rm -f "$dropin_tmp" + else + sudo install -m 0644 -o root -g root "$dropin_tmp" "$dropin" || { + rm -f "$dropin_tmp" + echo "ERROR: failed to install drop-in $dropin (sudo)" >&2 + return 1 + } + rm -f "$dropin_tmp" + fi + hapi_claude_oauth_secure_chown_mode "$dropin" "root:root" "0644" || return 1 + else + mv -f "$dropin_tmp" "$dropin" || { + rm -f "$dropin_tmp" + echo "ERROR: failed to install drop-in $dropin" >&2 + return 1 + } + chmod 0644 "$dropin" || return 1 + fi + echo "Installed: $dropin -> EnvironmentFile=-$token_file" + + if [[ -e "$token_file" || -L "$token_file" ]]; then + hapi_claude_oauth_assert_safe_token_file "$token_file" || return 1 + if [[ "$root_controlled" -eq 1 ]]; then + hapi_claude_oauth_secure_chmod_chown "$token_file" "root:root" || return 1 + elif [[ -n "$owner" && "$scope" == system ]]; then + hapi_claude_oauth_secure_chmod_chown "$token_file" "$owner" || return 1 + else + hapi_claude_oauth_secure_chmod_chown "$token_file" || return 1 + fi + if hapi_claude_oauth_has_effective_token "$token_file"; then + echo "Claude OAuth token file present: $token_file" + local restart_cmd + case "$scope" in + user) restart_cmd="systemctl --user restart ${runner_unit}" ;; + *) restart_cmd="sudo systemctl restart ${runner_unit}" ;; + esac + cat <<EOF +==> If the runner is already running, reload it so new UI sessions inherit the token: + $restart_cmd + (daemon-reload alone does not update the running process environment.) +EOF + else + echo "WARN: $token_file exists but has no effective CLAUDE_CODE_OAUTH_TOKEN= value" >&2 + hapi_print_claude_oauth_setup_instructions "$token_file" "$runner_unit" "$scope" + fi + else + hapi_print_claude_oauth_setup_instructions "$token_file" "$runner_unit" "$scope" + fi + + case "$scope" in + system) + systemctl daemon-reload || { + echo "ERROR: systemctl daemon-reload failed after installing $dropin" >&2 + return 1 + } + ;; + user) + systemctl --user daemon-reload || { + echo "ERROR: systemctl --user daemon-reload failed after installing $dropin" >&2 + return 1 + } + ;; + esac +} diff --git a/scripts/tooling/verify-hapi-install.sh b/scripts/tooling/verify-hapi-install.sh index e725f99418..de9932703b 100755 --- a/scripts/tooling/verify-hapi-install.sh +++ b/scripts/tooling/verify-hapi-install.sh @@ -6,7 +6,8 @@ # Owns only what that verifier cannot prove: installer argument parse, watchdog # journal fire, sudoers grants the runner account, systemctl wrapper present # (default; HAPI_EXPECT_NO_SYSTEMCTL_WRAPPER=1 if --no-systemctl-wrapper), -# MainPID ↔ runner.state.json after restart, live /proc oom_score_adj, hub /health. +# MainPID ↔ runner.state.json after restart, live /proc oom_score_adj, hub /health, +# runner-vs-child Claude OAuth presence (token values never printed). # # Failure semantics (MainPID / sudoers): three outcomes, not two. # ok — property under test held @@ -194,6 +195,392 @@ if [[ -x "$VERIFY_UNITS" || -f "$VERIFY_UNITS" ]]; then fi fi +# --- 2b. Claude OAuth safety (before watchdog kick / hub+runner restart) ---------- +# A compromised runner with passwordless restart can plant a drop-in +# (User=root / ExecStart=…) that watchdog would execute as root, or discard +# an ambient-only token. Validate before starting hapi-runner-watchdog.service. +hapi_home="$("${CTL[@]}" show "$RUNNER_UNIT" -p Environment --value 2>/dev/null \ + | tr ' ' '\n' | sed -n 's/^HAPI_HOME=//p' | head -n1 || true)" +if [[ -z "$hapi_home" ]]; then + if [[ "$SCOPE" == user ]]; then + hapi_home="${HAPI_HOME:-$HOME/.hapi}" + else + hapi_home="${HAPI_HOME:-/var/lib/hapi}" + fi +fi +state_file="$hapi_home/runner.state.json" + +# Claude OAuth helpers needed before restart: a compromised runner with +# passwordless restart can plant a drop-in (User=root / ExecStart=…) that this +# verifier would otherwise execute. Source here (not at top) so --installer-smoke +# on archived trees still works. +# shellcheck source=lib/hapi-claude-oauth-dropin.sh +source "$REPO_ROOT/scripts/tooling/lib/hapi-claude-oauth-dropin.sh" + +SYSTEM_OAUTH_SAFE=1 +OAUTH_RESTART_SAFE=1 +SYSTEM_DROPIN_VALIDATED="" +SYSTEM_TOKEN_PRECHECKED=0 +SYSTEM_TOKEN_NODE_CHECKED=0 +if [[ "$SCOPE" == system && "${EUID:-$(id -u)}" -ne 0 ]]; then + # Token is 0600 root; runner /proc environ is other-UID. Non-root cannot + # prove durable vs ambient — refuse watchdog kick/restart rather than + # defaulting OAUTH_RESTART_SAFE=1. + not_ok "system-scope OAuth restart checks require root (running as $(id -un)) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 +fi +if [[ "$SCOPE" == system ]]; then + sys_dropin_dir="/etc/systemd/system/${RUNNER_UNIT}.d" + sys_dropin="${sys_dropin_dir}/42-claude-oauth-token.conf" + # Always validate the .d directory when it exists — a missing expected file + # must not skip the gate (runner can plant 99-owned.conf before we restart). + if [[ -e "$sys_dropin_dir" || -L "$sys_dropin_dir" ]]; then + if ! hapi_claude_oauth_assert_root_controlled_parent "$sys_dropin_dir" >/dev/null 2>&1; then + not_ok "Claude OAuth drop-in directory must be root-controlled ($sys_dropin_dir) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + else + ok "Claude OAuth drop-in directory is root-controlled ($sys_dropin_dir)" + fi + fi + # systemd.unit(5): every *.conf in the unit .d directory is loaded. A + # previously planted 99-owned.conf survives directory-mode repair and can + # override User=/ExecStart= on the verifier's restart — gate every conf. + if [[ -d "$sys_dropin_dir" && ! -L "$sys_dropin_dir" ]]; then + shopt -s nullglob + for conf in "$sys_dropin_dir"/*.conf; do + if [[ -L "$conf" ]]; then + not_ok "Claude OAuth drop-in dir has unsafe drop-in symlink ($conf) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + if [[ ! -f "$conf" ]]; then + not_ok "Claude OAuth drop-in dir has unsafe drop-in non-regular ($conf) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + dropin_uid="$(stat -c '%u' "$conf" 2>/dev/null || true)" + dropin_owner="$(stat -c '%U:%G' "$conf" 2>/dev/null || true)" + dropin_mode="$(stat -c '%a' "$conf" 2>/dev/null || true)" + if [[ "$dropin_uid" != "0" ]]; then + not_ok "Claude OAuth drop-in dir has unsafe drop-in ($conf is not root-owned; got ${dropin_owner:-unknown}) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + if [[ -n "$dropin_mode" ]] && (( (8#$dropin_mode & 022) != 0 )); then + not_ok "Claude OAuth drop-in dir has unsafe drop-in ($conf is group/other-writable mode ${dropin_mode}) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + ok "Claude OAuth drop-in is root-owned non-writable ($conf mode ${dropin_mode:-unknown})" + if [[ "$(basename "$conf")" == "42-claude-oauth-token.conf" ]]; then + SYSTEM_DROPIN_VALIDATED="$conf" + fi + done + shopt -u nullglob + elif [[ -L "$sys_dropin" ]]; then + not_ok "Claude OAuth drop-in is a symlink ($sys_dropin) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + elif [[ -e "$sys_dropin" && ! -f "$sys_dropin" ]]; then + not_ok "Claude OAuth drop-in is not a regular file ($sys_dropin) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + fi + + # Loaded fragments can live outside ${RUNNER_UNIT}.d (FragmentPath, /run, + # /usr/lib). systemd.unit(5): DropInPaths is the resolved set. Require each + # loaded fragment to be a root-owned, non-group/other-writable regular file + # before watchdog kick or restart would exec it. + hapi_verify_audit_loaded_fragments() { + local unit="$1" frag dropins path owner uid mode + frag="$("${CTL[@]}" show "$unit" -p FragmentPath --value 2>/dev/null || true)" + dropins="$("${CTL[@]}" show "$unit" -p DropInPaths --value 2>/dev/null || true)" + for path in $frag $dropins; do + [[ -n "$path" ]] || continue + if [[ -L "$path" ]]; then + not_ok "loaded unit fragment is a symlink ($unit $path) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + if [[ ! -f "$path" ]]; then + not_ok "loaded unit fragment is not a regular file ($unit $path) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + uid="$(stat -c '%u' "$path" 2>/dev/null || true)" + mode="$(stat -c '%a' "$path" 2>/dev/null || true)" + owner="$(stat -c '%U:%G' "$path" 2>/dev/null || true)" + if [[ "$uid" != "0" ]]; then + not_ok "loaded unit fragment is not root-owned ($unit $path got ${owner:-unknown}) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + if [[ -n "$mode" ]] && (( (8#$mode & 022) != 0 )); then + not_ok "loaded unit fragment is group/other-writable ($unit $path mode $mode) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + if ! hapi_claude_oauth_assert_root_controlled_ancestors "$path" >/dev/null 2>&1; then + not_ok "loaded unit fragment parent is not root-controlled ($unit $path) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + continue + fi + ok "loaded unit fragment is root-owned non-writable ($unit $path)" + done + } + hapi_verify_audit_loaded_fragments "$RUNNER_UNIT" + hapi_verify_audit_loaded_fragments "$HUB_UNIT" + hapi_verify_audit_loaded_fragments "hapi-runner-watchdog.service" + hapi_verify_audit_loaded_fragments "hapi-runner-watchdog.timer" + unset -f hapi_verify_audit_loaded_fragments + + # Canonical token is always /etc/hapi/claude-setup-token.env. Basename + # matches on EnvironmentFiles (e.g. /etc/hapi-old/...) must not retarget + # the pre-restart gate to a stale file. + pre_token="/etc/hapi/claude-setup-token.env" + pre_token_parent="$(dirname "$pre_token")" + if [[ -d "$pre_token_parent" || -L "$pre_token_parent" ]]; then + if ! hapi_claude_oauth_assert_root_controlled_parent "$pre_token_parent" >/dev/null 2>&1; then + not_ok "Claude OAuth token parent must be root-controlled before restart ($pre_token_parent) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + else + ok "Claude OAuth token parent is root-controlled before restart ($pre_token_parent)" + SYSTEM_TOKEN_PRECHECKED=1 + fi + fi + if [[ -L "$pre_token" ]]; then + not_ok "Claude OAuth token file is a symlink ($pre_token) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + SYSTEM_TOKEN_NODE_CHECKED=1 + elif [[ -e "$pre_token" && ! -f "$pre_token" ]]; then + not_ok "Claude OAuth token file is not a regular file ($pre_token) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + SYSTEM_TOKEN_NODE_CHECKED=1 + elif [[ -e "$pre_token" && ! -r "$pre_token" ]]; then + not_ok "Claude OAuth token file is unreadable ($pre_token) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + SYSTEM_TOKEN_NODE_CHECKED=1 + elif [[ -f "$pre_token" ]]; then + SYSTEM_TOKEN_NODE_CHECKED=1 + pre_owner="$(stat -c '%U:%G' "$pre_token" 2>/dev/null || true)" + pre_mode="$(stat -c '%a' "$pre_token" 2>/dev/null || true)" + if [[ "$pre_owner" != "root:root" || "$pre_mode" != "600" ]]; then + not_ok "Claude OAuth token file must be root:root 0600 before restart (got ${pre_owner:-unknown} mode ${pre_mode:-unknown} at $pre_token) — refusing restart" + SYSTEM_OAUTH_SAFE=0 + OAUTH_RESTART_SAFE=0 + else + ok "Claude OAuth token file is root:root 0600 before restart ($pre_token)" + SYSTEM_TOKEN_PRECHECKED=1 + fi + fi +fi + +# Ambient-only guard (system + user): if MainPID still carries a token but the +# durable EnvironmentFile is missing/ineffective, restart would discard it +# before the later /proc probe can report split-brain. +pre_ambient_token="" +pre_env_files_all="$("${CTL[@]}" show "$RUNNER_UNIT" -p EnvironmentFiles --value 2>/dev/null || true)" +if [[ "$SCOPE" == system ]]; then + pre_ambient_token="/etc/hapi/claude-setup-token.env" + sys_canon_wired=0 + while IFS=$'\t' read -r ef_path ef_ignore || [[ -n "${ef_path-}" ]]; do + [[ -n "$ef_path" ]] || continue + [[ "$ef_path" == "/etc/hapi/claude-setup-token.env" ]] && sys_canon_wired=1 + done < <(hapi_claude_oauth_iter_environment_files "$pre_env_files_all") + if [[ "$sys_canon_wired" -ne 1 ]]; then + not_ok "durable Claude OAuth token must be wired as /etc/hapi/claude-setup-token.env in ${RUNNER_UNIT} EnvironmentFiles — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi +elif [[ "$SCOPE" != system ]]; then + pre_ambient_token="${hapi_home}/claude-setup-token.env" + user_canon_wired=0 + while IFS=$'\t' read -r ef_path ef_ignore || [[ -n "${ef_path-}" ]]; do + [[ -n "$ef_path" ]] || continue + [[ "$ef_path" == "$pre_ambient_token" ]] && user_canon_wired=1 + done < <(hapi_claude_oauth_iter_environment_files "$pre_env_files_all") + if [[ "$user_canon_wired" -ne 1 ]]; then + not_ok "durable Claude OAuth token must be wired as ${pre_ambient_token} in ${RUNNER_UNIT} EnvironmentFiles — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi +fi +if ! hapi_claude_oauth_has_effective_token "${pre_ambient_token:-}" 2>/dev/null; then + pre_runner_pid="$("${CTL[@]}" show "$RUNNER_UNIT" -p MainPID --value 2>/dev/null || echo 0)" + pre_runner_pid="${pre_runner_pid:-0}" + if [[ "$pre_runner_pid" != "0" && -r "/proc/$pre_runner_pid/environ" ]]; then + pre_ambient_val="" + while IFS= read -r -d '' pre_env_line || [[ -n "$pre_env_line" ]]; do + case "$pre_env_line" in + CLAUDE_CODE_OAUTH_TOKEN=*) + pre_ambient_val="${pre_env_line#CLAUDE_CODE_OAUTH_TOKEN=}" + ;; + esac + done <"/proc/$pre_runner_pid/environ" + if [[ -n "$pre_ambient_val" ]]; then + not_ok "runner MainPID=$pre_runner_pid has ambient CLAUDE_CODE_OAUTH_TOKEN but ${pre_ambient_token:-token file} is missing/empty — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi + elif [[ "$pre_runner_pid" != "0" ]]; then + not_ok "cannot inspect runner MainPID=$pre_runner_pid environ for ambient OAuth — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi +fi + +# Merged EnvironmentFiles gate runs independently of token effectiveness. +# A required (non-`-`) EnvironmentFile missing after the current runner started +# must still refuse restart/watchdog kick — systemd will stop the working +# process and fail to start its replacement. +# Also: last-assignment-wins — a later TOKEN= (including empty) overrides canon. +saw_canon_env=0 +expected_canon="$pre_ambient_token" +[[ "$SCOPE" == system ]] && expected_canon="/etc/hapi/claude-setup-token.env" +while IFS=$'\t' read -r ef_path ef_ignore || [[ -n "${ef_path-}" ]]; do + [[ -n "$ef_path" ]] || continue + ef_missing=0 + [[ -e "$ef_path" || -L "$ef_path" ]] || ef_missing=1 + if [[ "$ef_missing" -eq 1 && "$ef_ignore" == "no" ]]; then + not_ok "required EnvironmentFile is missing ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + if [[ "$ef_missing" -eq 1 ]]; then + if [[ "$ef_path" == "$expected_canon" ]]; then + saw_canon_env=1 + continue + fi + # Optional (-) absence is non-fatal, but a later path under a + # runner-writable home can appear after verify and override TOKEN=. + if [[ "$saw_canon_env" -eq 1 && "$SCOPE" == system ]]; then + if ! hapi_claude_oauth_assert_root_controlled_ancestors "$ef_path" >/dev/null 2>&1; then + not_ok "later EnvironmentFile parent is not root-controlled ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi + fi + continue + fi + if [[ "$ef_ignore" == "no" ]]; then + if [[ ! -f "$ef_path" ]]; then + not_ok "required EnvironmentFile is not a regular file ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + if [[ ! -r "$ef_path" ]]; then + not_ok "required EnvironmentFile is unreadable ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + fi + if [[ "$ef_path" == "$expected_canon" ]]; then + saw_canon_env=1 + continue + fi + [[ "$saw_canon_env" -eq 1 ]] || continue + if [[ "$SCOPE" == system ]]; then + if ! hapi_claude_oauth_assert_root_controlled_ancestors "$ef_path" >/dev/null 2>&1; then + not_ok "later EnvironmentFile parent is not root-controlled ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + if [[ -L "$ef_path" ]]; then + not_ok "later EnvironmentFile is a symlink ($ef_path) after canonical token — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + if [[ ! -f "$ef_path" ]]; then + not_ok "later EnvironmentFile is not a regular file ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + later_uid="$(stat -c '%u' "$ef_path" 2>/dev/null || true)" + later_mode="$(stat -c '%a' "$ef_path" 2>/dev/null || true)" + if [[ "$later_uid" != "0" ]]; then + not_ok "later EnvironmentFile is not root-owned ($ef_path) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + if [[ -n "$later_mode" ]] && (( (8#$later_mode & 022) != 0 )); then + not_ok "later EnvironmentFile is group/other-writable ($ef_path mode $later_mode) — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + fi + if [[ -L "$ef_path" ]]; then + not_ok "later EnvironmentFile is a symlink ($ef_path) after canonical token — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + [[ -f "$ef_path" ]] || continue + if [[ ! -r "$ef_path" ]]; then + not_ok "later EnvironmentFile is unreadable ($ef_path) after canonical token — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + continue + fi + grep_rc=0 + set +e + grep -qE '^[[:space:]]*CLAUDE_CODE_OAUTH_TOKEN[[:space:]]*=' "$ef_path" + grep_rc=$? + set -e + if [[ "$grep_rc" -eq 0 ]]; then + not_ok "later EnvironmentFile $ef_path overrides CLAUDE_CODE_OAUTH_TOKEN after canonical token — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + elif [[ "$grep_rc" -ne 1 ]]; then + not_ok "later EnvironmentFile is unreadable ($ef_path) after canonical token — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 + fi +done < <(hapi_claude_oauth_iter_environment_files "$pre_env_files_all") + +# systemd.exec(5): UnsetEnvironment is applied after EnvironmentFile. A wired +# token is useless if the merged unit strips CLAUDE_CODE_OAUTH_TOKEN on exec. +unset_env="$("${CTL[@]}" show "$RUNNER_UNIT" -p UnsetEnvironment --value 2>/dev/null || true)" +unset_oauth=0 +if [[ "$unset_env" =~ (^|[[:space:]])CLAUDE_CODE_OAUTH_TOKEN(=|[[:space:]]|$) ]]; then + unset_oauth=1 +fi +for unset_name in $unset_env; do + unset_name="${unset_name#\"}" + unset_name="${unset_name%\"}" + if [[ "$unset_name" == "CLAUDE_CODE_OAUTH_TOKEN" || "$unset_name" == CLAUDE_CODE_OAUTH_TOKEN=* ]]; then + unset_oauth=1 + break + fi +done +if [[ "$unset_oauth" -eq 1 ]]; then + not_ok "runner UnsetEnvironment removes CLAUDE_CODE_OAUTH_TOKEN — refusing restart" + OAUTH_RESTART_SAFE=0 + SYSTEM_OAUTH_SAFE=0 +fi + # --- 3. Watchdog journal fire (system scope) ------------------------------- # verify-hapi-systemd-units.sh already asserts ConditionPathExists from unit # text. This layer only proves a live start left journal evidence. Fresh-box @@ -207,8 +594,16 @@ if [[ "$SCOPE" == system ]]; then | sed -n 's/^ConditionPathExists=//p' | tail -n1 || true)" wd_path="${wd_path#!}" # Kick once so a fresh install has journal evidence (idempotent). - "${CTL[@]}" start hapi-runner-watchdog.service 2>/dev/null || true - sleep 1 + # Never kick if OAuth/drop-in is unsafe or --skip-restart: the oneshot + # can restart an unhealthy runner via passwordless systemctl. + if [[ "${OAUTH_RESTART_SAFE:-1}" -eq 0 ]]; then + inconclusive "skipped watchdog kick — OAuth drop-in/token/ambient safety checks failed" + elif [[ "$SKIP_RESTART" -eq 1 ]]; then + inconclusive "skipped watchdog kick — --skip-restart" + else + "${CTL[@]}" start hapi-runner-watchdog.service 2>/dev/null || true + sleep 1 + fi set +e journal="$("${CTL[@]}" status hapi-runner-watchdog.service --no-pager -n 20 2>&1)" jlog="$(journalctl -u hapi-runner-watchdog.service -n 30 --no-pager 2>&1)" @@ -279,28 +674,21 @@ else ok "systemctl wrapper N/A for user-pet scope" fi -# --- 6 + 7. Restart → active + MainPID match + live OOM + /health ---------- -hapi_home="$("${CTL[@]}" show "$RUNNER_UNIT" -p Environment --value 2>/dev/null \ - | tr ' ' '\n' | sed -n 's/^HAPI_HOME=//p' | head -n1 || true)" -if [[ -z "$hapi_home" ]]; then - if [[ "$SCOPE" == user ]]; then - hapi_home="${HAPI_HOME:-$HOME/.hapi}" +# --- 6 + 7. Restart → active + MainPID match + live OOM + Claude auth + /health ---------- +if [[ "$SKIP_RESTART" -eq 0 ]]; then + if [[ "$OAUTH_RESTART_SAFE" -eq 0 ]]; then + inconclusive "skipped hub/runner restart — OAuth drop-in/token/ambient safety checks failed" else - hapi_home="${HAPI_HOME:-/var/lib/hapi}" + "${CTL[@]}" restart "$HUB_UNIT" 2>/dev/null || true + "${CTL[@]}" restart "$RUNNER_UNIT" 2>/dev/null || true + # Give hub+runner a moment to write state + for _ in $(seq 1 30); do + active="$("${CTL[@]}" is-active "$RUNNER_UNIT" 2>/dev/null || true)" + [[ "$active" == active ]] && break + sleep 1 + done fi fi -state_file="$hapi_home/runner.state.json" - -if [[ "$SKIP_RESTART" -eq 0 ]]; then - "${CTL[@]}" restart "$HUB_UNIT" 2>/dev/null || true - "${CTL[@]}" restart "$RUNNER_UNIT" 2>/dev/null || true - # Give hub+runner a moment to write state - for _ in $(seq 1 30); do - active="$("${CTL[@]}" is-active "$RUNNER_UNIT" 2>/dev/null || true)" - [[ "$active" == active ]] && break - sleep 1 - done -fi active="$("${CTL[@]}" is-active "$RUNNER_UNIT" 2>/dev/null || true)" if [[ "$active" == active ]]; then @@ -359,6 +747,598 @@ else not_ok "live runner oom_score_adj (no /proc/$main_pid)" fi +# --- Claude OAuth ambient token (drop-in + load + split-brain) --------------- +# UI POST /api/machines/:id/spawn does not send a per-spawn Claude token. +# New sessions inherit the runner ambient login. Missing EnvironmentFile load +# is the antevorta 2026-10-01 failure mode: --resume children still have the +# token, new UI sessions print Not logged in. +# Drop-in lib already sourced before restart (system safety gate). +dropin_paths=() +if [[ "$SCOPE" == system ]]; then + dropin_paths+=("/etc/systemd/system/${RUNNER_UNIT}.d/42-claude-oauth-token.conf") +else + dropin_paths+=("${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${RUNNER_UNIT}.d/42-claude-oauth-token.conf") + dropin_paths+=("$HOME/.config/systemd/user/${RUNNER_UNIT}.d/42-claude-oauth-token.conf") +fi +dropin_found="" +if [[ -n "${SYSTEM_DROPIN_VALIDATED:-}" ]]; then + dropin_found="$SYSTEM_DROPIN_VALIDATED" + # Presence + ownership already ok'd before restart — do not double-count. +elif [[ "$SCOPE" == system && "$SYSTEM_OAUTH_SAFE" -eq 0 ]]; then + dropin_found="" # already not_ok'd; skip a second presence failure +else + for d in "${dropin_paths[@]}"; do + # Prefer -e/-L over -f: -f follows symlinks and would accept a planted link. + if [[ -L "$d" ]]; then + not_ok "Claude OAuth drop-in is a symlink ($d)" + dropin_found="" + break + elif [[ -e "$d" && ! -f "$d" ]]; then + not_ok "Claude OAuth drop-in is not a regular file ($d)" + dropin_found="" + break + elif [[ -f "$d" ]]; then + dropin_found="$d" + break + fi + done + if [[ -n "$dropin_found" ]]; then + ok "Claude OAuth drop-in present ($dropin_found)" + elif [[ "$SCOPE" != system || "$SYSTEM_OAUTH_SAFE" -eq 1 ]]; then + not_ok "Claude OAuth drop-in present (expected 42-claude-oauth-token.conf under ${RUNNER_UNIT}.d)" + fi +fi + +env_files="$("${CTL[@]}" show "$RUNNER_UNIT" -p EnvironmentFiles --value 2>/dev/null || true)" +token_file_from_unit="" +if [[ "$env_files" == *claude-setup-token.env* ]]; then + # Extract path: systemd prints "path (ignore_errors=yes)" lines. + token_file_from_unit="$(printf '%s\n' "$env_files" | tr ' ' '\n' | grep 'claude-setup-token\.env' | head -n1 || true)" + token_file_from_unit="${token_file_from_unit%% (*}" + if [[ "$SCOPE" == system ]]; then + # System scope must use the root-controlled canonical path — a legacy + # /var/lib/hapi/... basename match is not success for this change. + if [[ "$token_file_from_unit" == "/etc/hapi/claude-setup-token.env" ]]; then + ok "runner EnvironmentFiles uses canonical /etc/hapi/claude-setup-token.env" + else + not_ok "system runner EnvironmentFile must be /etc/hapi/claude-setup-token.env (got: ${token_file_from_unit:-empty})" + fi + else + ok "runner EnvironmentFiles references claude-setup-token.env" + fi +else + not_ok "runner EnvironmentFiles references claude-setup-token.env (got: ${env_files:-empty})" +fi + +# Prefer the path the unit actually loads; fall back by scope. +token_file="${token_file_from_unit:-}" +if [[ -z "$token_file" ]]; then + if [[ "$SCOPE" == system ]]; then + token_file="/etc/hapi/claude-setup-token.env" + else + token_file="$hapi_home/claude-setup-token.env" + fi +fi + +token_file_has_value=0 +token_file_readable=0 +if [[ -n "$token_file" ]]; then + # Fleet tokens are root:root 0600 under /etc/hapi. Installed sudoers only + # grant runner restart (hapi-watchdog.in) — not grep/test/python3. Do not + # fake a sudo -n read. System-scope verify must run as root. + # Symlink / non-regular nodes are hard failures (not "missing/empty"). + # System-scope already gated these before restart — do not double-count. + if [[ "$SCOPE" == system && "${SYSTEM_TOKEN_NODE_CHECKED:-0}" -eq 1 && ( -L "$token_file" || ( -e "$token_file" && ! -f "$token_file" ) ) ]]; then + : # already not_ok'd in pre-restart gate + elif [[ -L "$token_file" ]]; then + not_ok "Claude OAuth token file is a symlink ($token_file) — replace with a regular root:root 0600 file" + elif [[ -e "$token_file" && ! -f "$token_file" ]]; then + not_ok "Claude OAuth token file is not a regular file ($token_file) — replace with a regular root:root 0600 file" + elif [[ -r "$token_file" ]]; then + token_file_readable=1 + if hapi_claude_oauth_has_effective_token "$token_file"; then + token_file_has_value=1 + ok "Claude OAuth token file has CLAUDE_CODE_OAUTH_TOKEN= ($token_file)" + if [[ "$SCOPE" == system && "${SYSTEM_TOKEN_PRECHECKED:-0}" -eq 0 ]]; then + token_owner="$(stat -c '%U:%G' "$token_file" 2>/dev/null || true)" + token_mode="$(stat -c '%a' "$token_file" 2>/dev/null || true)" + if [[ "$token_owner" == "root:root" && "$token_mode" == "600" ]]; then + ok "Claude OAuth token file is root:root 0600 ($token_file)" + else + not_ok "Claude OAuth token file must be root:root 0600 (got ${token_owner:-unknown} mode ${token_mode:-unknown} at $token_file)" + fi + elif [[ "$SCOPE" != system ]]; then + token_uid="$(stat -c '%u' "$token_file" 2>/dev/null || true)" + token_mode="$(stat -c '%a' "$token_file" 2>/dev/null || true)" + want_uid="$(id -u)" + runner_user="$("${CTL[@]}" show "$RUNNER_UNIT" -p User --value 2>/dev/null || true)" + if [[ -n "$runner_user" && "$runner_user" != "-" ]]; then + want_uid="$(id -u "$runner_user" 2>/dev/null || echo "$want_uid")" + fi + if [[ "$token_uid" == "$want_uid" && "$token_mode" == "600" ]]; then + ok "Claude OAuth token file is runner-owned 0600 ($token_file)" + else + not_ok "user Claude OAuth token must be owned by the runner account mode 0600 (got uid ${token_uid:-unknown} mode ${token_mode:-unknown} at $token_file)" + fi + fi + else + inconclusive "Claude OAuth token file missing or empty (${token_file:-unknown}) — run claude setup-token and write CLAUDE_CODE_OAUTH_TOKEN=... then restart the runner" + fi + elif [[ "$SCOPE" == system && "${EUID:-$(id -u)}" -ne 0 ]]; then + if [[ -e "$token_file" ]]; then + not_ok "Claude OAuth token file unreadable as $(id -un) ($token_file) — re-run as root: sudo bash $REPO_ROOT/scripts/tooling/verify-hapi-install.sh" + else + inconclusive "Claude OAuth token file missing or empty (${token_file:-unknown}) — run claude setup-token then: printf 'CLAUDE_CODE_OAUTH_TOKEN=...\\n' | sudo install -m 0600 /dev/stdin $token_file && sudo systemctl restart the runner" + fi + elif [[ -e "$token_file" ]]; then + inconclusive "Claude OAuth token file unreadable ($token_file)" + else + inconclusive "Claude OAuth token file missing or empty (${token_file:-unknown}) — run claude setup-token and write CLAUDE_CODE_OAUTH_TOKEN=... then restart the runner" + fi + # User parents must be checked even when the leaf is missing/empty — a + # group/other-writable HAPI_HOME lets another account plant a token before + # Restart=always. Leaf mode checks above stay effective-token-only. + if [[ "$SCOPE" != system ]]; then + want_uid="$(id -u)" + runner_user="$("${CTL[@]}" show "$RUNNER_UNIT" -p User --value 2>/dev/null || true)" + if [[ -n "$runner_user" && "$runner_user" != "-" ]]; then + want_uid="$(id -u "$runner_user" 2>/dev/null || echo "$want_uid")" + fi + token_parent="$(dirname "$token_file")" + if [[ -d "$token_parent" || -L "$token_parent" ]]; then + if parent_err="$(hapi_claude_oauth_assert_user_token_parents "$token_file" "$want_uid" 2>&1)"; then + ok "user Claude OAuth token parent is runner-owned non-writable ($token_parent)" + else + not_ok "user Claude OAuth token parent must be runner-owned and not group/other-writable ($token_parent; ${parent_err:0:200})" + fi + fi + fi + # Parent dir must stay root-owned + not group/other-writable; otherwise the + # service account can unlink/replace a root:root 0600 token after verify. + # Capture helper stderr in-memory — never fixed /tmp paths. + # Skip re-check when the pre-restart gate already validated the parent. + if [[ "$SCOPE" == system && "${SYSTEM_TOKEN_PRECHECKED:-0}" -eq 0 ]]; then + token_parent="$(dirname "$token_file")" + if [[ -d "$token_parent" || -L "$token_parent" ]]; then + parent_err="" + if parent_err="$(hapi_claude_oauth_assert_root_controlled_parent "$token_parent" 2>&1)"; then + ok "Claude OAuth token parent is root-controlled ($token_parent)" + else + not_ok "Claude OAuth token parent must be root-owned and not group/other-writable ($token_parent; ${parent_err:0:200})" + fi + fi + fi +else + inconclusive "Claude OAuth token file missing or empty (unknown) — run claude setup-token and write CLAUDE_CODE_OAUTH_TOKEN=... then restart the runner" +fi + +# Bash fallback when python3 is absent (user-pet minimal hosts). Covers runner +# load + EnvironmentFile match; peer split-brain still needs python3 (hard-fail). +hapi_verify_claude_ambient_bash() { + local pid="$1" expect_load="$2" token_path="$3" + local runner_tok="" file_tok="" line="" + if [[ ! -r "/proc/$pid/environ" ]]; then + return 2 + fi + # Last assignment wins in the process environ too (rare duplicates). + while IFS= read -r -d '' line || [[ -n "$line" ]]; do + case "$line" in + CLAUDE_CODE_OAUTH_TOKEN=*) + runner_tok="${line#CLAUDE_CODE_OAUTH_TOKEN=}" + ;; + esac + done <"/proc/$pid/environ" + if [[ -n "$token_path" && -f "$token_path" && ! -L "$token_path" ]]; then + set +e + file_tok="$(hapi_claude_oauth_effective_token_value "$token_path" 2>/dev/null)" + set -e + fi + local runner_has=0 + [[ -n "$runner_tok" ]] && runner_has=1 + printf 'runner=%d peers=- expect_load=%d file_sha12=bash runner_sha12=bash home=bash\n' \ + "$runner_has" "$expect_load" + if [[ "$expect_load" -eq 1 && "$runner_has" -eq 0 ]]; then + return 3 + fi + if [[ "$runner_has" -eq 1 ]]; then + if [[ -z "$file_tok" || "$runner_tok" != "$file_tok" ]]; then + return 4 + fi + return 0 + fi + return 5 +} + +auth_probe_cmd=(python3) +auth_probe_need_root=0 +auth_probe_mode=python +if [[ "$main_pid" != "0" ]]; then + if [[ ! -r "/proc/$main_pid/environ" ]]; then + # Do not sudo -n python3: watchdog sudoers does not grant it. + if [[ "$SCOPE" == system && "${EUID:-$(id -u)}" -ne 0 ]]; then + not_ok "runner Claude ambient token unreadable as $(id -un) (/proc/$main_pid/environ) — re-run as root: sudo bash $REPO_ROOT/scripts/tooling/verify-hapi-install.sh" + auth_probe_cmd=() + auth_probe_need_root=1 + else + auth_probe_cmd=() + fi + elif ! command -v python3 >/dev/null 2>&1; then + if [[ "$SCOPE" == user ]]; then + auth_probe_mode=bash + auth_probe_cmd=() + else + # System verify already requires python3 for secure OAuth tooling. + not_ok "python3 required to verify runner Claude ambient token (install python3 and re-run)" + auth_probe_cmd=() + auth_probe_need_root=1 + fi + fi +fi + +if [[ "$main_pid" != "0" && "$auth_probe_mode" == bash ]]; then + set +e + auth_out="$(hapi_verify_claude_ambient_bash "$main_pid" "$token_file_has_value" "${token_file:-}")" + auth_rc=$? + set -e + if [[ "$auth_rc" -eq 0 ]]; then + ok "runner Claude ambient token loaded ($auth_out)" + elif [[ "$auth_rc" -eq 3 ]]; then + not_ok "token file has CLAUDE_CODE_OAUTH_TOKEN but runner process did not load it ($auth_out; restart runner after installing drop-in)" + elif [[ "$auth_rc" -eq 4 ]]; then + not_ok "runner CLAUDE_CODE_OAUTH_TOKEN does not match EnvironmentFile ($auth_out; restart runner after rotating the token)" + elif [[ "$auth_rc" -eq 5 ]]; then + inconclusive "runner Claude ambient token not loaded yet ($auth_out) — write CLAUDE_CODE_OAUTH_TOKEN and restart the runner" + else + not_ok "runner Claude ambient token probe failed without python3 (rc=$auth_rc)" + fi +elif [[ "$main_pid" != "0" && ${#auth_probe_cmd[@]} -gt 0 ]]; then + set +e + auth_out="$("${auth_probe_cmd[@]}" - "$main_pid" "$token_file_has_value" "${token_file:-}" "${hapi_home:-}" <<'PY' +import hashlib, os, sys +pid = sys.argv[1] +expect_load = sys.argv[2] == "1" +token_file = sys.argv[3] if len(sys.argv) > 3 else "" +expected_home = sys.argv[4] if len(sys.argv) > 4 else "" + +def read_oauth_from_environ(path): + try: + for item in open(path, "rb").read().split(b"\0"): + if item.startswith(b"CLAUDE_CODE_OAUTH_TOKEN="): + val = item.split(b"=", 1)[1] + # Empty CLAUDE_CODE_OAUTH_TOKEN= is not a loaded ambient login. + return val if val else None + except OSError: + return None + return None + +def read_env_var(path, key): + prefix = key.encode("utf-8") + b"=" + try: + for item in open(path, "rb").read().split(b"\0"): + if item.startswith(prefix): + return item.split(b"=", 1)[1].decode("utf-8", "replace") + except OSError: + return None + return None + +def sha12(raw): + return hashlib.sha256(raw).hexdigest()[:12] + +def real_uid(p): + try: + for line in open("/proc/%d/status" % p, "r", encoding="utf-8", errors="replace"): + if line.startswith("Uid:"): + return int(line.split()[1]) + except OSError: + return None + return None + +def descendants(root): + children = {} + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + p = int(entry) + try: + for line in open("/proc/%d/status" % p, "r", encoding="utf-8", errors="replace"): + if line.startswith("PPid:"): + pp = int(line.split()[1]) + children.setdefault(pp, []).append(p) + break + except OSError: + continue + out = {root} + stack = [root] + while stack: + cur = stack.pop() + for kid in children.get(cur, []): + if kid not in out: + out.add(kid) + stack.append(kid) + return out + +try: + runner_tok = read_oauth_from_environ("/proc/%s/environ" % pid) +except OSError: + sys.exit(2) +runner_has = bool(runner_tok) +runner_uid = real_uid(int(pid)) +runner_home = read_env_var("/proc/%s/environ" % pid, "HAPI_HOME") or expected_home +tree = descendants(int(pid)) + +def parse_env_file_value(raw): + # systemd env-file.c: PRE_VALUE skips whitespace; quotes concatenate + # with following unquoted segments ("abc"def -> abcdef). + if raw is None: + return None + val = raw.strip() + if not val: + return None + out = bytearray() + i = 0 + state = "pre" + while i < len(val): + c = val[i:i+1] + if state == "pre": + if c in b" \t\r": + i += 1 + continue + if c == b"'": + state = "sq" + i += 1 + continue + if c == b'"': + state = "dq" + i += 1 + continue + if c == b"\\": + state = "val" + if i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + else: + out.extend(c) + i += 1 + continue + state = "val" + out.extend(c) + i += 1 + continue + if state == "sq": + if c == b"'": + state = "pre" + else: + out.extend(c) + i += 1 + continue + if state == "dq": + if c == b"\\" and i + 1 < len(val): + nxt = val[i+1:i+2] + if nxt in (b"\\", b'"', b"`", b"$"): + out.extend(nxt) + else: + out.extend(b"\\") + out.extend(nxt) + i += 2 + continue + if c == b'"': + state = "pre" + i += 1 + continue + out.extend(c) + i += 1 + continue + if c == b"\\" and i + 1 < len(val): + out.extend(val[i+1:i+2]) + i += 2 + continue + out.extend(c) + i += 1 + if state in ("sq", "dq"): + return None + return bytes(out) or None + +file_tok = None +file_key_seen = False +if token_file and os.path.isfile(token_file) and not os.path.islink(token_file): + try: + blob = open(token_file, "rb").read() + physical = [ln.rstrip(b"\r") for ln in blob.splitlines()] + logical = [] + buf = b"" + join_nl = False + for piece in physical: + if not buf: + buf = piece + elif join_nl: + buf += b"\n" + piece + else: + buf += piece + eq = buf.find(b"=") + quote = None + cont_bs = False + if eq >= 0: + val = buf[eq + 1:].lstrip(b" \t") + i = 0 + pre = True + while i < len(val): + c = val[i:i+1] + if quote is None: + if pre: + if c in b" \t": + i += 1 + continue + if c == b"'": + quote = b"'" + i += 1 + continue + if c == b'"': + quote = b'"' + i += 1 + continue + if c == b"\\": + pre = False + if i + 1 >= len(val): + cont_bs = True + break + i += 2 + continue + pre = False + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + cont_bs = True + break + i += 2 + continue + i += 1 + continue + if quote == b"'": + if c == b"'": + quote = None + pre = True + i += 1 + continue + if c == b"\\": + if i + 1 >= len(val): + quote = b'"' + cont_bs = True + break + i += 2 + continue + if c == b'"': + quote = None + pre = True + i += 1 + if cont_bs and buf.endswith(b"\\"): + buf = buf[:-1] + join_nl = False + continue + if quote is not None: + join_nl = True + continue + logical.append(buf) + buf = b"" + join_nl = False + if buf: + logical.append(buf) + # systemd applies the *last* assignment for a key; scan logical lines. + for line in logical: + s = line.lstrip(b" \t") + if not s or s.startswith(b"#") or s.startswith(b";"): + continue + eq = s.find(b"=") + if eq < 0: + continue + key = s[:eq].rstrip(b" \t") + if key == b"CLAUDE_CODE_OAUTH_TOKEN": + file_key_seen = True + # Same nonempty rule as the shell probe after systemd unquote: + # CLAUDE_CODE_OAUTH_TOKEN=\r\n / "" / '' are missing, not a value. + file_tok = parse_env_file_value(s[eq + 1:]) + except OSError: + file_tok = None + file_key_seen = False + +# KillMode=process reparents session wrappers to init. Count same-uid hapi/claude +# peers that are still in the MainPID descendant tree, OR share HAPI_HOME *and* +# the runner's systemd cgroup (UID+HAPI_HOME alone matches interactive shells +# that sourced .bashrc on user-pet hosts). +def cgroup_paths(proc_pid): + paths = [] + try: + with open("/proc/%d/cgroup" % proc_pid, encoding="utf-8", errors="replace") as fh: + for line in fh: + parts = line.strip().split(":") + if len(parts) >= 3 and parts[-1]: + paths.append(parts[-1]) + except OSError: + pass + return paths + +def cgroup_related(runner_paths, peer_paths): + for rp in runner_paths: + if not rp or rp == "/": + continue + rp_norm = rp.rstrip("/") + for pp in peer_paths: + if not pp: + continue + pp_norm = pp.rstrip("/") + if pp_norm == rp_norm or pp_norm.startswith(rp_norm + "/"): + return True + return False + +runner_cgroups = cgroup_paths(int(pid)) +peer_has = 0 +if runner_uid is not None: + for entry in os.listdir("/proc"): + if not entry.isdigit(): + continue + cpid = int(entry) + if cpid == int(pid): + continue + if real_uid(cpid) != runner_uid: + continue + try: + comm = open("/proc/%d/comm" % cpid, encoding="utf-8", errors="replace").read().strip() + except OSError: + continue + if comm not in ("hapi", "claude"): + continue + in_tree = cpid in tree + peer_home = read_env_var("/proc/%d/environ" % cpid, "HAPI_HOME") + same_home = bool(runner_home) and peer_home == runner_home + same_cgroup = cgroup_related(runner_cgroups, cgroup_paths(cpid)) + # Non-descendants need cgroup association — not HAPI_HOME alone. + if not in_tree and not (same_home and same_cgroup): + continue + if read_oauth_from_environ("/proc/%d/environ" % cpid) is not None: + peer_has += 1 + +file_h = sha12(file_tok) if file_tok else "-" +run_h = sha12(runner_tok) if runner_tok else "-" +print("runner=%d peers=%d expect_load=%d file_sha12=%s runner_sha12=%s home=%s" % ( + int(runner_has), peer_has, int(expect_load), file_h, run_h, runner_home or "-")) + +# Split-brain: instance peers still carry a token, runner does not. +if peer_has and not runner_has: + sys.exit(1) +# Token file claims a value but the running runner never loaded it. +if expect_load and not runner_has: + sys.exit(3) +# Loaded ambient token must have a durable matching backing file — missing file, +# empty assignment, or mismatch all mean the next restart loses/changes auth. +if runner_has: + if file_tok is None or runner_tok != file_tok: + sys.exit(4) + sys.exit(0) +# Fresh install / token not configured yet (no peers proving prior auth). +sys.exit(5) +PY +)" + auth_rc=$? + set -e + if [[ "$auth_rc" -eq 0 ]]; then + ok "runner Claude ambient token loaded ($auth_out)" + elif [[ "$auth_rc" -eq 1 ]]; then + not_ok "runner missing CLAUDE_CODE_OAUTH_TOKEN while same-instance Claude/hapi peers still have it ($auth_out; new UI spawns will /login)" + elif [[ "$auth_rc" -eq 3 ]]; then + not_ok "token file has CLAUDE_CODE_OAUTH_TOKEN but runner process did not load it ($auth_out; restart runner after installing drop-in)" + elif [[ "$auth_rc" -eq 4 ]]; then + not_ok "runner CLAUDE_CODE_OAUTH_TOKEN does not match EnvironmentFile ($auth_out; restart runner after rotating the token)" + elif [[ "$auth_rc" -eq 5 ]]; then + inconclusive "runner Claude ambient token not loaded yet ($auth_out) — write CLAUDE_CODE_OAUTH_TOKEN and restart the runner" + else + inconclusive "runner Claude ambient token (could not read /proc/$main_pid/environ)" + fi +else + if [[ "$auth_probe_need_root" -eq 0 ]]; then + inconclusive "runner Claude ambient token (no readable /proc/$main_pid/environ)" + fi +fi + hub_pid="$("${CTL[@]}" show "$HUB_UNIT" -p MainPID --value 2>/dev/null || true)" hub_pid="${hub_pid:-0}" if [[ "$SCOPE" == system && "$hub_pid" != "0" && -r "/proc/$hub_pid/oom_score_adj" ]]; then @@ -380,7 +1360,8 @@ else not_ok "hub /health ok ($HUB_URL → rc=$health_rc $(head -c 120 <<<"$health"))" fi -printf '\n# Explicit gap: agent CLI / Claude auth NOT tested (pet installer leaves login to the operator).\n' -printf '# This check proves supervision + hub HTTP, not the ninja "sessions cascade on restart" class.\n' +printf '\n# Claude auth: drop-in + EnvironmentFiles + /proc load + HAPI_HOME/descendant peer split-brain.\n' +printf '# Does not call Anthropic or print token values. Missing token file on a fresh box is inconclusive.\n' +printf '# System-scope OAuth file is root:root 0600; run verify as root (sudoers does not grant grep/python3).\n' printf '# pass=%d fail=%d inconclusive=%d\n' "$PASS" "$FAIL" "$INCONCLUSIVE" exit "$FAIL" From 0c83ff5fa1e566fd5277b002510c7dbea24afa96 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:03:59 +0000 Subject: [PATCH 2/2] chore(tooling): re-trigger CI after cancelled duplicate check-run Co-authored-by: Cursor <cursoragent@cursor.com>