GL-4: uninstaller gap-closure + operator-key day-0 fold (host-install v1.11.0)

Uninstall: NEW 4b4 removes the self-update artifacts (guarded wrapper,
.prev/.new.* A/B slots, rollback unit, limits drop-in — derived from
configs/felhom-selfupdate-guarded); enrolled drives under /mnt/felhom-drives
unmounted plain-umount-only (busy = warn+guidance, never -l/-f; root bind
guarded); both modes end with a KEPT-vs-WIPED statement (drives/PBS/hub
record/escrow/vaulted recovery credential live on); guest-only mode prints
the vmid's bind-store drives.

Key-pin: OPERATOR_KEY_* constants (empty until the operator ceremony) +
--operator-pubkey-file (argv-validated; comment=key_id required; file
overrides constants) -> authz.signers per the agent SignerKey schema;
no-keys-resolved KEEPS preserved signers; verify reports armed/dormant WARN.

Harness: +13 static cases + GL4-D parity + GL4-INV + PVE-tier GL4 H-U
(full-uninstall dry transcript vs live 9201). 28/28 on felhom-pve;
red-proofs RP-1..3 run->fail->revert. GO-LIVE-PACKAGE.md ABSENT AGAIN ->
status in CONTEXT.md. Live teardown/armed-pin = GL-6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-08 08:14:07 +02:00
parent a63cc7151b
commit f7cc6a720e
7 changed files with 484 additions and 52 deletions
+220 -7
View File
@@ -1,6 +1,6 @@
#!/bin/bash
#===============================================================================
# felhom-host-install.sh v1.10.0
# felhom-host-install.sh v1.11.0
# Day-0 host-bootstrap for a Felhom Proxmox host (operator-deploy model).
#
# Run by the operator on a FRESHLY-PVE-INSTALLED box (after a manual PVE install
@@ -28,6 +28,15 @@
# stay off), pool+ACL verify asserts (BOTH modes — campaign-2 R2 lesson), and --preflight-only.
# Test harness: scripts/hostinstall-mode-harness.sh (static tier runs anywhere; PVE tier dry-only).
#
# v1.11.0 (GL-4, go-live G6 + the G1 key-pin fold): (A) uninstall gap-closure — the teardown now
# also removes the self-update artifacts (guarded wrapper, .prev/.new.* A/B slots, rollback unit,
# start-limit drop-in), unmounts every enrolled drive under /mnt/felhom-drives (plain umount only,
# NEVER -l/-f; busy = warn + guidance, data always stays on the drive), and ends with an explicit
# KEPT-vs-WIPED statement (PBS backups, hub record, escrow, vaulted recovery credential live on).
# (B) operator-key day-0 fold — OPERATOR_KEY_* constants (empty until the pin ceremony) and
# --operator-pubkey-file write authz.signers into the agent config at step 6; empty keys keep a
# preserved config's signers (never clobber a manually-pinned box); verify reports armed/dormant.
#
# Grounding: documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md
#
# Usage:
@@ -76,6 +85,12 @@
#
# --passphrase-file PATH read the retrieval passphrase from a 0600 file
# (default: secure no-echo prompt)
# --operator-pubkey-file PATH pin the operator signing PUBLIC keys at day-0 (GL-4). One key per
# line: "operational <authorized_keys line>" or "recovery <authorized_keys
# line>" (# comments/blank ok; the authorized_keys comment field is the
# key_id and is required). Overrides the script's OPERATOR_KEY_* constants.
# Written to authz.signers in the agent config; no keys resolved = a
# preserved config's signers are KEPT, else self-update stays dormant.
# --preserve-from PATH merge non-Day-0 sections (privileged/storage/backup/
# local_api/authz/lan_resolver) from an existing config
# --preserve-state-from PATH carry the prior agent leaf+key+token-store (local-api.crt/key,
@@ -129,7 +144,17 @@
set -euo pipefail
SCRIPT_VERSION="1.10.0" # keep in sync with the header line at the top of this file
SCRIPT_VERSION="1.11.0" # keep in sync with the header line at the top of this file
# Operator signing keys pinned at day-0 (GL-4; doc 04 §3 two-key model). EMPTY by default — the pin
# CEREMONY is an operator step: generate the real keypairs OFFLINE, then fill these four constants
# in one commit (or pass --operator-pubkey-file at install time, which overrides them). Empty =
# no authz.signers written = agent self-update stays DORMANT (the safe default; the verify step
# warns). PUBLIC keys only — this script never generates, reads, or references private key material.
OPERATOR_KEY_OPERATIONAL_ID="" # key_id = the authorized_keys comment, e.g. "felhom-op-1"
OPERATOR_KEY_OPERATIONAL_LINE="" # full authorized_keys line: "ssh-ed25519 AAAA… felhom-op-1"
OPERATOR_KEY_RECOVERY_ID="" # cold key; authorizes only key-rotation/break-glass
OPERATOR_KEY_RECOVERY_LINE=""
#-------------------------------------------------------------------------------
# Logging (mirrors felhom-controller/scripts/docker-setup.sh)
@@ -166,6 +191,7 @@ SYSDATA_GROW=""
CPU_CORES="" # --cores: optional appliance CPU-core cap (empty/unset = golden default)
MEM_MIB="" # --memory: optional appliance RAM cap in MiB (empty/unset = golden default)
PASSPHRASE_FILE=""
OPERATOR_PUBKEY_FILE="" # --operator-pubkey-file: "operational|recovery <authorized_keys line>" per line; overrides the OPERATOR_KEY_* constants (GL-4)
PRESERVE_FROM=""
PRESERVE_STATE_FROM="" # dir holding a prior local-api.{crt,key} + local-tokens.log to carry over (keeps the pin stable across a reinstall)
ALLOW_NEW_LEAF=false # opt-in to intentionally regenerate the agent leaf on a populated host (else the guard refuses)
@@ -495,12 +521,79 @@ remove_old_broad_acl() {
if _role_exists "$PVE_ROLE"; then run pveum role delete "$PVE_ROLE"; else log_skip " old broad role $PVE_ROLE already absent"; fi
}
# _guest_drive_note VMID — GL-4 (guest-only mode): best-effort list of the drives the agent's bind
# store records for THIS vmid (/var/lib/felhom-agent/guest-binds.json, vmid -> durable-ids); store
# unreadable/absent → the generic note. Read-only, never dies.
_guest_drive_note() {
local vmid="$1" binds=""
binds=$(python3 -c "import json
try:
d=json.load(open('$AGENT_STATE_DIR/guest-binds.json'))
print(', '.join(d.get('$vmid',[])))
except Exception:
pass" 2>/dev/null || true)
if [[ -n "$binds" ]]; then
log_info " drives recorded as bound to guest $vmid: $binds"
log_info " they stay mounted (other guests may share the host) — eject via the dashboard BEFORE uninstalling if this guest owned them."
else
log_info " enrolled drives (if any) stay mounted; eject a drive via the dashboard BEFORE uninstall if it belonged to this guest."
fi
}
# _uninstall_statement full|guest-only — GL-4: the explicit end-of-teardown KEPT-vs-WIPED statement.
# WIPED mirrors what THIS mode actually ran; KEPT names everything that deliberately lives on.
# Statement-only: this script NEVER deletes PBS backups, hub records, escrow blobs, or drive data.
# Reads run_uninstall's locals (vmid, pool_removed, _busy_mounts, _had_break_glass) via bash's
# dynamic scoping — call it from run_uninstall only.
_uninstall_statement() {
local scope="$1"
echo ""
log_step "kept vs wiped — read before pulling drives or closing the customer out"
echo " WIPED (this run):"
echo " - guest $vmid (container + its OS/Docker/user-data volumes)"
if [[ "$scope" == "full" ]]; then
echo " - the felhom-agent: binary, unit, sudoers, config, state dir, service user"
echo " - self-update artifacts: guarded wrapper, A/B slots (.prev/.new.*), rollback unit, start-limit drop-in"
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets"
echo " - pveum: the Felhom roles/user/token/scoped ACL$( $pool_removed && printf '; the emptied %s pool' "$PVE_POOL")"
echo " - the install state file"
if $REMOVE_GOLDEN; then echo " - the golden vzdump (--remove-golden)"; fi
else
echo " - NOTHING host-level (other Felhom guests remain: agent, token/ACL, pool, state all stay)"
fi
echo " KEPT (lives on deliberately — remove/rotate these out-of-band if the customer is leaving):"
if [[ "$scope" == "full" ]]; then
echo " - the enrolled drives + ALL data under /mnt/felhom-drives — unmounted only, NEVER wiped;"
if [[ ${#_busy_mounts[@]} -gt 0 ]]; then
echo " physically removable now, EXCEPT still mounted (busy — stop the apps and retry): ${_busy_mounts[*]}"
else
echo " the drives are physically removable now."
fi
else
echo " - the enrolled drives + ALL data under /mnt/felhom-drives — left MOUNTED (remaining guests may use them)"
fi
if pvesm status 2>/dev/null | awk '$2=="pbs"{found=1} END{exit !found}'; then
echo " - the PBS backups + this customer's namespace on the PBS side — delete there if wanted"
fi
echo " - the hub host/customer record + report history (operator UI / DB)"
echo " - the escrow blob in the hub, if one exists (operator UI)"
if $_had_break_glass; then
echo " - the hub-vaulted root@pam recovery credential — the box KEEPS the password step 4b set; rotate it if the box leaves Felhom management"
fi
echo ""
}
# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it
# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user,
# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8.
run_uninstall() {
log_step "UNINSTALL — local host teardown"
# GL-4: snapshot state facts BEFORE any removal (the closing statement needs them; the state
# file itself is deleted in step 7).
local _had_break_glass=false _busy_mounts=()
_state_has break_glass && _had_break_glass=true
# 1. Resolve the target vmid: --vmid, else the recorded provisioned_vmid, else die.
local state_vmid vmid pool_removed=false
state_vmid=$(_state_get provisioned_vmid)
@@ -575,6 +668,9 @@ run_uninstall() {
echo ""
log_warn "Other Felhom guests remain (${others_csv}); leaving the agent + PVE token + state in place."
log_warn "Re-run --uninstall --force to remove host-level components anyway (this orphans ${others_csv})."
# GL-4 (Scenario B): NO umounts in guest-only mode — drives may serve the remaining guests.
_guest_drive_note "$vmid"
_uninstall_statement guest-only
log_success "UNINSTALL (guest-only) complete — removed guest $vmid; host-level components preserved."
log_info " NOTE: the host record still exists in the hub — remove it there if desired."
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
@@ -651,6 +747,27 @@ run_uninstall() {
if [[ -d /etc/felhom-sshd ]]; then run rm -rf /etc/felhom-sshd; fi
if id felhom-op >/dev/null 2>&1; then run userdel -r felhom-op 2>/dev/null || run userdel felhom-op; fi
# 4b4. Self-update artifacts (TASK D1; GL-4 gap-closure). Paths derived from the AUTHORITATIVE
# list in felhom-agent configs/felhom-selfupdate-guarded: the wrapper itself, the A/B slot
# files it creates next to the live binary (.prev snapshot + orphaned .new.* temps), plus
# the rollback unit + start-limit drop-in step 5 installs alongside it. pending.json lives
# under $AGENT_STATE_DIR (already removed in 4). Tolerate-absent throughout.
if systemctl list-unit-files felhom-agent-rollback.service >/dev/null 2>&1; then
systemctl is-active --quiet felhom-agent-rollback 2>/dev/null && run systemctl stop felhom-agent-rollback
systemctl is-enabled --quiet felhom-agent-rollback 2>/dev/null && run systemctl disable felhom-agent-rollback
fi
run systemctl reset-failed felhom-agent-rollback.service 2>/dev/null || true
local sua
for sua in /usr/local/sbin/felhom-selfupdate-guarded /etc/systemd/system/felhom-agent-rollback.service \
"${AGENT_BIN}.prev"; do
if [[ -e "$sua" ]]; then run rm -f "$sua"; fi
done
for sua in "${AGENT_BIN}".new.*; do [[ -e "$sua" ]] && run rm -f "$sua"; done
if [[ -d "${AGENT_UNIT}.d" ]]; then
if [[ -f "${AGENT_UNIT}.d/felhom-agent-limits.conf" ]]; then run rm -f "${AGENT_UNIT}.d/felhom-agent-limits.conf"; fi
run rmdir "${AGENT_UNIT}.d" 2>/dev/null || true
fi
# 4c. Shared-parent unit + wrapper + /mnt/felhom-drives (agent-installed at runtime; drill R2).
# Stop/disable, remove unit + script, unbind + remove the (empty) parent dir. Tolerate-absent.
if systemctl list-unit-files felhom-shared-parent.service 2>/dev/null | grep -q felhom-shared-parent; then
@@ -662,7 +779,31 @@ run_uninstall() {
if [[ -f /etc/systemd/system/felhom-shared-parent.service ]]; then run rm -f /etc/systemd/system/felhom-shared-parent.service; else log_skip " felhom-shared-parent.service already absent"; fi
if [[ -f /usr/local/sbin/felhom-shared-parent.sh ]]; then run rm -f /usr/local/sbin/felhom-shared-parent.sh; fi
run systemctl daemon-reload
if mountpoint -q /mnt/felhom-drives 2>/dev/null; then run umount /mnt/felhom-drives; fi
# GL-4: unmount every enrolled/network drive mounted UNDER /mnt/felhom-drives (deepest first)
# BEFORE the root self-bind. Plain umount ONLY — NEVER -l/-f: a lazy/forced unmount on a busy
# data mount risks the customer's data; a busy mount gets a warning + guidance instead. The
# DATA STAYS ON THE DRIVE — nothing here (or anywhere in this script) wipes or formats it.
local dmnt
while IFS= read -r dmnt; do
[[ -n "$dmnt" ]] || continue
if $DRY_RUN; then
log_dry "umount $dmnt # data stays on the drive"
elif umount "$dmnt" 2>/dev/null; then
log_success " unmounted $dmnt (data stays on the drive)"
else
log_warn " $dmnt is busy — NOT forcing (never umount -l/-f). Eject the drive via the dashboard, or stop the apps using it and re-run."
_busy_mounts+=("$dmnt")
continue
fi
run rmdir "$dmnt" 2>/dev/null || true
done < <(findmnt -rn -o TARGET 2>/dev/null | grep '^/mnt/felhom-drives/' | sort -r || true)
if mountpoint -q /mnt/felhom-drives 2>/dev/null; then
if [[ ${#_busy_mounts[@]} -gt 0 ]]; then
log_warn " /mnt/felhom-drives root bind left mounted (busy drive mounts above must go first)"
else
run umount /mnt/felhom-drives
fi
fi
if [[ -d /mnt/felhom-drives ]]; then run rmdir /mnt/felhom-drives 2>/dev/null || true; fi
# 4d. Guarded-mkfs wrapper, guest-hook snippet, lan-resolver dnsmasq snippets (drill R3-R5).
@@ -730,9 +871,9 @@ run_uninstall() {
if [[ -f "$STATE_FILE" ]]; then run rm -f "$STATE_FILE"; else log_skip " $STATE_FILE already absent"; fi
run rmdir "$STATE_DIR" 2>/dev/null || true
# 8. Summary.
echo ""
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config/user + shared-parent/mkfs-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
# 8. Summary + the GL-4 kept-vs-wiped statement.
_uninstall_statement full
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
if $REMOVE_GOLDEN; then log_info " golden vzdump: removed."; else log_info " golden vzdump: left in place (--remove-golden to remove)."; fi
log_info " NOTE: the 'sudo' and 'dnsmasq' packages were left installed (system packages); the host record still exists in the hub — remove it there if desired."
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
@@ -841,6 +982,7 @@ while [[ $# -gt 0 ]]; do
--cores) CPU_CORES="$2"; shift 2 ;;
--memory) MEM_MIB="$2"; shift 2 ;;
--passphrase-file) PASSPHRASE_FILE="$2"; shift 2 ;;
--operator-pubkey-file) OPERATOR_PUBKEY_FILE="$2"; shift 2 ;;
--preserve-from) PRESERVE_FROM="$2"; shift 2 ;;
--preserve-state-from) PRESERVE_STATE_FROM="$2"; shift 2 ;;
--allow-new-leaf) ALLOW_NEW_LEAF=true; shift ;;
@@ -954,6 +1096,46 @@ if [[ "$MODE" == "byo" ]]; then
fi
fi
# GL-4: resolve the operator signing keys — script constants by default; --operator-pubkey-file
# OVERRIDES them (C5). Validated HERE (before the passphrase prompt, before any step) so a bad key
# file dies fast and the harness can exercise it on a non-PVE machine. File format: one key per
# line, "operational <authorized_keys line>" or "recovery <authorized_keys line>"; '#' comments and
# blank lines allowed. The key_id is the authorized_keys COMMENT field — required (the agent's
# signed-op verifier addresses keys by key_id).
RESOLVED_OP_ID="$OPERATOR_KEY_OPERATIONAL_ID"; RESOLVED_OP_LINE="$OPERATOR_KEY_OPERATIONAL_LINE"
RESOLVED_REC_ID="$OPERATOR_KEY_RECOVERY_ID"; RESOLVED_REC_LINE="$OPERATOR_KEY_RECOVERY_LINE"
if [[ -n "$OPERATOR_PUBKEY_FILE" ]]; then
[[ -f "$OPERATOR_PUBKEY_FILE" ]] || die "--operator-pubkey-file not found: $OPERATOR_PUBKEY_FILE"
if [[ -n "$RESOLVED_OP_LINE$RESOLVED_REC_LINE" ]]; then
log_info "--operator-pubkey-file overrides the script's built-in operator key constants"
fi
RESOLVED_OP_ID=""; RESOLVED_OP_LINE=""; RESOLVED_REC_ID=""; RESOLVED_REC_LINE=""
_kseen=false
while IFS= read -r _kline || [[ -n "$_kline" ]]; do
_kline="${_kline%$'\r'}"
[[ -z "$_kline" || "$_kline" == \#* ]] && continue
_krole="${_kline%% *}"; _krest="${_kline#* }"
case "$_krole" in
operational|recovery) ;;
*) die "--operator-pubkey-file: unknown role '$_krole' (want operational|recovery) in line: $_kline" ;;
esac
read -r _ktype _kb64 _kid _ <<<"$_krest"
[[ "$_ktype" == ssh-* || "$_ktype" == sk-ssh-* || "$_ktype" == ecdsa-* ]] \
|| die "--operator-pubkey-file: not an authorized_keys line (bad key type '${_ktype:-<empty>}') in line: $_kline"
[[ -n "$_kb64" && "$_kb64" =~ ^[A-Za-z0-9+/=]+$ ]] \
|| die "--operator-pubkey-file: not an authorized_keys line (missing/invalid key material) in line: $_kline"
[[ -n "$_kid" ]] \
|| die "--operator-pubkey-file: key line has no comment field — the comment IS the key_id; add one (e.g. felhom-op-1): $_kline"
if [[ "$_krole" == operational ]]; then
RESOLVED_OP_ID="$_kid"; RESOLVED_OP_LINE="$_krest"
else
RESOLVED_REC_ID="$_kid"; RESOLVED_REC_LINE="$_krest"
fi
_kseen=true
done < "$OPERATOR_PUBKEY_FILE"
$_kseen || die "--operator-pubkey-file has no key lines (empty/comments only): $OPERATOR_PUBKEY_FILE"
fi
# Resume mode-mismatch (C4): an install keeps the mode it started with — mode-flipping a half-done
# install would skip already-completed steps under the WRONG profile's guarantees.
if $RESUME; then
@@ -1017,6 +1199,7 @@ EOF
echo " guest: the provisioned Felhom LXC (vmid ${VMID}, capped ${CPU_CORES} cores / ${MEM_MIB} MiB) + its volumes"
echo " + the golden vzdump imported onto storage '${ARCHIVE_STORAGE}'"
fi
echo " update: operator-signed self-update authority: ${RESOLVED_OP_ID:-NONE (self-update stays dormant)}"
echo " NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), WireGuard tunnels, OOB sshd."
echo ""
if $DRY_RUN; then
@@ -1716,6 +1899,11 @@ step_agent_config() {
if $DRY_RUN; then
log_dry "write $AGENT_CONFIG (0600): proxmox{endpoint,node=$NODE,token=<secret>,tls.fingerprint=$fp} hub{url=$HUB_URL,host_id=$HOST_ID,api_key=<secret>} local_api{$BRIDGE_ADDR}"
if [[ -n "$RESOLVED_OP_LINE$RESOLVED_REC_LINE" ]]; then
log_dry "write authz.signers: operational=${RESOLVED_OP_ID:-<none>} recovery=${RESOLVED_REC_ID:-<none>} (operator-signed self-update authority)"
else
log_dry "authz.signers: none resolved — self-update stays dormant (preserved signers, if any, are kept)"
fi
if [[ "$MODE" == "byo" ]]; then
log_dry "assert (byo) written config: lan_resolver.enable is false/absent"
log_dry "assert (byo) written config: wg_tunnel.enabled is false/absent"
@@ -1730,8 +1918,10 @@ step_agent_config() {
# Secrets passed via env (NOT argv) to avoid ps exposure.
PVE_TOKEN="$PVE_TOKEN" HOST_API_KEY="$HOST_API_KEY" \
NODE="$NODE" FP="$fp" HUB_URL="$HUB_URL" HOST_ID="$HOST_ID" BRIDGE_ADDR="$BRIDGE_ADDR" \
OP_KEY_ID="$RESOLVED_OP_ID" OP_KEY_LINE="$RESOLVED_OP_LINE" \
REC_KEY_ID="$RESOLVED_REC_ID" REC_KEY_LINE="$RESOLVED_REC_LINE" \
PRESERVE_FROM="$PRESERVE_FROM" INSTALL_MODE="$MODE" OUT="$AGENT_CONFIG" python3 <<'PY'
import json, os
import json, os, sys
out = os.environ['OUT']
base = {}
pf = os.environ.get('PRESERVE_FROM','')
@@ -1778,6 +1968,21 @@ base['hub'] = {
"poll_seconds": base.get('hub',{}).get('poll_seconds',900),
"timeout_seconds": base.get('hub',{}).get('timeout_seconds',30),
}
# GL-4: operator-signed self-update authority (authz.signers, schema = agent config.go SignerKey).
# Script/file-provided keys are authoritative ONLY when non-empty — a reinstall with NO keys keeps
# a manually-pinned box's preserved signers (never silently clobber; C4). nonce_store_path is never
# invented here: the agent defaults it, and a preserved value rides along untouched in base['authz'].
signers = []
if os.environ.get('OP_KEY_LINE',''):
signers.append({"key_id": os.environ['OP_KEY_ID'], "role": "operational", "public_key": os.environ['OP_KEY_LINE']})
if os.environ.get('REC_KEY_LINE',''):
signers.append({"key_id": os.environ['REC_KEY_ID'], "role": "recovery", "public_key": os.environ['REC_KEY_LINE']})
if signers:
authz = base.setdefault('authz', {})
prev = authz.get('signers') or []
if prev:
print("[config] replacing %d preserved authz signer(s) with the script/file-provided operator key(s) (pin rotation)" % len(prev), file=sys.stderr)
authz['signers'] = signers
fd = os.open(out, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600)
with os.fdopen(fd,'w') as f:
json.dump(base, f, indent=2); f.write('\n')
@@ -1949,6 +2154,14 @@ step_verify() {
log_error " acl: $_arole@$_apath MISSING for the user and/or the token (re-apply with --rescope-acl)"; ok=false
fi
done
# GL-4: operator-key pin state. Dormant is the SAFE default, not an error — WARN only.
local _signers
_signers=$(python3 -c "import json;print(len(json.load(open('$AGENT_CONFIG')).get('authz',{}).get('signers') or []))" 2>/dev/null || echo 0)
if [[ "${_signers:-0}" -ge 1 ]]; then
log_success " authz signers: $_signers (operator-signed self-update armed)"
else
log_warn " no operator key pinned — agent self-update stays dormant (pin via --operator-pubkey-file or the script's OPERATOR_KEY_* constants; the key ceremony is an operator step)"
fi
# controller container healthy in-guest — bounded wait (the post-provision reboot + docker start
# take a while, especially on modest hardware; drill R6 re-verify)
local cstat="" _waited=0