Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,30 @@

import logging
import os
from pathlib import Path
import time
from types import SimpleNamespace
from typing import Any, Dict, List

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds implicit profile Codex-home discovery, but current documentation deliberately keeps Codex state shared by default and requires explicit CODEX_HOME for profile isolation (website/docs/user-guide/features/codex-app-server-runtime.md:296-309). Please resolve that precedence/design contract and document setup/auth behavior before introducing this convention.

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)``.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
56 changes: 47 additions & 9 deletions agent/transports/codex_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:danger-full-access is not the built-in unrestricted profile Hermes documents or emits; hermes_cli/codex_runtime_plugin_migration.py:629-637 identifies :danger-no-sandbox. This condition therefore misses the supported configuration, while its body removes the Kanban sandbox protections. Please retain the narrow overrides or establish a supported, tested security contract first.

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.
Expand Down
60 changes: 60 additions & 0 deletions tests/agent/transports/test_codex_app_server_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions tests/run_agent/test_codex_app_server_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down