From 4ec4976c46d13a697966d7e247ee842577f29941 Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 10 Aug 2026 22:39:51 -0300 Subject: [PATCH] hermes(agent): route native Codex through CLI runtime --- services/hermes/agent-configmap.yaml | 3 + services/hermes/agent-deployment.yaml | 40 ++++- services/hermes/kustomization.yaml | 1 + .../hermes/scripts/configure_agent_clients.py | 21 +++ .../hermes/scripts/hermes_model_routing.py | 9 +- .../hermes/scripts/patch_codex_runtime.py | 155 ++++++++++++++++++ testing/tests/test_hermes_cli_lanes.py | 55 +++++++ testing/tests/test_hermes_coordinator.py | 3 + 8 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 services/hermes/scripts/patch_codex_runtime.py diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 128c1b13e..5c6f4a3f3 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -12,6 +12,9 @@ data: provider: openai-codex default: gpt-5.6-terra model: gpt-5.6-terra + # Reuse the owner's authenticated Codex CLI instead of maintaining a + # second rotating OAuth token in Hermes' provider store. + openai_runtime: codex_app_server fallback_providers: - provider: anthropic diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 58e462b01..8a448bfd8 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-stock-supervisor-probe" + ai.bstein.dev/config-rev: "20260811-codex-app-server-routing" 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 @@ -241,6 +241,33 @@ spec: resources: requests: {cpu: 25m, memory: 64Mi} limits: {cpu: 100m, memory: 128Mi} + - name: patch-codex-runtime + image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5 + imagePullPolicy: IfNotPresent + command: + - /opt/hermes/.venv/bin/python + - /opt/coordinator/patch_codex_runtime.py + - /opt/hermes/hermes_cli/runtime_provider.py + - /patched/runtime_provider.py + - /opt/hermes/agent/transports/codex_app_server_session.py + - /patched/codex_app_server_session.py + - /opt/hermes/agent/codex_runtime.py + - /patched/codex_runtime.py + securityContext: + allowPrivilegeEscalation: false + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - name: coordinator + mountPath: /opt/coordinator + readOnly: true + - name: codex-runtime-patch + mountPath: /patched + resources: + requests: {cpu: 25m, memory: 64Mi} + limits: {cpu: 100m, memory: 128Mi} - name: bootstrap-coordinator image: registry.bstein.dev/bstein/hermes-agent@sha256:46de11122226a68adad6d902e81ef9b4b53deb0433dcc61f57fa43fae2e3a3a5 imagePullPolicy: IfNotPresent @@ -274,6 +301,9 @@ spec: - sh - -ec - | + set -a + . /opt/data/.env + set +a /opt/hermes/.venv/bin/python /opt/coordinator/configure_agent_clients.py /opt/hermes/.venv/bin/python /opt/coordinator/migrate_herdr_state.py env: @@ -352,6 +382,9 @@ spec: - {name: provider-auth, mountPath: /shared-auth} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/transports/codex_app_server_session.py, subPath: codex_app_server_session.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.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} @@ -497,6 +530,9 @@ spec: - {name: provider-auth, mountPath: /shared-auth} - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/transports/codex_app_server_session.py, subPath: codex_app_server_session.py} + - {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.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} @@ -615,6 +651,8 @@ spec: emptyDir: {} - name: tui-gateway-patch emptyDir: {} + - name: codex-runtime-patch + emptyDir: {} - name: auto-router-plugin configMap: name: hermes-auto-router-plugin diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 603280fb7..aec81c35c 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -58,6 +58,7 @@ configMapGenerator: - install_agent_tools.sh=scripts/install_agent_tools.sh - migrate_herdr_state.py=scripts/migrate_herdr_state.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py + - patch_codex_runtime.py=scripts/patch_codex_runtime.py - patch_tui_gateway.py=scripts/patch_tui_gateway.py - patch_ttyd_index.py=scripts/patch_ttyd_index.py options: diff --git a/services/hermes/scripts/configure_agent_clients.py b/services/hermes/scripts/configure_agent_clients.py index 9dfb4bfd1..d8d976740 100755 --- a/services/hermes/scripts/configure_agent_clients.py +++ b/services/hermes/scripts/configure_agent_clients.py @@ -8,6 +8,8 @@ import os from pathlib import Path from typing import Any +import yaml + def configure_claude_state(path: Path) -> None: """Disable Claude's long-session resume chooser without losing state.""" @@ -98,11 +100,30 @@ def configure_claude_settings(path: Path) -> None: os.chmod(path, 0o600) +def configure_codex_runtime(config_path: Path, migrate_fn=None) -> Any: + """Expose Hermes tools to Codex and keep the owner lane unattended.""" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if migrate_fn is None: + from hermes_cli.codex_runtime_plugin_migration import migrate + + migrate_fn = migrate + report = migrate_fn( + config, + default_permission_profile=":danger-no-sandbox", + ) + if report.errors: + raise RuntimeError("; ".join(str(item) for item in report.errors)) + print(report.summary()) + return report + + def main() -> None: """Configure provider clients under the persistent Hermes home.""" home = Path(os.environ.get("CLAUDE_CONFIG_DIR", "/opt/data/home/.claude")) configure_claude_state(home / ".claude.json") configure_claude_settings(home / "settings.json") + hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) + configure_codex_runtime(hermes_home / "config.yaml") if __name__ == "__main__": diff --git a/services/hermes/scripts/hermes_model_routing.py b/services/hermes/scripts/hermes_model_routing.py index a92bef45e..bf8f80fb0 100644 --- a/services/hermes/scripts/hermes_model_routing.py +++ b/services/hermes/scripts/hermes_model_routing.py @@ -328,6 +328,7 @@ def _profile_config( "provider": primary_provider, "default": primary_model, "model": primary_model, + "openai_runtime": "codex_app_server", } config["fallback_providers"] = [ fallback, @@ -398,6 +399,7 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A "provider": "openai-codex", "default": codex_coordinator, "model": codex_coordinator, + "openai_runtime": "codex_app_server", } base["fallback_providers"] = [ {"provider": "anthropic", "model": claude_coordinator}, @@ -441,10 +443,7 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A ), env_values, ) - local = [ - "custom/qwen2.5:14b-instruct-q4_0", - "custom/gpt-oss:20b", - ] + local = ["custom/gpt-oss:20b"] routes[codex_name] = [ f"openai-codex/{codex_model}", f"anthropic/{claude_model}", @@ -473,7 +472,6 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A routes["synthesis-xhigh"] = [ f"anthropic/{claude_models['xhigh']}", f"openai-codex/{codex_models['xhigh']}", - "custom/qwen2.5:14b-instruct-q4_0", "custom/gpt-oss:20b", ] _write_yaml( @@ -486,7 +484,6 @@ def configure_routes(root: Path, codex: Catalog, claude: Catalog) -> dict[str, A routes["coordinator"] = [ f"openai-codex/{codex_coordinator}", f"anthropic/{claude_coordinator}", - "custom/qwen2.5:14b-instruct-q4_0", "custom/gpt-oss:20b", ] return routes diff --git a/services/hermes/scripts/patch_codex_runtime.py b/services/hermes/scripts/patch_codex_runtime.py new file mode 100644 index 000000000..e737c1efa --- /dev/null +++ b/services/hermes/scripts/patch_codex_runtime.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Let Hermes use the authenticated Codex app-server with routed settings.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +PROVIDER_BEFORE = ''' if provider == "openai-codex": + try: + creds = resolve_codex_runtime_credentials() +''' +PROVIDER_AFTER = ''' if provider == "openai-codex": + if ( + str(model_cfg.get("openai_runtime") or "").strip().lower() + == "codex_app_server" + ): + return { + "provider": "openai-codex", + "api_mode": "codex_app_server", + "base_url": DEFAULT_CODEX_BASE_URL, + "api_key": "codex-cli-runtime", + "source": "codex-cli", + "requested_provider": requested_provider, + } + try: + creds = resolve_codex_runtime_credentials() +''' + +SESSION_SIGNATURE_BEFORE = ''' def run_turn( + self, + user_input: Any, + *, + turn_timeout: float = 600.0, +''' +SESSION_SIGNATURE_AFTER = ''' def run_turn( + self, + user_input: Any, + *, + model: Optional[str] = None, + effort: Optional[str] = None, + turn_timeout: float = 600.0, +''' + +SESSION_REQUEST_BEFORE = ''' ts = self._client.request( + "turn/start", + { + "threadId": self._thread_id, + "input": [{"type": "text", "text": user_input_text}], + }, + timeout=10, + ) +''' +SESSION_REQUEST_AFTER = ''' turn_params: dict[str, Any] = { + "threadId": self._thread_id, + "input": [{"type": "text", "text": user_input_text}], + } + if model: + turn_params["model"] = model + if effort: + turn_params["effort"] = effort + ts = self._client.request( + "turn/start", + turn_params, + timeout=10, + ) +''' + +TURN_BEFORE = ''' try: + turn = agent._codex_session.run_turn(user_input=user_message) +''' +TURN_AFTER = ''' reasoning = getattr(agent, "reasoning_config", None) + effort = ( + str(reasoning.get("effort") or "").strip() + if isinstance(reasoning, dict) + else "" + ) + try: + turn = agent._codex_session.run_turn( + user_input=user_message, + model=str(getattr(agent, "model", "") or "").strip() or None, + effort=effort or None, + ) +''' + + +def _replace_once(content: str, before: str, after: str, label: str) -> str: + """Apply one exact replacement and fail closed when upstream drifts.""" + if content.count(before) != 1: + raise RuntimeError(f"Hermes {label} patch context changed") + return content.replace(before, after, 1) + + +def patch_provider(source: Path, destination: Path) -> None: + """Allow the explicit Codex app-server runtime without duplicate OAuth.""" + content = source.read_text(encoding="utf-8") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + _replace_once( + content, + PROVIDER_BEFORE, + PROVIDER_AFTER, + "runtime provider", + ), + encoding="utf-8", + ) + + +def patch_session(source: Path, destination: Path) -> None: + """Pass the router's selected model and effort to each Codex turn.""" + content = source.read_text(encoding="utf-8") + content = _replace_once( + content, + SESSION_SIGNATURE_BEFORE, + SESSION_SIGNATURE_AFTER, + "app-server session signature", + ) + content = _replace_once( + content, + SESSION_REQUEST_BEFORE, + SESSION_REQUEST_AFTER, + "app-server turn request", + ) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + + +def patch_turn(source: Path, destination: Path) -> None: + """Forward live Hermes route metadata into the Codex session adapter.""" + content = source.read_text(encoding="utf-8") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + _replace_once(content, TURN_BEFORE, TURN_AFTER, "Codex turn"), + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("provider_source", type=Path) + parser.add_argument("provider_destination", type=Path) + parser.add_argument("session_source", type=Path) + parser.add_argument("session_destination", type=Path) + parser.add_argument("turn_source", type=Path) + parser.add_argument("turn_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) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index c189dcdb4..079440f4c 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -37,6 +37,7 @@ policy = _load("claude_command_policy") migration = _load("migrate_herdr_state") auth_patch = _load("patch_hermes_auth") tui_gateway_patch = _load("patch_tui_gateway") +codex_runtime_patch = _load("patch_codex_runtime") ttyd_patch = _load("patch_ttyd_index") client_config = _load("configure_agent_clients") @@ -858,6 +859,60 @@ def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path): auth_patch.patch(source, tmp_path / "patched.py") +def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path): + provider = tmp_path / "runtime_provider.py" + provider.write_text(codex_runtime_patch.PROVIDER_BEFORE, encoding="utf-8") + provider_out = tmp_path / "patched/runtime_provider.py" + codex_runtime_patch.patch_provider(provider, provider_out) + assert '"api_mode": "codex_app_server"' in provider_out.read_text() + + session = tmp_path / "codex_app_server_session.py" + session.write_text( + codex_runtime_patch.SESSION_SIGNATURE_BEFORE + + codex_runtime_patch.SESSION_REQUEST_BEFORE, + encoding="utf-8", + ) + session_out = tmp_path / "patched/codex_app_server_session.py" + codex_runtime_patch.patch_session(session, session_out) + session_content = session_out.read_text() + assert 'turn_params["model"] = model' in session_content + assert 'turn_params["effort"] = effort' in session_content + + turn = tmp_path / "codex_runtime.py" + turn.write_text(codex_runtime_patch.TURN_BEFORE, encoding="utf-8") + turn_out = tmp_path / "patched/codex_runtime.py" + codex_runtime_patch.patch_turn(turn, turn_out) + assert "model=str(getattr(agent" in turn_out.read_text() + + +def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path): + source = tmp_path / "runtime_provider.py" + source.write_text("def changed():\n pass\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="context changed"): + codex_runtime_patch.patch_provider(source, tmp_path / "patched.py") + + +def test_codex_runtime_migration_uses_owner_unsafe_profile(tmp_path: Path): + config = tmp_path / "config.yaml" + config.write_text("model: {}\n", encoding="utf-8") + calls = [] + + class Report: + errors = [] + + @staticmethod + def summary(): + return "configured" + + def migrate(value, **kwargs): + calls.append((value, kwargs)) + return Report() + + client_config.configure_codex_runtime(config, migrate) + + assert calls[0][1]["default_permission_profile"] == ":danger-no-sandbox" + + def test_tui_gateway_patch_extends_and_bounds_agent_startup(tmp_path: Path): source = tmp_path / "server.py" destination = tmp_path / "patched/server.py" diff --git a/testing/tests/test_hermes_coordinator.py b/testing/tests/test_hermes_coordinator.py index 1c2c304dc..dac194790 100644 --- a/testing/tests/test_hermes_coordinator.py +++ b/testing/tests/test_hermes_coordinator.py @@ -92,7 +92,9 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path (tmp_path / "profiles/claude-high/config.yaml").read_text(encoding="utf-8") ) assert root["model"]["model"] == "gpt-5.6-terra" + assert root["model"]["openai_runtime"] == "codex_app_server" assert codex_profile["model"]["model"] == "gpt-5.6-sol" + assert codex_profile["model"]["openai_runtime"] == "codex_app_server" assert codex_profile["fallback_providers"][0] == { "provider": "anthropic", "model": "claude-opus-5", @@ -104,6 +106,7 @@ def test_configure_routes_builds_cross_provider_fallback_profiles(tmp_path: Path assert codex_profile["fallback_providers"][1] == routing.ATLAS_FALLBACK assert len(codex_profile["fallback_providers"]) == 2 assert routes["coordinator"][0] == "openai-codex/gpt-5.6-terra" + assert "custom/qwen2.5:14b-instruct-q4_0" not in routes["coordinator"] assert "max" not in json.dumps(routes) profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")