grok-token-broker: hold a refreshable session credential behind a rotation-safety gate

This commit is contained in:
Vuong Hoang
2026-09-16 15:12:38 -07:00
parent d17bd3df86
commit ebc4dac6d8
3 changed files with 394 additions and 8 deletions
+30 -8
View File
@@ -26,6 +26,7 @@ The cross-arm comparison has to be behavioural (on-beat / in-band / ran-on) plus
""" """
from __future__ import annotations from __future__ import annotations
import argparse, hashlib, json, math, random, subprocess, time import argparse, hashlib, json, math, random, subprocess, time
import pathlib
from pathlib import Path from pathlib import Path
import torch import torch
@@ -36,10 +37,27 @@ from peft import LoraConfig, get_peft_model
TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"] TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
SYS = ("You expand a single story beat into ONE paragraph of prose in the manner of Rebecca " # ⚠ THE SYSTEM PROMPT MUST MATCH THE ONE THE PAIRS WERE BUILT UNDER, and the pair file now
"Yarros — contemporary first-person PRESENT-tense narration, emotionally charged, sensory " # records it. Reading it from the pairs' provenance rather than hardcoding it here is the fix
"and physical, the voice of new-adult romantasy. Render the beat itself; do not move past " # for the defect that shaped the whole BabyYarros pilot: the trainer said "ONE paragraph" while
"it, do not add a new scene, do not comment. Output the paragraph only, 90–140 words.") # the data was a median of four, and the carrier believed the data. A literal in this file is a
# second place for that to drift.
SYS_YARROS_FROZEN = (
"You expand a single story beat into ONE paragraph of prose in the manner of Rebecca "
"Yarros — contemporary first-person PRESENT-tense narration, emotionally charged, sensory "
"and physical, the voice of new-adult romantasy. Render the beat itself; do not move past "
"it, do not add a new scene, do not comment. Output the paragraph only, 90–140 words.")
def resolve_sys(pairs_path: str) -> tuple[str, str]:
"""Return (system_prompt, source). Prefer the pair build's own provenance."""
prov = pathlib.Path(str(pairs_path) + ".provenance.json")
if prov.exists():
d = json.loads(prov.read_text(encoding="utf-8"))
s = d.get("system_prompt")
if s:
return s, f"pairs provenance ({d.get('register', '?')})"
return SYS_YARROS_FROZEN, "frozen Yarros literal (no provenance found)"
def user_msg(rec: dict) -> str: def user_msg(rec: dict) -> str:
@@ -57,11 +75,11 @@ class Pairs(Dataset):
when a template changes its spacing, and it would mask the wrong span without erroring. when a template changes its spacing, and it would mask the wrong span without erroring.
""" """
def __init__(self, tok, records, seq_len): def __init__(self, tok, records, seq_len, sys_prompt):
self.rows = [] self.rows = []
dropped = 0 dropped = 0
for r in records: for r in records:
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": user_msg(r)}] msgs = [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg(r)}]
# ⚠ transformers 5.16 returns a BatchEncoding from apply_chat_template(tokenize=True), # ⚠ transformers 5.16 returns a BatchEncoding from apply_chat_template(tokenize=True),
# not a list of ids. Taking len() of it yields 2 (the number of keys), so every # not a list of ids. Taking len() of it yields 2 (the number of keys), so every
# example failed the `len(full) <= len(prefix)` test and the whole dataset was # example failed the `len(full) <= len(prefix)` test and the whole dataset was
@@ -146,6 +164,8 @@ def main() -> int:
torch.manual_seed(a.seed); random.seed(a.seed) torch.manual_seed(a.seed); random.seed(a.seed)
out = Path(a.out); out.mkdir(parents=True, exist_ok=True) out = Path(a.out); out.mkdir(parents=True, exist_ok=True)
SYS, sys_src = resolve_sys(a.pairs)
print(f"[data] system prompt from {sys_src}")
tok = AutoTokenizer.from_pretrained(a.base) tok = AutoTokenizer.from_pretrained(a.base)
if tok.chat_template is None: if tok.chat_template is None:
raise SystemExit("REFUSING: carrier has no chat template -- it is not an instruct build") raise SystemExit("REFUSING: carrier has no chat template -- it is not an instruct build")
@@ -169,7 +189,8 @@ def main() -> int:
if any(r.get("split") != "val" for r in val_recs): if any(r.get("split") != "val" for r in val_recs):
raise SystemExit("REFUSING: val pairs are not all from the corpus val split") raise SystemExit("REFUSING: val pairs are not all from the corpus val split")
train_ds, val_ds = Pairs(tok, train_recs, a.seq_len), Pairs(tok, val_recs, a.seq_len) train_ds = Pairs(tok, train_recs, a.seq_len, SYS)
val_ds = Pairs(tok, val_recs, a.seq_len, SYS)
with_ctx = sum(1 for r in train_recs if r.get("context")) with_ctx = sum(1 for r in train_recs if r.get("context"))
print(f"[data] {len(train_ds)} train pairs ({train_ds.dropped} dropped), " print(f"[data] {len(train_ds)} train pairs ({train_ds.dropped} dropped), "
f"{len(val_ds)} val ({val_ds.dropped} dropped), " f"{len(val_ds)} val ({val_ds.dropped} dropped), "
@@ -213,7 +234,8 @@ def main() -> int:
"trainable_pct": round(100 * trainable / total, 3), "trainable_pct": round(100 * trainable / total, 3),
"steps_per_epoch": steps_per_epoch, "planned_steps": steps_per_epoch * int(a.epochs), "steps_per_epoch": steps_per_epoch, "planned_steps": steps_per_epoch * int(a.epochs),
"resolved": resolved, "harness_commit": git, "harness_dirty_at_launch": dirty, "resolved": resolved, "harness_commit": git, "harness_dirty_at_launch": dirty,
"launched_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "system_prompt": SYS} "launched_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "system_prompt": SYS,
"system_prompt_source": sys_src}
(out / "provenance.json").write_text(json.dumps(prov, indent=2)) (out / "provenance.json").write_text(json.dumps(prov, indent=2))
print("[prov] " + json.dumps({k: prov[k] for k in print("[prov] " + json.dumps({k: prov[k] for k in
("pairs_sha256_16", "train_pairs", "planned_steps", "trainable_pct", ("pairs_sha256_16", "train_pairs", "planned_steps", "trainable_pct",
+67
View File
@@ -0,0 +1,67 @@
# grok-token-broker
Holds a refreshable xAI **session** credential so a gateway can serve the Grok Build coding
plan — without touching the credential the operator's `grok` CLI depends on.
seed copy the CLI credential into the broker's own store (read-once)
probe-rotation MEASURE whether refresh rotates AND invalidates <- gate
token --raw emit a valid access token, refreshing if near expiry
refresh refresh now
status report state, touching nothing
## The problem
The coding plan (`https://cli-chat-proxy.grok.com/v1`, serving **grok-4.6**, 500k context)
authenticates with an OIDC session token — `api_key: null`, `env_key: null` in the CLI's own
model cache — that expires roughly every six hours. LiteLLM and every other gateway here hold
a **static** credential, so a naive alias works until the session lapses and then fails closed.
## ⛔ Why the loop is disarmed until measured
`https://auth.x.ai/oauth2/token` supports the refresh grant. But **a refresh may rotate the
refresh token, and many providers invalidate the old one server-side the moment a new one
issues.** The `grok` CLI holds its refresh token in `~/.grok/auth.json`.
**Not writing to that file is necessary and not sufficient.** If xAI rotates-and-invalidates,
a broker refreshing the *same* credential kills the CLI login anyway, server-side. heid's
`groa_http_dispatch.py` refuses to refresh at all for exactly this reason — correct, absent an
answer. This broker exists to get the answer, then act on it.
So `probe-rotation` is a gate, not a diagnostic: `refresh` and `token`-near-expiry both refuse
until a verdict exists and says safe.
| verdict | meaning |
|---|---|
| `non-rotating` | same refresh token returned. Coexistence safe. Armed. |
| `rotating-old-still-valid` | rotation happens, old token still works. Coexistence safe. Armed. |
| `rotating-and-invalidating` | **the CLI login is already dead.** Broker must not share this credential — it needs its own login. Stays disarmed. |
⚠ **The probe spends one refresh, and there is no way to ask the question without spending
it.** If the answer is the bad one, the CLI is broken at that moment and needs an interactive
`grok` re-login — the same remedy heid's exit-3 path already names. **Run it when a broken CLI
login is a two-minute annoyance, not mid-panel.** Hence the required
`--i-accept-this-may-end-the-cli-session` flag.
## Current state
Seeded on **nh3-dev** 2026-09-16. Scope `openid profile email offline_access grok-cli:access
api:access`, client `b1a00492-…`. **Probe NOT yet run** — it is the operator's call when to
spend it.
## For a consumer
```bash
TOKEN=$(broker.py token --raw)
curl https://cli-chat-proxy.grok.com/v1/... -H "Authorization: Bearer $TOKEN"
```
⚠ **The coding plan speaks the Responses API (`api_backend: "responses"`), not
`/chat/completions`.** That is a second, independent obstacle to a LiteLLM alias and this
broker does not solve it — it solves the credential half only.
## Invariants
- Never writes `~/.grok/auth.json`. Reads it once, on `seed`.
- Never prints a token except under `token --raw`.
- Never logs a token value — expiry, scope and subject only.
- Store is `~/.config/grok-token-broker/` at 0700, credential at 0600.
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""Grok token broker — hold a refreshable xAI session credential WITHOUT touching the CLI's.
⭐ THE PROBLEM. The Grok Build coding plan (`https://cli-chat-proxy.grok.com/v1`, serving
grok-4.6) authenticates with an OIDC **session** token, not an API key: `api_key: null`,
`env_key: null` in the CLI's own model cache. It expires roughly every six hours. LiteLLM and
every other gateway on this fleet hold a STATIC credential, so a naive alias works until the
session lapses and then fails closed.
⚠⚠ THE HAZARD THAT SHAPES THIS ENTIRE DESIGN, AND WHY IT IS NOT A SIMPLE REFRESH LOOP.
`https://auth.x.ai/oauth2/token` supports the `refresh_token` grant. But **a refresh may ROTATE
the refresh token**, and many OIDC providers invalidate the old one server-side the instant a
new one is issued. The operator's interactive `grok` CLI holds its refresh token in
`~/.grok/auth.json`. If xAI rotates-and-invalidates, then a broker refreshing against the SAME
credential silently kills the CLI login — and it does so **even though the broker never writes
to that file.** Not-writing is necessary and NOT sufficient; the damage would be server-side.
heid's `groa_http_dispatch.py` refuses to refresh at all for this reason, which is the correct
call absent an answer. This broker's job is to get the answer first and then act on it.
⛔ SO THE REFRESH LOOP IS NOT ARMED UNTIL `probe-rotation` HAS RUN AND SAID IT IS SAFE.
`serve` and `refresh` both refuse without a stored probe verdict. That is deliberate: this is a
tool whose whole value is contingent on one unknown, and running it before measuring the unknown
is how you find out by breaking the operator's login.
## The probe, and what each outcome means
`probe-rotation` performs ONE refresh using a COPY of the credential, then re-presents the
ORIGINAL refresh token:
original still valid -> NON-ROTATING (or rotation without invalidation). The broker can
coexist with the CLI on one credential. Loop may be armed.
original now rejected -> ROTATING-AND-INVALIDATING. The broker MUST NOT share the CLI's
credential. It needs its own login, or the idea is dead. Refuses
to arm, and says so.
⚠ The probe itself consumes one refresh. If the answer is "rotating", the CLI's token is
already dead by the time you read the verdict — there is no way to ask this question without
spending the credential, and pretending otherwise would be worse. The remedy is a `grok` CLI
re-login, which is the same remedy heid's exit-3 path already names. **Run it when a broken CLI
login is a two-minute annoyance, not mid-panel.**
## What it never does
- Never writes `~/.grok/auth.json`. Reads it once, on `seed`, and never again.
- Never prints a token to stdout except under `token --raw`, which is for a consumer to capture.
- Never logs a token value. Expiry, scope and subject only.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
CLI_AUTH = pathlib.Path.home() / ".grok" / "auth.json"
STORE = pathlib.Path(os.environ.get("GROK_BROKER_HOME",
pathlib.Path.home() / ".config" / "grok-token-broker"))
CRED = STORE / "credential.json"
VERDICT = STORE / "rotation-verdict.json"
TOKEN_URL = "https://auth.x.ai/oauth2/token"
API_BASE = "https://cli-chat-proxy.grok.com/v1"
REFRESH_MARGIN_S = 1800 # refresh when under 30 min remain
def _claims(jwt: str) -> dict:
pl = jwt.split(".")[1]
pl += "=" * (-len(pl) % 4)
return json.loads(base64.urlsafe_b64decode(pl))
def _remaining(jwt: str) -> int:
try:
return int(_claims(jwt)["exp"] - time.time())
except Exception:
return -1
def _post_form(url: str, fields: dict, timeout: int = 30):
data = urllib.parse.urlencode(fields).encode()
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")[:400]
return e.code, {"_error": body}
except Exception as e: # noqa: BLE001
return 0, {"_error": f"{type(e).__name__}: {e}"}
def _read_cli_credential() -> dict:
"""Pull access token, refresh token and client id out of the CLI's auth file.
Shape-tolerant on purpose: this file belongs to another tool and its layout is not ours to
depend on. Anything not found is reported, never guessed.
"""
if not CLI_AUTH.exists():
raise SystemExit(f"REFUSING: no CLI credential at {CLI_AUTH}")
blob = json.loads(CLI_AUTH.read_text(encoding="utf-8"))
found: dict = {}
def walk(o, key_hint=""):
if isinstance(o, dict):
for k, v in o.items():
if isinstance(v, str):
if k == "key" and v.count(".") == 2 and "access" not in found:
found["access"] = v
elif "refresh" in k.lower() and len(v) > 20:
found.setdefault("refresh", v)
walk(v, k)
if "auth.x.ai::" in key_hint:
found.setdefault("client_id", key_hint.split("::", 1)[1])
elif isinstance(o, list):
for v in o:
walk(v, key_hint)
for k, v in blob.items():
walk(v, k)
missing = [f for f in ("access", "refresh") if f not in found]
if missing:
raise SystemExit(f"REFUSING: could not locate {missing} in {CLI_AUTH}. "
"Its layout changed; inspect it by hand rather than letting this guess.")
return found
def cmd_seed(_a) -> int:
STORE.mkdir(parents=True, exist_ok=True)
os.chmod(STORE, 0o700)
c = _read_cli_credential()
CRED.write_text(json.dumps({**c, "seeded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"source": str(CLI_AUTH)}, indent=2), encoding="utf-8")
os.chmod(CRED, 0o600)
cl = _claims(c["access"])
print(f" seeded -> {CRED} (0600)")
print(f" scope : {cl.get('scope')}")
print(f" expires : {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(cl['exp']))} "
f"({_remaining(c['access']) // 60} min left)")
print(f" client : {c.get('client_id', '<not found>')}")
print("\n ⛔ The refresh loop is NOT armed. Run `probe-rotation` next, and read its warning "
"first — the probe spends one refresh and may end the CLI's session.")
return 0
def cmd_probe(a) -> int:
if not CRED.exists():
raise SystemExit("REFUSING: run `seed` first")
c = json.loads(CRED.read_text(encoding="utf-8"))
if not a.i_accept_this_may_end_the_cli_session:
print("REFUSING without --i-accept-this-may-end-the-cli-session.\n\n"
" This probe performs a real refresh. If xAI rotates AND invalidates, the\n"
" operator's `grok` CLI login dies at that moment and needs an interactive\n"
" re-login. There is no way to answer the question without spending the\n"
" credential. Run it when that is a two-minute annoyance, not mid-panel.",
file=sys.stderr)
return 2
old_refresh = c["refresh"]
fields = {"grant_type": "refresh_token", "refresh_token": old_refresh}
if c.get("client_id"):
fields["client_id"] = c["client_id"]
print(" [1/2] refreshing with the stored refresh token ...")
st, body = _post_form(TOKEN_URL, fields)
if st != 200:
print(f" refresh FAILED http={st}: {str(body.get('_error'))[:200]}", file=sys.stderr)
print(" Verdict NOT recorded. The loop stays disarmed.", file=sys.stderr)
return 4
new_refresh = body.get("refresh_token")
rotated = bool(new_refresh and new_refresh != old_refresh)
print(f" ok. refresh_token {'ROTATED' if rotated else 'unchanged'}")
if not rotated:
verdict, safe = "non-rotating", True
note = "Provider returned the same refresh token; the CLI's copy is untouched."
else:
print(" [2/2] re-presenting the ORIGINAL refresh token to see if it still works ...")
st2, _ = _post_form(TOKEN_URL, fields)
if st2 == 200:
verdict, safe = "rotating-old-still-valid", True
note = ("Rotation happens but the old refresh token still works, so the CLI's copy "
"survives. Coexistence is safe.")
else:
verdict, safe = "rotating-and-invalidating", False
note = (f"The original refresh token is now rejected (http={st2}). The CLI's login "
"is ALREADY DEAD and needs an interactive `grok` re-login. A broker MUST "
"NOT share this credential — it needs its own login.")
print(f" original token -> http={st2}")
c["access"] = body.get("access_token", c["access"])
if new_refresh:
c["refresh"] = new_refresh
CRED.write_text(json.dumps(c, indent=2), encoding="utf-8")
os.chmod(CRED, 0o600)
VERDICT.write_text(json.dumps({
"verdict": verdict, "safe_to_arm": safe, "note": note,
"probed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"token_url": TOKEN_URL, "api_base": API_BASE,
}, indent=2), encoding="utf-8")
os.chmod(VERDICT, 0o600)
print(f"\n VERDICT: {verdict} (safe_to_arm={safe})\n {note}")
if not safe:
print("\n ⚠ TELL THE OPERATOR: the `grok` CLI on this box needs a re-login.", file=sys.stderr)
return 0 if safe else 5
def _ensure_armed() -> dict:
if not VERDICT.exists():
raise SystemExit("REFUSING: no rotation verdict. Run `probe-rotation` first — this tool "
"will not refresh a credential whose rotation behaviour is unmeasured.")
v = json.loads(VERDICT.read_text(encoding="utf-8"))
if not v.get("safe_to_arm"):
raise SystemExit(f"REFUSING: verdict is '{v['verdict']}'. {v['note']}")
return v
def cmd_refresh(_a) -> int:
_ensure_armed()
c = json.loads(CRED.read_text(encoding="utf-8"))
fields = {"grant_type": "refresh_token", "refresh_token": c["refresh"]}
if c.get("client_id"):
fields["client_id"] = c["client_id"]
st, body = _post_form(TOKEN_URL, fields)
if st != 200:
print(f"refresh failed http={st}: {str(body.get('_error'))[:200]}", file=sys.stderr)
return 4
c["access"] = body["access_token"]
if body.get("refresh_token"):
c["refresh"] = body["refresh_token"]
c["refreshed_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
CRED.write_text(json.dumps(c, indent=2), encoding="utf-8")
os.chmod(CRED, 0o600)
print(f" refreshed; {_remaining(c['access']) // 60} min remaining")
return 0
def cmd_token(a) -> int:
"""Print a currently-valid access token, refreshing first if it is close to expiry."""
if not CRED.exists():
raise SystemExit("REFUSING: run `seed` first")
c = json.loads(CRED.read_text(encoding="utf-8"))
if _remaining(c["access"]) < REFRESH_MARGIN_S:
_ensure_armed()
if cmd_refresh(a) != 0:
return 4
c = json.loads(CRED.read_text(encoding="utf-8"))
if a.raw:
sys.stdout.write(c["access"])
else:
print(f" valid for {_remaining(c['access']) // 60} more minutes "
f"(use --raw to emit the token itself)")
return 0
def cmd_status(_a) -> int:
print(f" store : {STORE}")
if not CRED.exists():
print(" state : NOT SEEDED")
return 0
c = json.loads(CRED.read_text(encoding="utf-8"))
print(f" seeded : {c.get('seeded_at')} from {c.get('source')}")
print(f" access : {_remaining(c['access']) // 60} min remaining")
if VERDICT.exists():
v = json.loads(VERDICT.read_text(encoding="utf-8"))
print(f" rotation: {v['verdict']} safe_to_arm={v['safe_to_arm']} ({v['probed_at']})")
print(f" armed : {'YES' if v['safe_to_arm'] else 'NO — ' + v['verdict']}")
else:
print(" rotation: UNMEASURED — loop disarmed, `token` will refuse to refresh")
print(f" api_base: {API_BASE}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("seed", help="copy the CLI credential into the broker's own store")
p = sub.add_parser("probe-rotation", help="measure whether refresh rotates AND invalidates")
p.add_argument("--i-accept-this-may-end-the-cli-session", action="store_true")
sub.add_parser("refresh", help="refresh now (requires a safe verdict)")
t = sub.add_parser("token", help="emit a valid access token, refreshing if near expiry")
t.add_argument("--raw", action="store_true")
sub.add_parser("status", help="report state without touching anything")
a = ap.parse_args()
return {"seed": cmd_seed, "probe-rotation": cmd_probe, "refresh": cmd_refresh,
"token": cmd_token, "status": cmd_status}[a.cmd](a)
if __name__ == "__main__":
raise SystemExit(main())