From 137d6a54ef1484b194b3c4cbbc832ae2b7d47e9c Mon Sep 17 00:00:00 2001 From: jenkins Date: Mon, 10 Aug 2026 22:53:59 -0300 Subject: [PATCH] hermes(agent): validate Codex owner runtime --- services/hermes/agent-deployment.yaml | 2 +- .../hermes/scripts/configure_agent_clients.py | 80 ++++++++++++++++++- .../hermes/scripts/patch_codex_runtime.py | 2 + testing/tests/test_hermes_cli_lanes.py | 32 +++++++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 8a448bfd8..9244feef8 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-app-server-routing" + ai.bstein.dev/config-rev: "20260811-codex-app-server-permissions" 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 diff --git a/services/hermes/scripts/configure_agent_clients.py b/services/hermes/scripts/configure_agent_clients.py index d8d976740..19d807e6b 100755 --- a/services/hermes/scripts/configure_agent_clients.py +++ b/services/hermes/scripts/configure_agent_clients.py @@ -5,6 +5,9 @@ from __future__ import annotations import json import os +import re +import shutil +import subprocess from pathlib import Path from typing import Any @@ -100,19 +103,91 @@ def configure_claude_settings(path: Path) -> None: os.chmod(path, 0o600) -def configure_codex_runtime(config_path: Path, migrate_fn=None) -> Any: +OWNER_PERMISSIONS_BEGIN = "# begin hermes-agent owner permissions" +OWNER_PERMISSIONS_END = "# end hermes-agent owner permissions" + + +def configure_codex_owner_permissions(path: Path) -> None: + """Use the stable Codex 0.147 approval and sandbox configuration keys.""" + content = path.read_text(encoding="utf-8") if path.is_file() else "" + content = re.sub( + rf"(?ms)^{re.escape(OWNER_PERMISSIONS_BEGIN)}\n.*?^{re.escape(OWNER_PERMISSIONS_END)}\n?", + "", + content, + ) + + # Remove stale root-level values from the pre-0.147 Hermes migration. + # Stop at the first TOML table so identically named nested keys remain intact. + lines = content.splitlines() + first_table = next( + (index for index, line in enumerate(lines) if line.lstrip().startswith("[")), + len(lines), + ) + root = [ + line + for line in lines[:first_table] + if not re.match( + r"^\s*(default_permissions|approval_policy|sandbox_mode)\s*=", + line, + ) + ] + remainder = lines[first_table:] + owner_block = [ + OWNER_PERMISSIONS_BEGIN, + 'approval_policy = "never"', + 'sandbox_mode = "danger-full-access"', + OWNER_PERMISSIONS_END, + "", + ] + rendered = "\n".join([*owner_block, *root, *remainder]).rstrip() + "\n" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(rendered, encoding="utf-8") + os.chmod(path, 0o600) + + +def validate_codex_runtime(codex_bin: str = "codex") -> None: + """Fail pod initialization when Codex cannot load its config or auth.""" + resolved = shutil.which(codex_bin) + if resolved is None: + raise RuntimeError(f"Codex executable not found: {codex_bin}") + result = subprocess.run( + [resolved, "login", "status"], + check=False, + capture_output=True, + text=True, + timeout=20, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "unknown error").strip() + raise RuntimeError(f"Codex runtime validation failed: {detail}") + + +def configure_codex_runtime( + config_path: Path, + migrate_fn=None, + codex_home: Path | None = 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 + if codex_home is None: + codex_home = Path( + os.environ.get( + "CODEX_HOME", + str(Path(os.environ.get("HOME", "/opt/data/home")) / ".codex"), + ) + ) report = migrate_fn( config, - default_permission_profile=":danger-no-sandbox", + codex_home=codex_home, + default_permission_profile=None, ) if report.errors: raise RuntimeError("; ".join(str(item) for item in report.errors)) + configure_codex_owner_permissions(codex_home / "config.toml") print(report.summary()) return report @@ -124,6 +199,7 @@ def main() -> None: configure_claude_settings(home / "settings.json") hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) configure_codex_runtime(hermes_home / "config.yaml") + validate_codex_runtime() if __name__ == "__main__": diff --git a/services/hermes/scripts/patch_codex_runtime.py b/services/hermes/scripts/patch_codex_runtime.py index e737c1efa..4aa591a8f 100644 --- a/services/hermes/scripts/patch_codex_runtime.py +++ b/services/hermes/scripts/patch_codex_runtime.py @@ -55,6 +55,8 @@ SESSION_REQUEST_BEFORE = ''' ts = self._client.request( SESSION_REQUEST_AFTER = ''' turn_params: dict[str, Any] = { "threadId": self._thread_id, "input": [{"type": "text", "text": user_input_text}], + "approvalPolicy": "never", + "sandboxPolicy": {"type": "dangerFullAccess"}, } if model: turn_params["model"] = model diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index 079440f4c..2663346fa 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -877,6 +877,8 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path): session_content = session_out.read_text() assert 'turn_params["model"] = model' in session_content assert 'turn_params["effort"] = effort' in session_content + assert '"approvalPolicy": "never"' in session_content + assert '"sandboxPolicy": {"type": "dangerFullAccess"}' in session_content turn = tmp_path / "codex_runtime.py" turn.write_text(codex_runtime_patch.TURN_BEFORE, encoding="utf-8") @@ -892,9 +894,10 @@ def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path): codex_runtime_patch.patch_provider(source, tmp_path / "patched.py") -def test_codex_runtime_migration_uses_owner_unsafe_profile(tmp_path: Path): +def test_codex_runtime_migration_uses_owner_unsafe_mode(tmp_path: Path): config = tmp_path / "config.yaml" config.write_text("model: {}\n", encoding="utf-8") + codex_home = tmp_path / ".codex" calls = [] class Report: @@ -908,9 +911,32 @@ def test_codex_runtime_migration_uses_owner_unsafe_profile(tmp_path: Path): calls.append((value, kwargs)) return Report() - client_config.configure_codex_runtime(config, migrate) + client_config.configure_codex_runtime(config, migrate, codex_home) - assert calls[0][1]["default_permission_profile"] == ":danger-no-sandbox" + assert calls[0][1]["default_permission_profile"] is None + assert calls[0][1]["codex_home"] == codex_home + content = (codex_home / "config.toml").read_text(encoding="utf-8") + assert 'approval_policy = "never"' in content + assert 'sandbox_mode = "danger-full-access"' in content + assert "default_permissions" not in content + + +def test_codex_owner_permissions_replace_stale_profile(tmp_path: Path): + config = tmp_path / "config.toml" + config.write_text( + 'default_permissions = ":danger-no-sandbox"\n\n[features]\nhooks = true\n', + encoding="utf-8", + ) + + client_config.configure_codex_owner_permissions(config) + client_config.configure_codex_owner_permissions(config) + + content = config.read_text(encoding="utf-8") + assert content.count(client_config.OWNER_PERMISSIONS_BEGIN) == 1 + assert content.count('approval_policy = "never"') == 1 + assert content.count('sandbox_mode = "danger-full-access"') == 1 + assert "default_permissions" not in content + assert "[features]\nhooks = true" in content def test_tui_gateway_patch_extends_and_bounds_agent_startup(tmp_path: Path):