mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -27,12 +27,22 @@ if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROM
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auth fail-fast guard. The SuperGrok token (~/.grok/auth.json, mounted read-only)
|
# Auth fail-fast guard. The SuperGrok token (~/.grok/auth.json) has a ~6h TTL;
|
||||||
# has a ~6h TTL; on an expired/missing token headless grok does NOT refresh — it
|
# on an expired/missing token headless grok does NOT refresh — it hangs forever
|
||||||
# hangs forever at an interactive "Waiting for authorization..." prompt, which
|
# at an interactive "Waiting for authorization..." prompt, which reads as a
|
||||||
# reads as a silent zombie container. The orchestrator refreshes the host token
|
# silent zombie container. The orchestrator refreshes the host token on a loop;
|
||||||
# on a loop; this is the in-container backstop: exit 78 (EX_CONFIG) immediately
|
# this is the in-container backstop: exit 78 (EX_CONFIG) immediately so
|
||||||
# so _handle_stopped_container surfaces it, instead of hanging for hours.
|
# _handle_stopped_container surfaces it, instead of hanging for hours.
|
||||||
|
#
|
||||||
|
# F005: the orchestrator mounts the host ~/.grok DIRECTORY read-only at
|
||||||
|
# /home/agent/.grok-auth-ro (a single-file bind mount pins the inode, so the
|
||||||
|
# atomic auth.json refresh never reached a running container). Symlink
|
||||||
|
# ~/.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
|
||||||
|
# own writable state (config.toml, sessions/) still lands in the image's
|
||||||
|
# ~/.grok. `rm -f` first in case the image baked a stub auth.json.
|
||||||
|
rm -f /home/agent/.grok/auth.json
|
||||||
|
ln -s /home/agent/.grok-auth-ro/auth.json /home/agent/.grok/auth.json
|
||||||
if ! ( cd /app && python -m roboco.llm.providers.grok_auth --check ); then
|
if ! ( cd /app && python -m roboco.llm.providers.grok_auth --check ); then
|
||||||
echo "[grok] auth token missing or expired — refusing to run (would hang at" \
|
echo "[grok] auth token missing or expired — refusing to run (would hang at" \
|
||||||
"the login prompt). Refresh ~/.grok/auth.json (orchestrator auto-refresh or" \
|
"the login prompt). Refresh ~/.grok/auth.json (orchestrator auto-refresh or" \
|
||||||
|
|||||||
@@ -53,7 +53,16 @@ GROK_AUTH_HOST_PATH = os.environ.get("ROBOCO_HOST_GROK_DIR", str(Path.home() / "
|
|||||||
|
|
||||||
# In-container paths.
|
# In-container paths.
|
||||||
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
|
_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
|
# 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
|
# entrypoint writes the captured token usage here so the orchestrator reads it
|
||||||
# back at finalize, the grok analogue of the mounted Claude transcript.
|
# back at finalize, the grok analogue of the mounted Claude transcript.
|
||||||
@@ -152,19 +161,24 @@ class GrokCliProvider(AgentProvider):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _append_grok_auth_mount(cmd: list[str]) -> None:
|
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
|
F005: the mount is the DIRECTORY, not the single ``auth.json`` file.
|
||||||
credential; grok writes its per-run state (the rendered ``config.toml``,
|
A single-file bind mount pins the inode, so when the orchestrator
|
||||||
``sessions/``) into the image's own ``~/.grok``. The ~6h token is kept
|
atomically refreshes the token (``tmp.replace`` = rename within the
|
||||||
live host-side by the orchestrator (``_refresh_grok_auth`` ->
|
host ``~/.grok``), a running container kept reading the stale inode and
|
||||||
:func:`roboco.llm.providers.grok_auth.refresh_if_stale`), so a fresh
|
hung at grok's login prompt once the original ~6h token expired. A
|
||||||
credential is always what gets mounted; the entrypoint fails fast if it
|
directory bind mount sees the rename, so the refreshed ``auth.json``
|
||||||
somehow still finds an expired one rather than hanging at grok's login.
|
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"
|
auth_dir = Path(GROK_AUTH_HOST_PATH)
|
||||||
if auth_json.exists():
|
if (auth_dir / "auth.json").exists():
|
||||||
cmd.extend(["-v", f"{auth_json}:{_GROK_AUTH_IN_CONTAINER}:ro"])
|
cmd.extend(["-v", f"{auth_dir}:{_GROK_AUTH_DIR_IN_CONTAINER}:ro"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
|
def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None:
|
||||||
|
|||||||
@@ -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:
|
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 = auth_path.with_name(auth_path.name + ".refresh.tmp")
|
||||||
tmp.write_text(json.dumps(bundle), encoding="utf-8")
|
try:
|
||||||
with contextlib.suppress(OSError):
|
tmp.write_text(payload, encoding="utf-8")
|
||||||
shutil.copymode(auth_path, tmp)
|
with contextlib.suppress(OSError):
|
||||||
tmp.replace(auth_path)
|
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:
|
def _is_stale(creds: dict[str, Any], now: datetime, skew_seconds: int) -> bool:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import pathlib
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -126,6 +127,38 @@ def test_refresh_failed_when_no_access_token(tmp_path: Path) -> None:
|
|||||||
assert ga.refresh_if_stale(path, post=lambda _u, _f: {"expires_in": 1}) == "failed"
|
assert ga.refresh_if_stale(path, post=lambda _u, _f: {"expires_in": 1}) == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_persists_rotated_token_when_atomic_write_fails(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""F006: a rotated refresh_token is single-use — xAI invalidates the old one
|
||||||
|
the moment it issues the new one. If the atomic write (tmp+replace) fails
|
||||||
|
after the rotation, the file keeps the now-dead old refresh_token and the
|
||||||
|
credential is permanently lost on the next refresh. The write must fall back
|
||||||
|
to a direct write so the rotated refresh_token survives even when the atomic
|
||||||
|
replace can't."""
|
||||||
|
path = tmp_path / "auth.json"
|
||||||
|
_write(path, _bundle(_PAST))
|
||||||
|
|
||||||
|
def _post(_url: str, _form: dict[str, str]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"access_token": "new-access",
|
||||||
|
"refresh_token": "rotated-rt",
|
||||||
|
"expires_in": 21600,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Force the atomic tmp.replace to fail; the direct-write fallback must still
|
||||||
|
# land the rotated refresh_token on disk.
|
||||||
|
def _boom_replace(_self: pathlib.Path, _target: pathlib.Path) -> pathlib.Path:
|
||||||
|
raise OSError("replace failed (simulated)")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pathlib.Path, "replace", _boom_replace)
|
||||||
|
|
||||||
|
assert ga.refresh_if_stale(path, post=_post) == "refreshed"
|
||||||
|
creds = next(iter(json.loads(path.read_text()).values()))
|
||||||
|
assert creds["refresh_token"] == "rotated-rt" # survived the write failure
|
||||||
|
assert creds["key"] == "new-access"
|
||||||
|
|
||||||
|
|
||||||
def test_main_check_exit_codes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_main_check_exit_codes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
home = tmp_path / ".grok"
|
home = tmp_path / ".grok"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
|
|||||||
@@ -241,7 +241,11 @@ async def test_grok_spawn_mounts_auth_when_present(_isolate_grok_auth: Path) ->
|
|||||||
) as exec_mock:
|
) as exec_mock:
|
||||||
await provider.spawn(_config())
|
await provider.spawn(_config())
|
||||||
cmd = list(exec_mock.call_args.args)
|
cmd = list(exec_mock.call_args.args)
|
||||||
expected = f"{_isolate_grok_auth / 'auth.json'}:/home/agent/.grok/auth.json:ro"
|
# F005: mount the host ~/.grok DIRECTORY (ro), not the single auth.json
|
||||||
|
# file — a single-file bind mount pins the inode, so the orchestrator's
|
||||||
|
# atomic auth.json refresh (rename) never reaches a running container.
|
||||||
|
# The entrypoint symlinks ~/.grok/auth.json at this RO dir mount.
|
||||||
|
expected = f"{_isolate_grok_auth}:/home/agent/.grok-auth-ro:ro"
|
||||||
assert expected in cmd
|
assert expected in cmd
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +258,7 @@ async def test_grok_spawn_omits_auth_mount_when_absent() -> None:
|
|||||||
) as exec_mock:
|
) as exec_mock:
|
||||||
await provider.spawn(_config())
|
await provider.spawn(_config())
|
||||||
cmd = list(exec_mock.call_args.args)
|
cmd = list(exec_mock.call_args.args)
|
||||||
assert not any("/home/agent/.grok/auth.json" in c for c in cmd)
|
assert not any("/home/agent/.grok-auth-ro" in c for c in cmd)
|
||||||
|
|
||||||
|
|
||||||
async def test_grok_spawn_prompt_is_injection_safe() -> None:
|
async def test_grok_spawn_prompt_is_injection_safe() -> None:
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ def test_intake_grok_mounts_subscription_auth_when_present(
|
|||||||
cmd = AgentOrchestrator._build_intake_run_cmd(
|
cmd = AgentOrchestrator._build_intake_run_cmd(
|
||||||
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
|
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
|
||||||
)
|
)
|
||||||
assert f"{grok_dir / 'auth.json'}:/home/agent/.grok/auth.json:ro" in cmd
|
# F005: directory mount (ro), not the single-file inode-pinning mount.
|
||||||
|
assert f"{grok_dir}:/home/agent/.grok-auth-ro:ro" in cmd
|
||||||
|
|
||||||
|
|
||||||
def test_intake_anthropic_keeps_anthropic_env() -> None:
|
def test_intake_anthropic_keeps_anthropic_env() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user