hermes: make Claude setup-token access durable

This commit is contained in:
jenkins 2026-08-22 22:52:15 -03:00
parent 5e0b0d9cd6
commit 8acb99d100
11 changed files with 160 additions and 52 deletions

View File

@ -25,7 +25,7 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260822-claude-oauth-self-heal-v1"
ai.bstein.dev/config-rev: "20260822-claude-setup-token-v2"
prometheus.io/scrape: "true"
prometheus.io/path: /metrics
prometheus.io/port: "9010"

View File

@ -27,28 +27,17 @@ spec:
ai.bstein.dev/role: fenced-execution-only
ai.bstein.dev/scm-boundary: mediated-pr14-broker-with-completion-gates
ai.bstein.dev/model-policy: Switchyard AUTO with cross-provider fallback
ai.bstein.dev/storage: separate durable RWO workspace and OAuth refresh ownership per ordinal
ai.bstein.dev/storage: durable workspace and ordinal-private Codex refresh state
ai.bstein.dev/config-rev: "20260822-claude-setup-token-v1"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes-execution-worker
vault.hashicorp.com/agent-inject-containers: stage-worker-access
vault.hashicorp.com/agent-service-account-token-volume-name: vault-auth-token
vault.hashicorp.com/agent-inject-secret-claude-credentials-0: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-perms-claude-credentials-0: "0600"
vault.hashicorp.com/agent-inject-template-claude-credentials-0: |
vault.hashicorp.com/agent-inject-secret-claude-oauth-token: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-perms-claude-oauth-token: "0600"
vault.hashicorp.com/agent-inject-template-claude-oauth-token: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.execution_worker_0_claude_credentials_json }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-claude-credentials-1: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-perms-claude-credentials-1: "0600"
vault.hashicorp.com/agent-inject-template-claude-credentials-1: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.execution_worker_1_claude_credentials_json }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-claude-credentials-2: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-perms-claude-credentials-2: "0600"
vault.hashicorp.com/agent-inject-template-claude-credentials-2: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.execution_worker_2_claude_credentials_json }}
{{ .Data.data.claude_oauth_token }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-codex-auth-0: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-perms-codex-auth-0: "0600"
@ -133,6 +122,7 @@ spec:
runAsGroup: 0
seccompProfile: {type: RuntimeDefault}
volumeMounts:
- {name: claude-oauth-access, mountPath: /claude-oauth-access}
- {name: workspace, mountPath: /workspace}
- {name: provider-access, mountPath: /provider-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
@ -188,6 +178,9 @@ spec:
- {name: HOME, value: /worker-data/home}
- {name: CODEX_HOME, value: /provider-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /provider-access/claude}
- {name: CLAUDE_CODE_OAUTH_TOKEN_FILE, value: /claude-oauth-access/token}
- {name: HERMES_CLAUDE_BIN, value: /opt/coordinator/claude_oauth_exec}
- {name: HERMES_CLAUDE_NATIVE_BIN, value: /worker-data/tools/bin/claude}
- {name: HERMES_AUTO_ROUTER_PROFILE, value: agent}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: PATH, value: /worker-data/tools/bin:/opt/coordinator:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
@ -201,12 +194,12 @@ spec:
fieldPath: spec.nodeName
startupProbe:
exec:
command: [/bin/sh, -ec, "test -w /workspace && test -w /provider-access/codex/auth.json && test -w /provider-access/claude/.credentials.json"]
command: [/bin/sh, -ec, "test -w /workspace && test -w /provider-access/codex/auth.json && test -r /claude-oauth-access/token"]
periodSeconds: 5
failureThreshold: 60
readinessProbe:
exec:
command: [/bin/sh, -ec, "test -w /workspace && test -w /provider-access/codex/auth.json"]
command: [/bin/sh, -ec, "test -w /workspace && test -w /provider-access/codex/auth.json && test -r /claude-oauth-access/token"]
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
@ -217,6 +210,7 @@ spec:
runAsGroup: 10000
seccompProfile: {type: RuntimeDefault}
volumeMounts:
- {name: claude-oauth-access, mountPath: /claude-oauth-access, readOnly: true}
- {name: workspace, mountPath: /workspace}
- {name: worker-data, mountPath: /worker-data}
- {name: tools, mountPath: /worker-data/tools, readOnly: true}
@ -227,6 +221,8 @@ spec:
requests: {cpu: 5m, memory: 128Mi, ephemeral-storage: 1Gi}
limits: {cpu: "2", memory: 4Gi, ephemeral-storage: 8Gi}
volumes:
- name: claude-oauth-access
emptyDir: {medium: Memory, sizeLimit: 1Mi}
- name: worker-data
emptyDir: {sizeLimit: 128Mi}
- name: coordinator
@ -278,7 +274,7 @@ spec:
resources:
requests:
storage: 2Gi
# OAuth refresh ownership is single-mounter by design: keep it ReadWriteOnce.
# Codex refresh state and Claude session state remain ordinal-private.
- metadata:
name: provider-access
labels:

View File

@ -65,6 +65,7 @@ configMapGenerator:
- name: hermes-execution-pool
namespace: hermes
files:
- claude_oauth_exec=scripts/claude_oauth_exec
- cli_lane_board.py=scripts/cli_lane_board.py
- cli_lane_capabilities.py=scripts/cli_lane_capabilities.py
- cli_lane_config.py=scripts/cli_lane_config.py
@ -105,6 +106,7 @@ configMapGenerator:
- ai_usage_exporter.py=scripts/ai_usage_exporter.py
- ai_usage_http.py=scripts/ai_usage_http.py
- claude=scripts/claude
- claude_oauth_exec=scripts/claude_oauth_exec
- claude_command_policy.py=scripts/claude_command_policy.py
- cli_lane_board.py=scripts/cli_lane_board.py
- cli_lane_capabilities.py=scripts/cli_lane_capabilities.py

View File

@ -54,6 +54,10 @@ POLL_STARTUP_GRACE_SECONDS = 45
POLL_PROGRESS_BUDGET_SECONDS = 60
class QuotaNotExposed(RuntimeError):
"""The active provider credential cannot expose account quota."""
@dataclass(frozen=True)
class Sample:
"""One Prometheus gauge sample."""
@ -273,10 +277,20 @@ def _provider_authenticated(provider: str) -> bool:
def query_claude() -> dict[str, Any]:
"""Read Claude account quota with the runtime OAuth token held only in memory."""
try:
document = json.loads(CLAUDE_CREDENTIALS.read_text(encoding="utf-8"))
token = document.get("claudeAiOauth", {}).get("accessToken")
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise QuotaNotExposed("Claude runtime credentials are incomplete") from error
oauth = document.get("claudeAiOauth", {})
token = oauth.get("accessToken") if isinstance(oauth, dict) else None
if not isinstance(token, str) or not token:
raise RuntimeError("Claude runtime credentials are incomplete")
raise QuotaNotExposed("Claude runtime credentials are incomplete")
expires_at = _number(oauth.get("expiresAt"))
if expires_at is not None:
if expires_at > 10_000_000_000:
expires_at /= 1000
if expires_at <= time.time():
raise QuotaNotExposed("Claude quota credential has expired")
request = Request(
CLAUDE_USAGE_URL,
headers={
@ -339,6 +353,13 @@ class Collector:
samples = parse_claude_payload(query_claude())
else:
raise ValueError("unknown provider")
except QuotaNotExposed:
self.record_failure(
provider,
started=started,
monotonic_started=monotonic_started,
)
return
except Exception as error:
print(
f"{provider} quota collection deferred: {type(error).__name__}",

View File

@ -4,13 +4,10 @@ set -eu
umask 077
config_dir="${CLAUDE_CONFIG_DIR:-/runtime-access/claude}"
oauth_token_file="${CLAUDE_CODE_OAUTH_TOKEN_FILE:-/claude-oauth-access/token}"
if [ -s "$oauth_token_file" ]; then
CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$oauth_token_file")"
export CLAUDE_CODE_OAUTH_TOKEN
fi
HERMES_CLAUDE_NATIVE_BIN="${HERMES_CLAUDE_NATIVE_BIN:-/opt/data/tools/bin/claude}"
export HERMES_CLAUDE_NATIVE_BIN
exec /opt/data/tools/bin/claude \
exec /opt/coordinator/claude_oauth_exec \
--dangerously-skip-permissions \
--autocompact auto \
--settings "$config_dir/settings.json" \

View File

@ -0,0 +1,16 @@
#!/bin/sh
# Inject the Vault-mounted Claude setup token only into the native CLI process.
set -eu
umask 077
native_bin="${HERMES_CLAUDE_NATIVE_BIN:-/opt/data/tools/bin/claude}"
oauth_token_file="${CLAUDE_CODE_OAUTH_TOKEN_FILE:-/claude-oauth-access/token}"
test -x "$native_bin"
test -s "$oauth_token_file"
CLAUDE_CODE_OAUTH_TOKEN="$(tr -d '\r\n' < "$oauth_token_file")"
test -n "$CLAUDE_CODE_OAUTH_TOKEN"
export CLAUDE_CODE_OAUTH_TOKEN
unset ANTHROPIC_API_KEY CLAUDE_API_KEY
exec "$native_bin" "$@"

View File

@ -407,11 +407,23 @@ def readiness() -> None:
for path in (ROOT, ROOT / "provider-state", cli_lane_runner.DATA_ROOT):
if not path.is_dir() or not os.access(path, os.W_OK):
raise ProtocolError(f"durable worker path is not writable: {path}")
for credential in (
Path(os.environ.get("CODEX_HOME", "/runtime-access/codex")) / "auth.json",
Path(os.environ.get("CLAUDE_CONFIG_DIR", "/runtime-access/claude")) / ".credentials.json",
):
if not credential.is_file() or not os.access(credential, os.W_OK):
credentials = (
(
Path(os.environ.get("CODEX_HOME", "/runtime-access/codex"))
/ "auth.json",
os.W_OK,
),
(
Path(
os.environ.get(
"CLAUDE_CODE_OAUTH_TOKEN_FILE", "/claude-oauth-access/token"
)
),
os.R_OK,
),
)
for credential, access in credentials:
if not credential.is_file() or not os.access(credential, access):
raise ProtocolError(f"subscription credential is unavailable or read-only: {credential.name}")
cli_lane_runner.RESULT_SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True)
atomic_json(

View File

@ -291,7 +291,7 @@ def _durable_link(runtime: Path, durable: Path, name: str) -> None:
def stage_execution_worker() -> None:
"""Bootstrap and preserve ordinal-private subscription refresh ownership."""
"""Stage the shared Claude setup token and ordinal-private Codex refresh state."""
provider_state = WORKER_ROOT / "provider-state"
for path in (
provider_state,
@ -303,10 +303,10 @@ def stage_execution_worker() -> None:
ordinal = int(os.environ.get("HERMES_WORKER_ORDINAL", "-1"))
if ordinal not in range(3):
raise RuntimeError("execution worker ordinal is invalid")
_bootstrap_json(
f"claude-credentials-{ordinal}",
PROVIDER_ACCESS_ROOT / "claude" / ".credentials.json",
("claudeAiOauth", "refreshToken"),
_owned_directory(CLAUDE_OAUTH_ACCESS_ROOT)
_copy_secret(
"claude-oauth-token",
CLAUDE_OAUTH_ACCESS_ROOT / "token",
)
_bootstrap_json(
f"codex-auth-{ordinal}",

View File

@ -133,6 +133,37 @@ def test_provider_queries_validate_credentials_and_response_shape(
assert mod.query_codex is mod.codex_query.query_codex
def test_expired_claude_quota_credential_is_not_sent_or_logged(
tmp_path, monkeypatch, capsys
):
mod = load_module()
credentials = tmp_path / "credentials.json"
credentials.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "revoked-runtime-token",
"expiresAt": 1_700_000_000_000,
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(mod, "CLAUDE_CREDENTIALS", credentials)
monkeypatch.setattr(mod.time, "time", lambda: 1_800_000_000)
monkeypatch.setattr(
mod,
"urlopen",
lambda *_args, **_kwargs: pytest.fail("expired credentials must not be sent"),
)
collector = mod.Collector()
collector.refresh_provider("anthropic")
assert collector._providers["anthropic"].fetch_success is False
assert "revoked-runtime-token" not in capsys.readouterr().out
def test_provider_authentication_uses_only_fresh_non_secret_health(tmp_path, monkeypatch):
mod = load_module()
monkeypatch.setattr(mod, "PROVIDER_HEALTH_ROOT", tmp_path)

View File

@ -262,18 +262,19 @@ def test_readiness_checks_ordinal_paths_credentials_and_mediator(tmp_path, monke
worker_root = tmp_path / "worker"
data_root = tmp_path / "data"
codex = tmp_path / "codex"
claude = tmp_path / "claude"
for path in (worker_root, worker_root / "provider-state", data_root, codex, claude):
claude_token = tmp_path / "claude-oauth/token"
for path in (worker_root, worker_root / "provider-state", data_root, codex):
path.mkdir(parents=True, exist_ok=True)
(codex / "auth.json").write_text("{}")
(claude / ".credentials.json").write_text("{}")
claude_token.parent.mkdir()
claude_token.write_text("setup-token")
schema = tmp_path / "schema/result.json"
monkeypatch.setattr(worker, "ORDINAL", 0)
monkeypatch.setattr(worker, "ROOT", worker_root)
monkeypatch.setattr(worker.cli_lane_runner, "DATA_ROOT", data_root)
monkeypatch.setattr(worker.cli_lane_runner, "RESULT_SCHEMA_PATH", schema)
monkeypatch.setenv("CODEX_HOME", str(codex))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude))
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE", str(claude_token))
polled = []
monkeypatch.setattr(worker, "_poll", lambda: polled.append(True))
worker.readiness()

View File

@ -158,11 +158,10 @@ def test_execution_worker_and_mediator_separate_credentials_and_hmac(
runtime = tmp_path / "runtime"
worker = tmp_path / "worker"
provider_access = tmp_path / "provider-access"
claude_oauth_access = tmp_path / "claude-oauth-access"
pool_access = tmp_path / "pool-access"
vault.mkdir()
(vault / "claude-credentials-1").write_text(
json.dumps({"claudeAiOauth": {"refreshToken": "claude-refresh"}})
)
(vault / "claude-oauth-token").write_text("claude-setup-token")
(vault / "codex-auth-1").write_text(
json.dumps({"tokens": {"refresh_token": "codex-refresh"}})
)
@ -170,6 +169,7 @@ def test_execution_worker_and_mediator_separate_credentials_and_hmac(
monkeypatch.setattr(stage, "RUNTIME_ROOT", runtime)
monkeypatch.setattr(stage, "WORKER_ROOT", worker)
monkeypatch.setattr(stage, "PROVIDER_ACCESS_ROOT", provider_access)
monkeypatch.setattr(stage, "CLAUDE_OAUTH_ACCESS_ROOT", claude_oauth_access)
monkeypatch.setattr(stage, "POOL_ACCESS_ROOT", pool_access)
monkeypatch.setenv("HERMES_WORKER_ORDINAL", "1")
monkeypatch.setattr(stage.os, "chown", lambda *_args: None)
@ -181,6 +181,10 @@ def test_execution_worker_and_mediator_separate_credentials_and_hmac(
assert not (runtime / "execution-pool-key").exists()
assert not (runtime / "codex/sessions").exists()
assert not (runtime / "claude/projects").exists()
assert (claude_oauth_access / "token").read_text().strip() == (
"claude-setup-token"
)
assert not (provider_access / "claude/.credentials.json").exists()
assert (provider_access / "codex/auth.json").stat().st_mode & 0o777 == 0o600
refreshed = {"tokens": {"refresh_token": "provider-rotated"}}
(provider_access / "codex/auth.json").write_text(json.dumps(refreshed))
@ -203,13 +207,14 @@ def test_execution_worker_fails_closed_without_channel_credential(
stage = _load("stage_runtime_access")
vault = tmp_path / "vault"
vault.mkdir()
(vault / "claude-credentials-0").write_text(
json.dumps({"claudeAiOauth": {"refreshToken": "claude-refresh"}})
)
(vault / "claude-oauth-token").write_text("claude-setup-token")
monkeypatch.setattr(stage, "VAULT_ROOT", vault)
monkeypatch.setattr(stage, "RUNTIME_ROOT", tmp_path / "runtime")
monkeypatch.setattr(stage, "WORKER_ROOT", tmp_path / "worker")
monkeypatch.setattr(stage, "PROVIDER_ACCESS_ROOT", tmp_path / "provider-access")
monkeypatch.setattr(
stage, "CLAUDE_OAUTH_ACCESS_ROOT", tmp_path / "claude-oauth-access"
)
monkeypatch.setenv("HERMES_WORKER_ORDINAL", "0")
monkeypatch.setattr(stage.os, "chown", lambda *_args: None)
monkeypatch.setattr(stage.os, "fchown", lambda *_args: None)
@ -359,7 +364,11 @@ def test_runtime_refresh_sync_restores_incomplete_credentials_from_vault(
def test_claude_clients_use_the_vault_staged_long_lived_token():
wrapper = (SCRIPTS / "claude").read_text(encoding="utf-8")
oauth_exec = (SCRIPTS / "claude_oauth_exec").read_text(encoding="utf-8")
deployment = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())
worker = yaml.safe_load(
(HERMES / "execution-worker-statefulset.yaml").read_text()
)
runner = next(
item
for item in deployment["spec"]["template"]["spec"]["containers"]
@ -367,9 +376,32 @@ def test_claude_clients_use_the_vault_staged_long_lived_token():
)
environment = {item["name"]: item["value"] for item in runner["env"]}
assert 'CLAUDE_CODE_OAUTH_TOKEN_FILE:-/claude-oauth-access/token' in wrapper
assert 'export CLAUDE_CODE_OAUTH_TOKEN' in wrapper
assert "/opt/coordinator/claude_oauth_exec" in wrapper
assert 'CLAUDE_CODE_OAUTH_TOKEN_FILE:-/claude-oauth-access/token' in oauth_exec
assert 'export CLAUDE_CODE_OAUTH_TOKEN' in oauth_exec
assert environment["HERMES_CLAUDE_BIN"] == "/opt/coordinator/claude"
worker_pod = worker["spec"]["template"]["spec"]
worker_annotations = worker["spec"]["template"]["metadata"]["annotations"]
assert worker_annotations[
"vault.hashicorp.com/agent-inject-secret-claude-oauth-token"
] == "kv/data/atlas/hermes/agent-tokens"
assert not any("claude-credentials" in key for key in worker_annotations)
worker_container = worker_pod["containers"][0]
worker_environment = {
item["name"]: item["value"]
for item in worker_container["env"]
if "value" in item
}
assert worker_environment["HERMES_CLAUDE_BIN"] == (
"/opt/coordinator/claude_oauth_exec"
)
assert worker_environment["HERMES_CLAUDE_NATIVE_BIN"] == (
"/worker-data/tools/bin/claude"
)
oauth_volume = next(
item for item in worker_pod["volumes"] if item["name"] == "claude-oauth-access"
)
assert oauth_volume["emptyDir"] == {"medium": "Memory", "sizeLimit": "1Mi"}
def test_subprocess_patches_strip_and_redact_runtime_credentials(tmp_path: Path):