hermes(agent): validate Codex owner runtime
All checks were successful
Tests / Declarative: Post Actions passed: 228

This commit is contained in:
jenkins 2026-08-10 22:53:59 -03:00
parent 64c58a1376
commit 137d6a54ef
4 changed files with 110 additions and 6 deletions

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/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/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/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/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens

View File

@ -5,6 +5,9 @@ from __future__ import annotations
import json import json
import os import os
import re
import shutil
import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -100,19 +103,91 @@ def configure_claude_settings(path: Path) -> None:
os.chmod(path, 0o600) 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.""" """Expose Hermes tools to Codex and keep the owner lane unattended."""
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if migrate_fn is None: if migrate_fn is None:
from hermes_cli.codex_runtime_plugin_migration import migrate from hermes_cli.codex_runtime_plugin_migration import migrate
migrate_fn = 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( report = migrate_fn(
config, config,
default_permission_profile=":danger-no-sandbox", codex_home=codex_home,
default_permission_profile=None,
) )
if report.errors: if report.errors:
raise RuntimeError("; ".join(str(item) for item in report.errors)) raise RuntimeError("; ".join(str(item) for item in report.errors))
configure_codex_owner_permissions(codex_home / "config.toml")
print(report.summary()) print(report.summary())
return report return report
@ -124,6 +199,7 @@ def main() -> None:
configure_claude_settings(home / "settings.json") configure_claude_settings(home / "settings.json")
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
configure_codex_runtime(hermes_home / "config.yaml") configure_codex_runtime(hermes_home / "config.yaml")
validate_codex_runtime()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -55,6 +55,8 @@ SESSION_REQUEST_BEFORE = ''' ts = self._client.request(
SESSION_REQUEST_AFTER = ''' turn_params: dict[str, Any] = { SESSION_REQUEST_AFTER = ''' turn_params: dict[str, Any] = {
"threadId": self._thread_id, "threadId": self._thread_id,
"input": [{"type": "text", "text": user_input_text}], "input": [{"type": "text", "text": user_input_text}],
"approvalPolicy": "never",
"sandboxPolicy": {"type": "dangerFullAccess"},
} }
if model: if model:
turn_params["model"] = model turn_params["model"] = model

View File

@ -877,6 +877,8 @@ def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
session_content = session_out.read_text() session_content = session_out.read_text()
assert 'turn_params["model"] = model' in session_content assert 'turn_params["model"] = model' in session_content
assert 'turn_params["effort"] = effort' 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 = tmp_path / "codex_runtime.py"
turn.write_text(codex_runtime_patch.TURN_BEFORE, encoding="utf-8") 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") 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 = tmp_path / "config.yaml"
config.write_text("model: {}\n", encoding="utf-8") config.write_text("model: {}\n", encoding="utf-8")
codex_home = tmp_path / ".codex"
calls = [] calls = []
class Report: class Report:
@ -908,9 +911,32 @@ def test_codex_runtime_migration_uses_owner_unsafe_profile(tmp_path: Path):
calls.append((value, kwargs)) calls.append((value, kwargs))
return Report() 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): def test_tui_gateway_patch_extends_and_bounds_agent_startup(tmp_path: Path):