[F005,F006] grok auth: directory mount + atomic-write fallback

F005: the single-file bind mount of auth.json pinned the inode, so the
orchestrator's atomic refresh (tmp+rename within ~/.grok) never reached a
running grok container — a long-lived container hung at the login prompt
when the original ~6h token expired. Mount the host ~/.grok DIRECTORY (ro)
at /home/agent/.grok-auth-ro; the entrypoint symlinks ~/.grok/auth.json at
that RO mount so grok + the --check backstop read the live credential (the
directory mount sees the host-side rename) while grok's writable state
(config.toml, sessions/) stays in the image's ~/.grok.

F006: a rotated refresh_token is single-use — xAI invalidates the old one
the instant it issues the new one. If the atomic write failed after the
rotation, the file kept the now-dead old refresh_token and the credential
was permanently lost on the next refresh. _atomic_write now falls back to a
direct write when tmp+replace fails, so the rotated token always lands on
disk (losing the write is catastrophic; losing atomicity is not).

TDD: RED tests watched fail, then GREEN. ruff+mypy clean; 32 grok tests
green, no regressions.
This commit is contained in:
Renn F
2026-06-28 10:10:17 +02:00
parent 812bf1195a
commit 51990d7cad
6 changed files with 109 additions and 26 deletions
+26 -12
View File
@@ -53,7 +53,16 @@ GROK_AUTH_HOST_PATH = os.environ.get("ROBOCO_HOST_GROK_DIR", str(Path.home() / "
# In-container paths.
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
_GROK_AUTH_IN_CONTAINER = "/home/agent/.grok/auth.json"
# F005: the host ~/.grok DIRECTORY is mounted read-only here (NOT the single
# auth.json file). A single-file bind mount pins the inode, so the
# orchestrator's atomic auth.json refresh (tmp+rename within the dir) never
# reached a running container — a long-lived grok container hung at the login
# prompt when the original ~6h token expired. A directory mount sees the
# rename, so the refreshed token propagates to running containers. The
# entrypoint symlinks ~/.grok/auth.json -> this RO mount so grok (and the
# --check backstop) read the live credential while grok's own writable state
# (config.toml, sessions/) still lands in the image's ~/.grok.
_GROK_AUTH_DIR_IN_CONTAINER = "/home/agent/.grok-auth-ro"
# Per-agent data dir (the host side is reused from the shared assembly): the
# entrypoint writes the captured token usage here so the orchestrator reads it
# back at finalize, the grok analogue of the mounted Claude transcript.
@@ -152,19 +161,24 @@ class GrokCliProvider(AgentProvider):
@staticmethod
def _append_grok_auth_mount(cmd: list[str]) -> None:
"""Mount the host's SuperGrok ``auth.json`` (read-only) into ~/.grok.
"""Mount the host's SuperGrok ``~/.grok`` directory (read-only).
Read-only so concurrent containers can't corrupt the shared subscription
credential; grok writes its per-run state (the rendered ``config.toml``,
``sessions/``) into the image's own ``~/.grok``. The ~6h token is kept
live host-side by the orchestrator (``_refresh_grok_auth`` ->
:func:`roboco.llm.providers.grok_auth.refresh_if_stale`), so a fresh
credential is always what gets mounted; the entrypoint fails fast if it
somehow still finds an expired one rather than hanging at grok's login.
F005: the mount is the DIRECTORY, not the single ``auth.json`` file.
A single-file bind mount pins the inode, so when the orchestrator
atomically refreshes the token (``tmp.replace`` = rename within the
host ``~/.grok``), a running container kept reading the stale inode and
hung at grok's login prompt once the original ~6h token expired. A
directory bind mount sees the rename, so the refreshed ``auth.json``
propagates to running containers. The entrypoint symlinks
``~/.grok/auth.json`` at this RO directory mount, so grok (and the
``--check`` backstop) read the live credential while grok's own
writable state (``config.toml``, ``sessions/``) still lands in the
image's ``~/.grok``. Read-only so concurrent containers can't corrupt
the shared subscription credential.
"""
auth_json = Path(GROK_AUTH_HOST_PATH) / "auth.json"
if auth_json.exists():
cmd.extend(["-v", f"{auth_json}:{_GROK_AUTH_IN_CONTAINER}:ro"])
auth_dir = Path(GROK_AUTH_HOST_PATH)
if (auth_dir / "auth.json").exists():
cmd.extend(["-v", f"{auth_dir}:{_GROK_AUTH_DIR_IN_CONTAINER}:ro"])
@staticmethod
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
+26 -5
View File
@@ -126,12 +126,33 @@ def _post_token(url: str, form: dict[str, str]) -> dict[str, Any]:
def _atomic_write(auth_path: Path, bundle: dict[str, Any]) -> None:
"""Rewrite ``auth.json`` atomically, preserving the original file mode."""
"""Rewrite ``auth.json`` atomically, preserving the original file mode.
F006: a rotated refresh_token is single-use — xAI invalidates the old one
the instant it issues the new one. If this write fails after the rotation,
the file keeps the now-dead old refresh_token and the credential is
permanently lost on the next refresh (the file's old token no longer works
at the token endpoint). Losing the write is therefore catastrophic; losing
atomicity is not. So the atomic tmp+replace is followed by a direct-write
fallback: only if BOTH paths fail do we let the OSError propagate so the
caller can report ``failed``. The direct write lands the rotated
refresh_token on disk even when the atomic replace can't (e.g. the tmp
rename fails across a boundary the kernel won't honor).
"""
payload = json.dumps(bundle)
tmp = auth_path.with_name(auth_path.name + ".refresh.tmp")
tmp.write_text(json.dumps(bundle), encoding="utf-8")
with contextlib.suppress(OSError):
shutil.copymode(auth_path, tmp)
tmp.replace(auth_path)
try:
tmp.write_text(payload, encoding="utf-8")
with contextlib.suppress(OSError):
shutil.copymode(auth_path, tmp)
tmp.replace(auth_path)
return
except OSError as exc:
logger.warning(
"grok auth atomic write failed; trying direct write", error=str(exc)
)
# Last-resort direct write so a rotated refresh_token is never lost.
auth_path.write_text(payload, encoding="utf-8")
def _is_stale(creds: dict[str, Any], now: datetime, skew_seconds: int) -> bool: