diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index fad2b3f7c..a221802cf 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -24,7 +24,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: "20260811-codex-cross-provider-fallback" + ai.bstein.dev/config-rev: "20260811-codex-cli-auxiliary" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens @@ -279,6 +279,8 @@ spec: - /patched/chat_completion_helpers.py - /opt/hermes/agent/conversation_loop.py - /patched/conversation_loop.py + - /opt/hermes/agent/auxiliary_client.py + - /patched/auxiliary_client.py securityContext: allowPrivilegeEscalation: false runAsUser: 10000 @@ -424,6 +426,7 @@ spec: - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py} - {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py} - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} @@ -574,6 +577,7 @@ spec: - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/chat_completion_helpers.py, subPath: chat_completion_helpers.py} - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py} - {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py} - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} diff --git a/services/hermes/scripts/patch_codex_runtime.py b/services/hermes/scripts/patch_codex_runtime.py index a02c51e09..6f8e9c979 100644 --- a/services/hermes/scripts/patch_codex_runtime.py +++ b/services/hermes/scripts/patch_codex_runtime.py @@ -223,6 +223,50 @@ FALLBACK_CONTEXT_AFTER = '''def build_cross_provider_codex_prompt( def run_codex_app_server_turn( ''' +AUXILIARY_TOKEN_BEFORE = ''' except Exception as exc: + logger.debug("Could not read Codex auth for auxiliary client: %s", exc) + return None +''' +AUXILIARY_TOKEN_AFTER = ''' except Exception as exc: + logger.debug("Could not read Hermes Codex auth for auxiliary client: %s", exc) + + # Agent Hermes deliberately runs Codex through the authenticated CLI + # app-server and does not duplicate those credentials into Hermes' + # provider auth store. Auxiliary tasks still use Hermes' native Codex + # Responses adapter, so read the current CLI access token without + # copying or refreshing it here. The Codex CLI remains the sole owner + # of refresh-token rotation. + try: + codex_home = os.environ.get("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + auth_path = Path(codex_home).expanduser() / "auth.json" + payload = json.loads(auth_path.read_text(encoding="utf-8")) + tokens = payload.get("tokens") or {} + access_token = tokens.get("access_token") + if not isinstance(access_token, str) or not access_token.strip(): + return None + + # Match the native expiry check above. An expired CLI token is not + # refreshed from this side channel because that would race the + # app-server's canonical refresh-token owner. + try: + import base64 + jwt_payload = access_token.split(".")[1] + jwt_payload += "=" * (-len(jwt_payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(jwt_payload)) + expires_at = claims.get("exp", 0) + if expires_at and time.time() > expires_at: + logger.debug("Codex CLI access token is expired; skipping auxiliary route") + return None + except Exception: + pass + return access_token.strip() + except Exception as cli_exc: + logger.debug("Could not read Codex CLI auth for auxiliary client: %s", cli_exc) + return None +''' + def _replace_once(content: str, before: str, after: str, label: str) -> str: """Apply one exact replacement and fail closed when upstream drifts.""" @@ -311,6 +355,21 @@ def patch_loop(source: Path, destination: Path) -> None: ) +def patch_auxiliary(source: Path, destination: Path) -> None: + """Let auxiliary tasks reuse the current Codex CLI access token safely.""" + content = source.read_text(encoding="utf-8") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + _replace_once( + content, + AUXILIARY_TOKEN_BEFORE, + AUXILIARY_TOKEN_AFTER, + "Codex CLI auxiliary auth", + ), + encoding="utf-8", + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("provider_source", type=Path) @@ -323,12 +382,15 @@ def main() -> int: parser.add_argument("fallback_destination", type=Path) parser.add_argument("loop_source", type=Path) parser.add_argument("loop_destination", type=Path) + parser.add_argument("auxiliary_source", type=Path) + parser.add_argument("auxiliary_destination", type=Path) args = parser.parse_args() patch_provider(args.provider_source, args.provider_destination) patch_session(args.session_source, args.session_destination) patch_turn(args.turn_source, args.turn_destination) patch_fallback(args.fallback_source, args.fallback_destination) patch_loop(args.loop_source, args.loop_destination) + patch_auxiliary(args.auxiliary_source, args.auxiliary_destination) return 0 diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index 57106999b..5d91cd007 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -957,6 +957,70 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path): assert 'if agent.api_mode == "codex_app_server"' in loop_content assert "build_cross_provider_codex_prompt" in loop_content + auxiliary = tmp_path / "auxiliary_client.py" + auxiliary.write_text( + codex_runtime_patch.AUXILIARY_TOKEN_BEFORE, + encoding="utf-8", + ) + auxiliary_out = tmp_path / "patched/auxiliary_client.py" + codex_runtime_patch.patch_auxiliary(auxiliary, auxiliary_out) + auxiliary_content = auxiliary_out.read_text() + assert 'os.environ.get("CODEX_HOME"' in auxiliary_content + assert 'Path(codex_home).expanduser() / "auth.json"' in auxiliary_content + assert "sole owner" in auxiliary_content + + +def test_agent_mounts_codex_auxiliary_runtime_patch(): + deployment = _agent_deployment() + pod = deployment["spec"]["template"]["spec"] + patch_init = next( + item for item in pod["initContainers"] + if item["name"] == "patch-codex-runtime" + ) + assert patch_init["command"][-2:] == [ + "/opt/hermes/agent/auxiliary_client.py", + "/patched/auxiliary_client.py", + ] + expected_mount = { + "name": "codex-runtime-patch", + "mountPath": "/opt/hermes/agent/auxiliary_client.py", + "subPath": "auxiliary_client.py", + } + containers = {item["name"]: item for item in pod["containers"]} + for name in ("hermes", "terminal"): + assert expected_mount in containers[name]["volumeMounts"] + + +def test_codex_auxiliary_patch_reads_cli_token_without_copying_it( + tmp_path: Path, + monkeypatch, +): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "cli-access-token"}}), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + source = tmp_path / "auxiliary_client.py" + source.write_text( + "import json, logging, os, time\n" + "from pathlib import Path\n" + "logger = logging.getLogger(__name__)\n" + "def read_token():\n" + " try:\n" + " raise RuntimeError('Hermes provider store intentionally empty')\n" + + codex_runtime_patch.AUXILIARY_TOKEN_BEFORE, + encoding="utf-8", + ) + destination = tmp_path / "patched/auxiliary_client.py" + codex_runtime_patch.patch_auxiliary(source, destination) + namespace: dict = {} + exec(compile(destination.read_text(), str(destination), "exec"), namespace) + + assert namespace["read_token"]() == "cli-access-token" + def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path): source = tmp_path / "runtime_provider.py"