hermes(agent): route native Codex through CLI runtime
All checks were successful
Tests / Declarative: Post Actions passed: 227
All checks were successful
Tests / Declarative: Post Actions passed: 227
This commit is contained in:
parent
2f5780ebf9
commit
4ec4976c46
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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
|
||||
|
||||
155
services/hermes/scripts/patch_codex_runtime.py
Normal file
155
services/hermes/scripts/patch_codex_runtime.py
Normal 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())
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user