hermes(agent): route native Codex through CLI runtime
All checks were successful
Tests / Declarative: Post Actions passed: 227

This commit is contained in:
jenkins 2026-08-10 22:39:51 -03:00
parent 2f5780ebf9
commit 4ec4976c46
8 changed files with 280 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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__":

View File

@ -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

View File

@ -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())

View File

@ -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"

View File

@ -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")