diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py index 7128de9c4faa4..b08746d1dffdf 100644 --- a/agent/transports/codex_app_server.py +++ b/agent/transports/codex_app_server.py @@ -145,7 +145,7 @@ def initialize( client_title: str = "Hermes Agent", client_version: str = "0.1", capabilities: Optional[dict] = None, - timeout: float = 10.0, + timeout: float = 30.0, ) -> dict: """Send `initialize` + `initialized` handshake. Returns the server's InitializeResponse (userAgent, codexHome, platformFamily, platformOs).""" diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index d9ee92dfbf58a..16349d7164219 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -29,6 +29,7 @@ import threading import time from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Callable, Optional from agent.redact import redact_sensitive_text @@ -117,6 +118,38 @@ class TurnResult: ) +def _codex_configured_writable_roots() -> list[Path]: + """Return Codex sandbox writable roots from ~/.codex/config.toml. + + This intentionally reads only the local Codex sandbox stanza and ignores + malformed config. It lets Hermes safely answer Codex app-server permission + requests for roots the operator has already marked writable. + """ + config_path = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "config.toml" + try: + import tomllib + except Exception: # pragma: no cover - Python < 3.11 fallback path + return [] + + try: + data = tomllib.loads(config_path.read_text()) + except Exception: + return [] + + sandbox_cfg = data.get("sandbox_workspace_write") + if not isinstance(sandbox_cfg, dict): + return [] + roots = sandbox_cfg.get("writable_roots") + if not isinstance(roots, list): + return [] + + out: list[Path] = [] + for root in roots: + if isinstance(root, str) and root.strip(): + out.append(Path(root).expanduser()) + return out + + def _classify_oauth_failure(*parts: str) -> Optional[str]: """Return a user-friendly re-auth hint if any of the provided strings look like a codex OAuth/token-refresh failure; otherwise None. @@ -213,6 +246,7 @@ def ensure_started(self) -> str: client_name="hermes", client_title="Hermes Agent", client_version=_get_hermes_version(), + timeout=30.0, ) # Permission selection is intentionally NOT sent on thread/start. # Two reasons (live-tested against codex 0.130.0): @@ -619,11 +653,8 @@ def _handle_server_request(self, req: dict) -> None: decision = self._decide_apply_patch_approval(params) self._client.respond(rid, {"decision": decision}) elif method == "item/permissions/requestApproval": - # Codex sometimes asks to escalate permissions mid-turn. We - # always decline — the user already chose their permission - # profile in ~/.codex/config.toml and surprise escalations - # shouldn't be silently accepted. - self._client.respond(rid, {"decision": "decline"}) + decision = self._decide_permissions_approval(params) + self._client.respond(rid, {"decision": decision}) elif method == "mcpServer/elicitation/request": # Codex's MCP layer asks the user for structured input on # behalf of an MCP server (e.g. tool-call confirmation, @@ -716,6 +747,76 @@ def _decide_apply_patch_approval(self, params: dict) -> str: return "decline" return "decline" + def _decide_permissions_approval(self, params: dict) -> str: + """Decide Codex mid-turn sandbox permission requests. + + Codex asks for this when a command needs write access outside the + current sandbox or needs a broader workspace grant. Declining every + request made Hermes-created Codex turns unusable for normal agentic + coding flows: `apply_patch`, Playwright, Vite, and build tools can all + request a workspace grant after the turn has already started. + + If an interactive Hermes approval callback exists, route the request to + the user. In non-interactive contexts, only auto-accept grants rooted in + the current session cwd or in Codex's configured + `sandbox_workspace_write.writable_roots`; everything else still fails + closed. + """ + reason = params.get("reason") or params.get("description") or "Codex requests sandbox permissions" + grant_root = params.get("grantRoot") or params.get("root") or params.get("cwd") + permission_summary = self._format_permissions_request(params) + + description_parts = [str(reason)] + if permission_summary: + description_parts.append(permission_summary) + if grant_root: + description_parts.append(f"grant root: {grant_root}") + description = "; ".join(description_parts) + + if self._approval_callback is not None: + try: + choice = self._approval_callback( + f"codex permissions: {grant_root or permission_summary or reason}", + description, + allow_permanent=False, + ) + return _approval_choice_to_codex_decision(choice) + except Exception: + logger.exception("approval_callback raised on permissions request") + return "decline" + + if grant_root and self._is_allowed_permission_root(str(grant_root)): + logger.info("auto-approved codex permission grant for configured root: %s", grant_root) + return "accept" + + logger.warning("declined codex permission request without approval callback: %s", description) + return "decline" + + def _format_permissions_request(self, params: dict) -> str: + permissions = params.get("permissions") or params.get("requestedPermissions") or params.get("permission") + if isinstance(permissions, list): + return "permissions: " + ", ".join(map(str, permissions)) + if permissions: + return f"permissions: {permissions}" + return "" + + def _is_allowed_permission_root(self, raw_root: str) -> bool: + try: + root = Path(raw_root).expanduser().resolve() + except Exception: + return False + + allowed_roots = [Path(self._cwd).expanduser()] + allowed_roots.extend(_codex_configured_writable_roots()) + for allowed in allowed_roots: + try: + allowed_resolved = allowed.resolve() + except Exception: + continue + if root == allowed_resolved or allowed_resolved in root.parents: + return True + return False + def _track_pending_file_change(self, note: dict) -> None: """Maintain self._pending_file_changes from item/started + item/completed notifications. Lets the apply_patch approval prompt show what's diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8a1e4aca2e1db..8b9bfa2407de1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -314,10 +314,15 @@ async def auth_middleware(request: Request, call_next): "description": "Memory provider plugin", "options": ["builtin", "honcho"], }, + "model.openai_runtime": { + "type": "select", + "description": "OpenAI/Codex runtime path", + "options": ["auto", "codex_app_server"], + }, "approvals.mode": { "type": "select", "description": "Dangerous command approval mode", - "options": ["ask", "yolo", "deny"], + "options": ["manual", "smart", "off"], }, "context.engine": { "type": "select", diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 575250d9b0183..8459460c98223 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -225,20 +225,34 @@ Codex requests approval before executing commands or applying patches. These get For `apply_patch` (file edit) approvals, Hermes shows a summary of what changed (`1 add, 1 update: /tmp/new.py, /tmp/old.py`) when codex provides the data via the corresponding `fileChange` item. -## Permission profiles +## Sandbox and writable roots -Codex has three built-in permission profiles: -- `:read-only` — no writes; every shell command requires approval -- `:workspace` — writes within the current workspace allowed without prompts (Hermes' default when you enable the runtime) -- `:danger-no-sandbox` — no sandbox at all (don't use this unless you understand it) +Codex owns the filesystem sandbox for the `codex_app_server` runtime. Hermes forwards Codex's command/file-change approvals, but the sandbox still decides which paths can be written. -You can override the default in `~/.codex/config.toml` outside Hermes' managed block: +For normal coding work, configure Codex like this in `~/.codex/config.toml` outside Hermes' managed block: ```toml -default_permissions = ":read-only" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "/Users/you/src/primary-repo", + "/Users/you/src/related-repo", +] +network_access = true ``` -(Hermes will preserve your override on re-migration as long as it lives outside the `# managed by hermes-agent` markers.) +Why this matters: + +- `apply_patch` needs file-write permission for the target repo. +- Vite may write a timestamped config bundle next to `vite.config.ts`. +- Playwright writes `test-results/` and `playwright-report/`. +- Cross-repo work, such as editing `../related-repo`, needs that sibling repo in `writable_roots` or Codex will correctly deny it. + +Hermes now handles Codex `item/permissions/requestApproval` requests by asking the user when an approval callback is available. In non-interactive contexts, Hermes only auto-accepts permission grants rooted in the current session cwd or in `sandbox_workspace_write.writable_roots`; unknown roots still fail closed. + +If you want maximum prompting instead, use `sandbox_mode = "read-only"`. If you want no sandbox at all, Codex also supports `danger-full-access` / bypass flags, but do not use them unless the whole environment is externally sandboxed. ## Auxiliary tasks and ChatGPT subscription token cost