File size: 9,942 Bytes
f76c374 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | #!/usr/bin/env python3
"""Boot-time re-seed of a terminally-dead Nous bootstrap session.
Background
----------
A Nous bootstrap session (client_id ``hermes-cli-vps``) can take a terminal
``invalid_grant`` and be quarantined locally β the refresh path clears the dead
tokens from ``auth.json`` and stamps
``providers.nous.last_auth_error.relogin_required = true``. From then on every
inference turn hard-fails with a provider-auth error until the credential is
replaced, even though the gateway and dashboard otherwise look healthy.
``stage2-hook.sh`` seeds ``auth.json`` from ``HERMES_AUTH_JSON_BOOTSTRAP`` only
on a *blank* volume (``[ ! -f auth.json ]``) β that guard is load-bearing: it
stops a container restart from clobbering a healthy, rotated refresh token. So a
plain restart with a fresh seed env can NOT recover a container whose volume
already has an auth.json.
This script is the narrow, safe exception. An orchestrator that manages the
container can supply a freshly-issued bootstrap session via
``HERMES_AUTH_JSON_REBOOTSTRAP`` (plus a restart). On boot we re-seed the Nous
provider entry from that env when the on-disk entry is provably terminal, or
when the orchestrator seed's ``obtained_at`` is newer than the local session.
The latter matters because an orchestrator may revoke the previous session
before restart while its still-present local tokens look healthy. Older or
incomparable seeds remain no-ops, so a retained env cannot roll auth backward.
Design constraints
------------------
- Pure stdlib, no hermes_cli imports: runs early in the boot hook, before the
app venv/modules are guaranteed importable, as its own subprocess.
- Surgical: replaces ONLY ``providers.nous`` in the existing auth.json, leaving
every other provider, the version, and any other top-level state untouched.
- Fail-safe: any parse/IO error leaves auth.json exactly as-is and exits 0 (a
failed re-seed must never take the container further down than it already is).
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from datetime import datetime, timezone
from typing import Any, Optional
# Env var the orchestrator sets to the re-seed payload. Deliberately DISTINCT
# from HERMES_AUTH_JSON_BOOTSTRAP (create-only, blank-volume seed) so the two
# paths can never be confused: BOOTSTRAP seeds a fresh volume; REBOOTSTRAP
# overwrites a terminally-dead Nous entry on an existing volume.
REBOOTSTRAP_ENV = "HERMES_AUTH_JSON_REBOOTSTRAP"
BOOTSTRAP_CLIENT_ID = "hermes-cli-vps"
def _nous_entry_is_terminal(nous_state: Any) -> bool:
"""True iff the on-disk Nous provider entry is in the terminal/quarantined
state AND holds no usable credential.
Mirrors the ``terminal`` predicate in ``hermes_cli.auth.get_nous_session_validity``:
a persisted ``last_auth_error.relogin_required`` with the token material
already cleared. Keeping this in lockstep is what guarantees we only re-seed
a session that is genuinely dead.
"""
if not isinstance(nous_state, dict):
return False
last_err = nous_state.get("last_auth_error")
if not (isinstance(last_err, dict) and last_err.get("relogin_required")):
return False
# Only terminal while there is no usable credential left. If a live token is
# somehow present, treat it as healthy and do NOT clobber it.
if nous_state.get("access_token") or nous_state.get("refresh_token"):
return False
return True
def _extract_nous_from_seed(seed_raw: str) -> Optional[dict]:
"""Pull the ``providers.nous`` block out of a HERMES_AUTH_JSON_REBOOTSTRAP
payload. The payload is a full auth.json document (same shape as
HERMES_AUTH_JSON_BOOTSTRAP). Returns None unless it carries the expected VPS
bootstrap client plus non-empty access and refresh tokens β caller treats
None as "nothing to do"."""
try:
seed = json.loads(seed_raw)
except (ValueError, TypeError):
return None
if not isinstance(seed, dict):
return None
providers = seed.get("providers")
if not isinstance(providers, dict):
return None
nous = providers.get("nous")
if not isinstance(nous, dict) or not nous:
return None
if nous.get("client_id") != BOOTSTRAP_CLIENT_ID:
return None
if not (
isinstance(nous.get("access_token"), str)
and nous["access_token"].strip()
and isinstance(nous.get("refresh_token"), str)
and nous["refresh_token"].strip()
):
return None
return nous
def _parse_timestamp(value: Any) -> Optional[datetime]:
"""Parse an OAuth timestamp without guessing when either side is malformed."""
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, OverflowError):
return None
if parsed.tzinfo is None:
return None
try:
return parsed.astimezone(timezone.utc)
except (OverflowError, ValueError):
return None
def _seed_is_newer(local_nous: Any, seed_nous: dict) -> bool:
"""Whether NAS supplied a bootstrap session newer than the local one.
NAS mints the replacement before restarting the machine and revokes the
previous session. A healthy-looking local entry can therefore already be
stale. ``obtained_at`` is the server-issued ordering signal that lets boot
apply a genuinely newer replacement without allowing an old retained env
value to roll credentials back on later restarts.
"""
if not isinstance(local_nous, dict):
return False
local_obtained = _parse_timestamp(local_nous.get("obtained_at"))
seed_obtained = _parse_timestamp(seed_nous.get("obtained_at"))
return bool(
local_obtained is not None
and seed_obtained is not None
and seed_obtained > local_obtained
)
def reseed_if_terminal(auth_path: str, seed_raw: str) -> str:
"""Core logic. Returns a short status string for logging/testing:
- "no_seed" β seed env empty/absent
- "bad_seed" β seed present but unparseable / no nous entry
- "no_auth_file" β auth.json absent (blank volume β let the normal
HERMES_AUTH_JSON_BOOTSTRAP path handle it)
- "auth_unreadable" β auth.json present but unparseable (leave as-is)
- "not_terminal" β local entry is healthy and at least as new β no-op
- "reseeded" β terminal entry replaced from seed
- "reseeded_newer" β healthy-but-stale entry replaced by a newer seed
"""
if not seed_raw:
return "no_seed"
seed_nous = _extract_nous_from_seed(seed_raw)
if seed_nous is None:
return "bad_seed"
if not os.path.exists(auth_path):
# Blank volume β this is the normal first-boot case, not a re-seed.
return "no_auth_file"
try:
with open(auth_path, "r", encoding="utf-8") as fh:
store = json.load(fh)
except (OSError, ValueError):
# Corrupt/unreadable auth.json: do NOT overwrite blindly. A separate
# concern; leave it for the operator / other recovery paths.
return "auth_unreadable"
if not isinstance(store, dict):
return "auth_unreadable"
providers = store.get("providers")
if not isinstance(providers, dict):
providers = {}
store["providers"] = providers
local_nous = providers.get("nous")
terminal = _nous_entry_is_terminal(local_nous)
newer_seed = _seed_is_newer(local_nous, seed_nous)
if not terminal and not newer_seed:
# Healthy and at least as new as the seed, or incomparable. Never roll a
# session back merely because an old rebootstrap env remains configured.
return "not_terminal"
# Surgical replacement: swap ONLY providers.nous, preserve everything else.
providers["nous"] = seed_nous
# 0600 from creation: the seed holds a refresh token and must never sit at umask, even briefly.
# (stdlib only by design β see module docstring β so this mirrors utils.atomic_json_write by hand.)
# Randomly named: boot-hook PIDs inside a container repeat, so a PID-named temp left by a
# SIGKILL'd run would collide with O_EXCL forever and main() would swallow the FileExistsError.
fd, tmp_path = tempfile.mkstemp(
dir=os.path.dirname(auth_path) or ".", prefix=os.path.basename(auth_path) + ".rebootstrap.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(store, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, auth_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return "reseeded" if terminal else "reseeded_newer"
def main() -> int:
auth_path = sys.argv[1] if len(sys.argv) > 1 else ""
if not auth_path:
home = os.environ.get("HERMES_HOME", "")
auth_path = os.path.join(home, "auth.json") if home else "auth.json"
seed_raw = os.environ.get(REBOOTSTRAP_ENV, "")
try:
result = reseed_if_terminal(auth_path, seed_raw)
except Exception as exc: # never let a re-seed error fail the boot
print(f"[rebootstrap] error (ignored): {exc!r}", file=sys.stderr)
return 0
if result == "reseeded":
print("[rebootstrap] Nous bootstrap session was terminal; re-seeded auth.json from "
f"{REBOOTSTRAP_ENV}")
elif result == "reseeded_newer":
print("[rebootstrap] Applied newer orchestrator-issued Nous bootstrap session from "
f"{REBOOTSTRAP_ENV}")
else:
# Quiet by default for the common no-op cases; still emit a breadcrumb.
print(f"[rebootstrap] no-op ({result})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|