From a9aafbb360d6dc567c283799aff9407901c9c769 Mon Sep 17 00:00:00 2001 From: lijun Date: Fri, 3 Jul 2026 22:54:25 +0800 Subject: [PATCH 1/2] fix(codex): use profile Codex home for app-server workers Kanban workers are spawned as hermes -p and inherit CODEX_HOME from the long-lived gateway service. That makes profile-scoped Codex permission config ineffective for Codex app-server workers. When the active HERMES_HOME contains codex-home/config.toml, pass that directory to CodexAppServerSession so the Codex app-server child uses the profile-local Codex home. Profiles without a codex-home continue to inherit the existing global CODEX_HOME behavior. --- agent/codex_runtime.py | 20 +++++++++++++++++ .../test_codex_app_server_integration.py | 22 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 1e7b9fd7d2b11..c04992a51fb9f 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -18,6 +18,7 @@ import logging import os +from pathlib import Path import time from types import SimpleNamespace from typing import Any, Dict, List @@ -25,6 +26,22 @@ logger = logging.getLogger(__name__) +def _profile_codex_home_override() -> str | None: + """Return a profile-local Codex home when the active profile defines one.""" + hermes_home = (os.environ.get("HERMES_HOME") or "").strip() + if not hermes_home: + return None + try: + candidate = Path(hermes_home).expanduser() / "codex-home" + if (candidate / "config.toml").is_file(): + return str(candidate) + except Exception: + logger.debug( + "codex app-server: profile Codex home lookup failed", exc_info=True + ) + return None + + def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None: """Map a Codex app-server ``item/started`` notification to a Hermes tool-progress event ``(tool_name, preview, args)``. @@ -286,6 +303,8 @@ def run_codex_app_server_turn( exc_info=True, ) + codex_home = _profile_codex_home_override() + def _on_codex_event(note: dict) -> None: # Bridge Codex app-server item/started notifications to Hermes # tool-progress so gateways show verbose "running X" breadcrumbs @@ -304,6 +323,7 @@ def _on_codex_event(note: dict) -> None: agent._codex_session = CodexAppServerSession( cwd=cwd, + codex_home=codex_home, approval_callback=approval_callback, request_routing=_ServerRequestRouting( auto_approve_exec=auto_approve_requests, diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index f9d2dcf652d06..05a3b0e8b780a 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -351,6 +351,28 @@ def fake_run_turn(self, user_input: str, **kwargs): ) return captured + def test_uses_profile_codex_home_when_profile_defines_one( + self, monkeypatch, tmp_path + ): + captured = self._capture_routing_agent(monkeypatch) + profile_home = tmp_path / "profiles" / "coder-codex" + codex_home = profile_home / "codex-home" + codex_home.mkdir(parents=True) + (codex_home / "config.toml").write_text( + 'default_permissions = ":danger-full-access"\n', + encoding="utf-8", + ) + inherited_codex_home = tmp_path / "global-codex" + inherited_codex_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setenv("CODEX_HOME", str(inherited_codex_home)) + + agent = _make_codex_agent() + with patch.object(agent, "_spawn_background_review", return_value=None): + agent.run_conversation("work a kanban task") + + assert captured.get("codex_home") == str(codex_home) + def test_approvals_mode_off_auto_approves_codex_server_requests( self, monkeypatch ): From c33007b495eefea050804ded3484772b9c58d615 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 4 Jul 2026 20:15:41 +0800 Subject: [PATCH 2/2] fix(codex): preserve profile permissions for kanban app-server --- agent/transports/codex_app_server.py | 56 ++++++++++++++--- .../test_codex_app_server_runtime.py | 60 +++++++++++++++++++ 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py index 273e44667d6a8..a6484cf18ecf8 100644 --- a/agent/transports/codex_app_server.py +++ b/agent/transports/codex_app_server.py @@ -17,20 +17,48 @@ from __future__ import annotations import json +import logging import os import queue import subprocess import threading import time +import tomllib from dataclasses import dataclass, field from typing import Any, Optional from tools.environments.local import hermes_subprocess_env +logger = logging.getLogger(__name__) + # Default minimum codex version we test against. The PR sets this from the # `codex --version` parsed at install time; bumping is a one-line change here. MIN_CODEX_VERSION = (0, 125, 0) +_DANGER_DEFAULT_PERMISSIONS = ":danger-full-access" + + +def _codex_home_default_permissions(codex_home: Optional[str]) -> Optional[str]: + """Read Codex's top-level ``default_permissions`` from CODEX_HOME.""" + if not codex_home: + return None + config_path = os.path.join(codex_home, "config.toml") + try: + with open(config_path, "rb") as handle: + config = tomllib.load(handle) + except FileNotFoundError: + return None + except Exception: + logger.debug( + "codex app-server: failed to read Codex config %s", + config_path, + exc_info=True, + ) + return None + + value = config.get("default_permissions") + return value if isinstance(value, str) else None + @dataclass class CodexAppServerError(RuntimeError): @@ -112,16 +140,26 @@ def __init__( ), ) ) - app_server_args.extend( - [ - "-c", - 'sandbox_mode="workspace-write"', - "-c", - f'sandbox_workspace_write.writable_roots=["{kanban_root}"]', - "-c", - "sandbox_workspace_write.network_access=false", - ] + default_permissions = _codex_home_default_permissions( + spawn_env.get("CODEX_HOME") ) + if default_permissions == _DANGER_DEFAULT_PERMISSIONS: + logger.info( + "codex app-server: using profile default_permissions for " + "kanban worker (CODEX_HOME=%s)", + spawn_env.get("CODEX_HOME"), + ) + else: + app_server_args.extend( + [ + "-c", + 'sandbox_mode="workspace-write"', + "-c", + f'sandbox_workspace_write.writable_roots=["{kanban_root}"]', + "-c", + "sandbox_workspace_write.network_access=false", + ] + ) cmd = [codex_bin, "app-server"] + app_server_args # Codex emits tracing to stderr; default WARN keeps it quiet for users. diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py index 5c1c9bda60df7..3d49667e718b8 100644 --- a/tests/agent/transports/test_codex_app_server_runtime.py +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -296,6 +296,66 @@ def kill(self): assert "sandbox_workspace_write.network_access=false" in cmd assert all("danger" not in part for part in cmd) + def test_kanban_worker_honors_profile_danger_default_permissions( + self, monkeypatch, tmp_path + ): + """A profile-local Codex config may intentionally opt a kanban + worker into danger-full-access. In that case Hermes must not append + the workspace-write override that would silently narrow Codex back to + the default sandbox and re-block git refs writes. + """ + import subprocess + from agent.transports import codex_app_server as cas + + captured = {} + + class FakePopen: + def __init__(self, cmd, *args, **kwargs): + captured["cmd"] = list(cmd) + captured["env"] = kwargs.get("env", {}).copy() + self.stdin = None + self.stdout = None + self.stderr = None + self.pid = 1 + self.returncode = None + + def poll(self): + return None + + def terminate(self): + pass + + def wait(self, timeout=None): + return 0 + + def kill(self): + pass + + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + 'default_permissions = ":danger-full-access"\n', + encoding="utf-8", + ) + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setenv("HOME", "/users/alice") + monkeypatch.setenv("HERMES_HOME", "/users/alice/.hermes/profiles/coder") + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_smoke") + monkeypatch.setenv( + "HERMES_KANBAN_DB", + "/users/alice/.hermes/kanban/boards/smoke/kanban.db", + ) + + client = cas.CodexAppServerClient( + codex_bin="codex", codex_home=str(codex_home) + ) + client._closed = True + + cmd = captured["cmd"] + assert cmd == ["codex", "app-server"] + assert captured["env"].get("CODEX_HOME") == str(codex_home) + class TestSpawnEnvSecretStripping: """codex app-server routes its spawn env through hermes_subprocess_env(