yitongl commited on
Commit
b89b0b0
·
1 Parent(s): 002fc77

Add interactive Codex tmux auto-approval launcher

Browse files
Files changed (4) hide show
  1. .gitignore +7 -0
  2. README.md +159 -0
  3. codex_auto_run.py +1389 -0
  4. test_codex_auto_run.py +551 -0
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .coverage
5
+ htmlcov/
6
+ .venv/
7
+ venv/
README.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # codex_exec
2
+
3
+ `codex_exec` is an **unofficial** launcher for the interactive OpenAI Codex TUI. It starts a real interactive `codex` session inside `tmux` and runs a small watcher that can confirm recognized approval dialogs automatically.
4
+
5
+ Despite the repository name, this is **not** `codex exec` and it does not turn Codex into a non-interactive subprocess. You can attach to the TUI, type follow-up messages, detach, reattach, and resume persisted Codex conversations normally.
6
+
7
+ The runtime is one self-contained Python script with no third-party Python dependencies.
8
+
9
+ ## Requirements
10
+
11
+ - Linux. The watcher uses `fcntl` and `/proc` for locking and process identity checks.
12
+ - Python 3.10 or newer.
13
+ - `tmux` available on `PATH`.
14
+ - The Codex CLI installed, authenticated, and available as `codex` on `PATH` (or supplied with `--codex-binary`).
15
+
16
+ The current implementation and tests were validated with Codex CLI 0.142.3. The watcher recognizes text rendered by the Codex TUI, so a future Codex release that changes approval wording or layout may require detector updates.
17
+
18
+ ## Important safety warning
19
+
20
+ By default, Codex still starts with `on-request` approvals and the `workspace-write` sandbox. However, the watcher automatically confirms recognized one-shot approval choices for:
21
+
22
+ - shell commands;
23
+ - file edits;
24
+ - permission requests; and
25
+ - network access requests.
26
+
27
+ This removes the human review normally provided by an approval prompt. A mistaken or prompt-injected Codex action may delete files, run untrusted commands, disclose accessible data over the network, or request broader permissions. The watcher is a convenience mechanism, **not a security boundary**. Use it only in a workspace whose contents and consequences you understand, keep important work under version control, and prefer a disposable or externally isolated environment for risky tasks.
28
+
29
+ The watcher does not automatically answer ordinary questions, enable a full-access screen, trust hooks, install plugins, approve MCP/app calls, or trust a directory unless the relevant explicit option is enabled.
30
+
31
+ `--approve-mcp` can approve app or MCP calls with external side effects. `--auto-trust-directory` bypasses Codex's initial directory-trust confirmation. `--bypass` is substantially more dangerous: it passes Codex's `--dangerously-bypass-approvals-and-sandbox` option and should only be used inside an environment that is already isolated outside Codex.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ git clone https://huggingface.co/yitongl/codex_exec
37
+ cd codex_exec
38
+ chmod +x codex_auto_run.py
39
+ ```
40
+
41
+ ## Start a new session
42
+
43
+ ```bash
44
+ ./codex_auto_run.py \
45
+ -C ~/code/my_project \
46
+ -p "Implement the requested change, run the tests, and summarize the result."
47
+ ```
48
+
49
+ The launcher creates a uniquely named `tmux` session and normally attaches to it immediately. The printed startup information includes the exact command needed to reattach.
50
+
51
+ Use a UTF-8 prompt file when the task is long:
52
+
53
+ ```bash
54
+ ./codex_auto_run.py -C ~/code/my_project --prompt-file task.md
55
+ ```
56
+
57
+ ## Detach and reattach
58
+
59
+ Start without attaching:
60
+
61
+ ```bash
62
+ ./codex_auto_run.py \
63
+ --detach \
64
+ -C ~/code/my_project \
65
+ -p "Run the full task and verify the result."
66
+ ```
67
+
68
+ Then attach with the session name printed by the launcher:
69
+
70
+ ```bash
71
+ tmux attach -t codex-auto-YYYYMMDD-HHMMSS-PID-RANDOM
72
+ ```
73
+
74
+ Inside `tmux`, press `Ctrl-b d` to detach without stopping Codex. Exiting Codex normally ends the `tmux` session unless `--keep-dead-session` was used. When no managed panes remain, the watcher exits after its idle timeout (120 seconds by default).
75
+
76
+ ## Resume a Codex conversation
77
+
78
+ Resume the most recent interactive conversation for a working directory:
79
+
80
+ ```bash
81
+ ./codex_auto_run.py \
82
+ --resume-last \
83
+ -C ~/code/my_project \
84
+ -p "Continue the task and rerun the verification."
85
+ ```
86
+
87
+ Resume a specific Codex session ID or name:
88
+
89
+ ```bash
90
+ ./codex_auto_run.py \
91
+ --resume SESSION_ID \
92
+ -C ~/code/my_project \
93
+ -p "Continue from the previous result."
94
+ ```
95
+
96
+ Open Codex's interactive session picker:
97
+
98
+ ```bash
99
+ ./codex_auto_run.py --resume -C ~/code/my_project
100
+ ```
101
+
102
+ The picker form cannot be combined with `-p` or `--prompt-file`; select the conversation first and then type in Codex. Resuming starts a new `tmux` wrapper around a persisted Codex conversation. If the original `tmux` session is still running, simply reattach to that session instead.
103
+
104
+ ## Pass options to Codex
105
+
106
+ Place Codex-specific global options after `--`:
107
+
108
+ ```bash
109
+ ./codex_auto_run.py \
110
+ -C ~/code/my_project \
111
+ -p "Research and implement the task." \
112
+ -- \
113
+ --search \
114
+ --model MODEL_NAME
115
+ ```
116
+
117
+ The wrapper reserves `-p` for the initial or resumed-session follow-up prompt. Use `--codex-profile PROFILE_NAME` for a Codex profile.
118
+
119
+ No model is hardcoded by this repository. Without `--model`, Codex inherits the model selected by the user's Codex configuration and CLI defaults.
120
+
121
+ Some Codex options that conflict with wrapper-managed behavior, including Codex's own approval, sandbox, working-directory, full-auto, and bypass flags, are rejected. Use the wrapper's `--sandbox`, `-C`, and `--bypass` options instead.
122
+
123
+ ## Watcher controls
124
+
125
+ Show the watcher and managed-session status:
126
+
127
+ ```bash
128
+ ./codex_auto_run.py --status
129
+ ```
130
+
131
+ Stop the watcher:
132
+
133
+ ```bash
134
+ ./codex_auto_run.py --stop-daemon
135
+ ```
136
+
137
+ Start only the watcher:
138
+
139
+ ```bash
140
+ ./codex_auto_run.py --start-daemon
141
+ ```
142
+
143
+ By default, private watcher state and logs are stored under `~/.runtime/codex-auto/`. Only sessions created and registered by this wrapper are watched.
144
+
145
+ Run `./codex_auto_run.py --help` for all tuning and safety options.
146
+
147
+ ## Tests
148
+
149
+ The unit tests exercise approval-screen detection, argument validation, resume command construction, binary probing, `tmux` command construction, and daemon lifecycle safeguards without starting a real Codex session:
150
+
151
+ ```bash
152
+ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v test_codex_auto_run.py
153
+ ```
154
+
155
+ A passing unit-test suite cannot guarantee compatibility with a future Codex TUI. Before relying on unattended operation after a Codex upgrade, observe a real low-risk session and verify that approvals are detected as intended.
156
+
157
+ ## Project status
158
+
159
+ This project is independent and unofficial. It is not an OpenAI product and is not endorsed or supported by OpenAI.
codex_auto_run.py ADDED
@@ -0,0 +1,1389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Launch an interactive Codex TUI in tmux and approve its requests.
3
+
4
+ This is deliberately an interactive Codex session, not ``codex exec``. A
5
+ small daemon watches only tmux sessions created with the configured prefix and
6
+ presses the first, one-shot approval choice when a known Codex approval overlay
7
+ has been stable for multiple polls.
8
+
9
+ Typical use::
10
+
11
+ python3 codex_auto_run.py -p "implement the task and run the tests"
12
+ python3 codex_auto_run.py -p "fix it" -- --model gpt-5.4
13
+ python3 codex_auto_run.py --resume-last -C ~/code
14
+ python3 codex_auto_run.py --resume SESSION_ID -p "continue the task"
15
+ python3 codex_auto_run.py --resume -C ~/code
16
+ python3 codex_auto_run.py --status
17
+ python3 codex_auto_run.py --stop-daemon
18
+
19
+ The wrapper owns ``-p``/``--prompt``. Codex itself uses ``-p`` for profiles;
20
+ use ``--codex-profile NAME`` or place Codex options after ``--``.
21
+
22
+ Security model
23
+ --------------
24
+ By default the watcher approves Codex command, edit, permission, and network
25
+ approval overlays. It does not answer ordinary questions, enable full access,
26
+ trust hooks, install plugins, or approve MCP/app tool calls. Use
27
+ ``--approve-mcp`` and ``--auto-trust-directory`` only when those broader side
28
+ effects are intended. ``--bypass`` maps to Codex's dangerous no-sandbox mode
29
+ and should only be used inside an externally isolated container or VM.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import fcntl
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import re
40
+ import secrets
41
+ import shlex
42
+ import shutil
43
+ import signal
44
+ import subprocess
45
+ import sys
46
+ import time
47
+ from dataclasses import dataclass
48
+ from datetime import datetime
49
+ from pathlib import Path
50
+ from typing import TextIO
51
+
52
+
53
+ SCRIPT_VERSION = 1
54
+ DEFAULT_SESSION_PREFIX = "codex-auto"
55
+ DEFAULT_POLL_INTERVAL = 0.35
56
+ DEFAULT_COOLDOWN = 0.8
57
+ DEFAULT_STABILITY_POLLS = 2
58
+ DEFAULT_REARM_INTERVAL = 2.0
59
+ DEFAULT_IDLE_EXIT_SECONDS = 120.0
60
+ MAX_LOG_BYTES = 2 * 1024 * 1024
61
+ LOG_BACKUPS = 3
62
+ MAX_OVERLAY_ROWS = 80
63
+
64
+ ANSI_ESCAPE_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
65
+ SELECTED_FIRST_RE = re.compile(r"^[›>]\s*1\.\s+(?P<label>.+)$", re.IGNORECASE)
66
+ STANDARD_FOOTER_RE = re.compile(
67
+ r"^press\s+enter\s+to\s+confirm\s+or\s+.+\s+to\s+cancel(?:\s+or\s+.+)?$",
68
+ re.IGNORECASE,
69
+ )
70
+ MCP_FORM_FOOTER_RE = re.compile(r"^enter to submit(?: all)?\s*\|\s*esc to cancel$", re.IGNORECASE)
71
+ TRUST_FOOTER_RE = re.compile(r"^press enter to continue.*$", re.IGNORECASE)
72
+ NETWORK_TITLE_RE = re.compile(
73
+ r'^do you want to approve network access to ".+"\?$',
74
+ re.IGNORECASE,
75
+ )
76
+ MCP_TITLE_RE = re.compile(r"^.+ needs your approval\.$", re.IGNORECASE)
77
+
78
+ STANDARD_TITLES: dict[str, str] = {
79
+ "Would you like to run the following command?": "command",
80
+ "Would you like to make the following edits?": "edit",
81
+ "Would you like to grant these permissions?": "permissions",
82
+ }
83
+
84
+ POSITIVE_LABELS: dict[str, tuple[str, ...]] = {
85
+ "command": ("yes, proceed",),
86
+ "edit": ("yes, proceed",),
87
+ "permissions": ("yes, grant these permissions for this turn",),
88
+ "network": ("yes, just this once",),
89
+ "mcp": ("yes, provide the requested info",),
90
+ }
91
+
92
+ NEGATIVE_SIGNALS: dict[str, tuple[str, ...]] = {
93
+ "command": (
94
+ "no, continue without running it",
95
+ "no, and tell codex what to do differently",
96
+ ),
97
+ "edit": ("no, and tell codex what to do differently",),
98
+ "permissions": ("no, continue without permissions",),
99
+ "network": (
100
+ "no, continue without running it",
101
+ "no, and tell codex what to do differently",
102
+ "no, and block this host in the future",
103
+ ),
104
+ "mcp": ("no, but continue without it", "cancel this request"),
105
+ }
106
+
107
+ BLOCKED_CODEX_ARGS = {
108
+ "-C",
109
+ "--cd",
110
+ "-a",
111
+ "--ask-for-approval",
112
+ "-s",
113
+ "--sandbox",
114
+ "--dangerously-bypass-approvals-and-sandbox",
115
+ "--yolo",
116
+ "--full-auto",
117
+ "--no-alt-screen",
118
+ }
119
+
120
+ CODEX_VALUE_OPTIONS = {
121
+ "-c",
122
+ "--config",
123
+ "--enable",
124
+ "--disable",
125
+ "--remote",
126
+ "--remote-auth-token-env",
127
+ "-i",
128
+ "--image",
129
+ "-m",
130
+ "--model",
131
+ "--local-provider",
132
+ "-p",
133
+ "--profile",
134
+ "--add-dir",
135
+ }
136
+
137
+
138
+ def normalize_line(line: str) -> str:
139
+ """Normalize a captured terminal row without joining physical rows."""
140
+
141
+ line = ANSI_ESCAPE_RE.sub("", line).replace("\xa0", " ")
142
+ line = "".join(ch for ch in line if ch == "\t" or ord(ch) >= 32)
143
+ return re.sub(r"\s+", " ", line).strip()
144
+
145
+
146
+ def build_runtime_dir(session_prefix: str, explicit: str | None) -> Path:
147
+ if explicit:
148
+ return Path(explicit).expanduser().resolve()
149
+ return (Path.home() / ".runtime" / session_prefix).resolve()
150
+
151
+
152
+ def ensure_private_dir(path: Path) -> None:
153
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
154
+ try:
155
+ path.chmod(0o700)
156
+ except OSError:
157
+ pass
158
+
159
+
160
+ def register_session(config: Config, session_name: str) -> None:
161
+ """Record that a tmux session was created by this wrapper."""
162
+
163
+ ensure_private_dir(config.sessions_dir)
164
+ marker = config.sessions_dir / session_name
165
+ fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
166
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
167
+ handle.write(f"pid={os.getpid()}\n")
168
+
169
+
170
+ def unregister_session(config: Config, session_name: str) -> None:
171
+ try:
172
+ (config.sessions_dir / session_name).unlink()
173
+ except OSError:
174
+ pass
175
+
176
+
177
+ def registered_sessions(config: Config) -> set[str]:
178
+ try:
179
+ return {
180
+ marker.name
181
+ for marker in config.sessions_dir.iterdir()
182
+ if marker.is_file()
183
+ }
184
+ except OSError:
185
+ return set()
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Config:
190
+ session_prefix: str
191
+ runtime_dir: Path
192
+ poll_interval: float
193
+ cooldown: float
194
+ stability_polls: int
195
+ rearm_interval: float
196
+ idle_exit_seconds: float
197
+ approve_mcp: bool
198
+ auto_trust_directory: bool
199
+ keep_dead_session: bool
200
+
201
+ @property
202
+ def log_path(self) -> Path:
203
+ return self.runtime_dir / "approve-debug.log"
204
+
205
+ @property
206
+ def pid_path(self) -> Path:
207
+ return self.runtime_dir / "approver-daemon.json"
208
+
209
+ @property
210
+ def lock_path(self) -> Path:
211
+ return self.runtime_dir / "approver-daemon.lock"
212
+
213
+ @property
214
+ def sessions_dir(self) -> Path:
215
+ return self.runtime_dir / "sessions"
216
+
217
+ def daemon_fingerprint(self) -> str:
218
+ relevant = {
219
+ "version": SCRIPT_VERSION,
220
+ "session_prefix": self.session_prefix,
221
+ "poll_interval": self.poll_interval,
222
+ "cooldown": self.cooldown,
223
+ "stability_polls": self.stability_polls,
224
+ "rearm_interval": self.rearm_interval,
225
+ "idle_exit_seconds": self.idle_exit_seconds,
226
+ "approve_mcp": self.approve_mcp,
227
+ "auto_trust_directory": self.auto_trust_directory,
228
+ }
229
+ encoded = json.dumps(relevant, sort_keys=True, separators=(",", ":")).encode()
230
+ return hashlib.sha256(encoded).hexdigest()[:16]
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class PaneInfo:
235
+ session_name: str
236
+ pane_id: str
237
+ pane_dead: bool
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class PromptCandidate:
242
+ kind: str
243
+ signature: str
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class DaemonRecord:
248
+ pid: int
249
+ token: str
250
+ script: str
251
+ fingerprint: str
252
+
253
+
254
+ class SessionState:
255
+ def __init__(self) -> None:
256
+ self.pending_signature: str | None = None
257
+ self.pending_count = 0
258
+ self.active_signature: str | None = None
259
+ self.last_action_at = 0.0
260
+
261
+ def clear_candidate(self) -> bool:
262
+ had_active = self.active_signature is not None
263
+ self.pending_signature = None
264
+ self.pending_count = 0
265
+ self.active_signature = None
266
+ return had_active
267
+
268
+ def ready(self, candidate: PromptCandidate, now: float, config: Config) -> bool:
269
+ if (
270
+ self.active_signature == candidate.signature
271
+ and now - self.last_action_at < config.rearm_interval
272
+ ):
273
+ self.pending_signature = None
274
+ self.pending_count = 0
275
+ return False
276
+
277
+ if now - self.last_action_at < config.cooldown:
278
+ return False
279
+
280
+ if self.pending_signature == candidate.signature:
281
+ self.pending_count += 1
282
+ else:
283
+ self.pending_signature = candidate.signature
284
+ self.pending_count = 1
285
+
286
+ return self.pending_count >= config.stability_polls
287
+
288
+ def mark_approved(self, candidate: PromptCandidate, now: float) -> None:
289
+ self.active_signature = candidate.signature
290
+ self.last_action_at = now
291
+ self.pending_signature = None
292
+ self.pending_count = 0
293
+
294
+
295
+ class SecureLogger:
296
+ def __init__(self, config: Config):
297
+ self.config = config
298
+
299
+ def _rotate_if_needed(self) -> None:
300
+ path = self.config.log_path
301
+ try:
302
+ if path.stat().st_size < MAX_LOG_BYTES:
303
+ return
304
+ except FileNotFoundError:
305
+ return
306
+ except OSError:
307
+ return
308
+
309
+ try:
310
+ oldest = path.with_name(f"{path.name}.{LOG_BACKUPS}")
311
+ if oldest.exists():
312
+ oldest.unlink()
313
+ for idx in range(LOG_BACKUPS - 1, 0, -1):
314
+ source = path.with_name(f"{path.name}.{idx}")
315
+ target = path.with_name(f"{path.name}.{idx + 1}")
316
+ if source.exists():
317
+ source.replace(target)
318
+ path.replace(path.with_name(f"{path.name}.1"))
319
+ except OSError:
320
+ pass
321
+
322
+ def write(self, message: str) -> None:
323
+ try:
324
+ ensure_private_dir(self.config.runtime_dir)
325
+ self._rotate_if_needed()
326
+ fd = os.open(
327
+ self.config.log_path,
328
+ os.O_WRONLY | os.O_CREAT | os.O_APPEND,
329
+ 0o600,
330
+ )
331
+ with os.fdopen(fd, "a", encoding="utf-8") as handle:
332
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
333
+ handle.write(f"[{ts}] {message}\n")
334
+ except OSError:
335
+ pass
336
+
337
+
338
+ class TmuxClient:
339
+ def __init__(self, config: Config):
340
+ self.config = config
341
+ self.logger = SecureLogger(config)
342
+
343
+ def log(self, message: str) -> None:
344
+ self.logger.write(message)
345
+
346
+ def run(self, *args: str, timeout: float = 5.0) -> tuple[int, str, str]:
347
+ try:
348
+ result = subprocess.run(
349
+ ["tmux", *args],
350
+ capture_output=True,
351
+ text=True,
352
+ timeout=timeout,
353
+ )
354
+ return result.returncode, result.stdout, result.stderr
355
+ except subprocess.TimeoutExpired:
356
+ return -1, "", "tmux command timed out"
357
+ except OSError as exc:
358
+ return -1, "", str(exc)
359
+
360
+ def list_panes(self) -> list[PaneInfo]:
361
+ rc, output, _ = self.run(
362
+ "list-panes",
363
+ "-a",
364
+ "-F",
365
+ "#{session_name}\t#{pane_id}\t#{pane_dead}",
366
+ )
367
+ if rc != 0:
368
+ return []
369
+
370
+ prefix = f"{self.config.session_prefix}-"
371
+ allowed_sessions = registered_sessions(self.config)
372
+ seen_prefixed_sessions: set[str] = set()
373
+ panes: list[PaneInfo] = []
374
+ for raw in output.splitlines():
375
+ parts = raw.split("\t")
376
+ if len(parts) != 3 or not parts[0].startswith(prefix):
377
+ continue
378
+ seen_prefixed_sessions.add(parts[0])
379
+ if parts[0] not in allowed_sessions:
380
+ continue
381
+ panes.append(
382
+ PaneInfo(
383
+ session_name=parts[0],
384
+ pane_id=parts[1],
385
+ pane_dead=parts[2].strip() == "1",
386
+ )
387
+ )
388
+
389
+ for stale_session in allowed_sessions - seen_prefixed_sessions:
390
+ unregister_session(self.config, stale_session)
391
+ return panes
392
+
393
+ def capture_pane(self, pane_id: str) -> list[str]:
394
+ # Do not use -J: joining soft-wrapped rows makes screen-state matching
395
+ # less deterministic and breaks any relationship to physical rows.
396
+ rc, output, _ = self.run("capture-pane", "-t", pane_id, "-p")
397
+ return output.splitlines() if rc == 0 else []
398
+
399
+ def confirm_selected_choice(self, pane_id: str) -> bool:
400
+ # Detection requires Codex's own "Press enter to confirm" footer and
401
+ # verifies that row 1 is selected. Enter therefore follows the rendered
402
+ # keymap and cannot choose a persistent "don't ask again" row.
403
+ rc, _, _ = self.run("send-keys", "-t", pane_id, "Enter")
404
+ return rc == 0
405
+
406
+ def session_exists(self, session_name: str) -> bool:
407
+ rc, _, _ = self.run("has-session", "-t", f"={session_name}")
408
+ return rc == 0
409
+
410
+ def new_session(
411
+ self,
412
+ session_name: str,
413
+ argv: list[str],
414
+ cwd: Path,
415
+ cols: int,
416
+ rows: int,
417
+ ) -> tuple[bool, str]:
418
+ rc, _, error = self.run(
419
+ "new-session",
420
+ "-d",
421
+ "-s",
422
+ session_name,
423
+ "-c",
424
+ str(cwd),
425
+ "-x",
426
+ str(cols),
427
+ "-y",
428
+ str(rows),
429
+ *argv,
430
+ timeout=15.0,
431
+ )
432
+ if rc != 0:
433
+ return False, error.strip()
434
+ self.run(
435
+ "set-window-option",
436
+ "-t",
437
+ f"={session_name}:",
438
+ "remain-on-exit",
439
+ "on" if self.config.keep_dead_session else "off",
440
+ )
441
+ return True, ""
442
+
443
+ def kill_session(self, session_name: str) -> None:
444
+ self.run("kill-session", "-t", f"={session_name}")
445
+
446
+ def attach_or_switch(self, session_name: str) -> int:
447
+ if os.environ.get("TMUX"):
448
+ return subprocess.run(
449
+ ["tmux", "switch-client", "-t", f"={session_name}"]
450
+ ).returncode
451
+ return subprocess.run(
452
+ ["tmux", "attach-session", "-t", f"={session_name}"]
453
+ ).returncode
454
+
455
+
456
+ def _last_nonempty_row(lines: list[str]) -> int | None:
457
+ for idx in range(len(lines) - 1, -1, -1):
458
+ if lines[idx]:
459
+ return idx
460
+ return None
461
+
462
+
463
+ def _last_matching_row(
464
+ lines: list[str],
465
+ predicate,
466
+ *,
467
+ before: int | None = None,
468
+ ) -> int | None:
469
+ end = len(lines) - 1 if before is None else min(before - 1, len(lines) - 1)
470
+ for idx in range(end, -1, -1):
471
+ if predicate(lines[idx]):
472
+ return idx
473
+ return None
474
+
475
+
476
+ def _matching_tail_span(
477
+ lines: list[str],
478
+ regex: re.Pattern[str],
479
+ *,
480
+ max_rows: int = 3,
481
+ ) -> tuple[int, int] | None:
482
+ """Match a footer at the screen tail, including physical line wraps."""
483
+
484
+ end = _last_nonempty_row(lines)
485
+ if end is None:
486
+ return None
487
+ lower = max(0, end - max_rows + 1)
488
+ for start in range(end, lower - 1, -1):
489
+ logical = " ".join(line for line in lines[start : end + 1] if line)
490
+ if regex.match(logical):
491
+ return start, end
492
+ return None
493
+
494
+
495
+ def _classify_standard_title(text: str, approve_mcp: bool) -> str | None:
496
+ folded = text.casefold()
497
+ for title, kind in STANDARD_TITLES.items():
498
+ if folded == title.casefold():
499
+ return kind
500
+ if NETWORK_TITLE_RE.match(text):
501
+ return "network"
502
+ if approve_mcp and MCP_TITLE_RE.match(text):
503
+ return "mcp"
504
+ return None
505
+
506
+
507
+ def _find_standard_title_span(
508
+ lines: list[str],
509
+ *,
510
+ before: int,
511
+ approve_mcp: bool,
512
+ ) -> tuple[int, int, str] | None:
513
+ """Find the nearest known title, tolerating up to three wrapped rows."""
514
+
515
+ lower = max(0, before - MAX_OVERLAY_ROWS)
516
+ for end in range(before - 1, lower - 1, -1):
517
+ for start in range(end, max(lower, end - 2) - 1, -1):
518
+ logical = " ".join(line for line in lines[start : end + 1] if line)
519
+ kind = _classify_standard_title(logical, approve_mcp)
520
+ if kind is not None:
521
+ return start, end, kind
522
+ return None
523
+
524
+
525
+ def _selected_first_row(lines: list[str], before: int) -> tuple[int, str] | None:
526
+ for idx in range(before - 1, -1, -1):
527
+ match = SELECTED_FIRST_RE.match(lines[idx])
528
+ if match:
529
+ return idx, match.group("label")
530
+ return None
531
+
532
+
533
+ def _contains_any(lines: list[str], values: tuple[str, ...]) -> bool:
534
+ # Joining with spaces lets fixed phrases survive terminal soft wrapping.
535
+ joined = " ".join(lines).casefold()
536
+ return any(value in joined for value in values)
537
+
538
+
539
+ def _candidate_signature(kind: str, overlay: list[str]) -> str:
540
+ material = "\n".join(line for line in overlay if line).encode("utf-8", "replace")
541
+ digest = hashlib.sha256(material).hexdigest()[:20]
542
+ return f"{kind}:{digest}"
543
+
544
+
545
+ def detect_standard_approval(lines: list[str], approve_mcp: bool) -> PromptCandidate | None:
546
+ footer = _matching_tail_span(lines, STANDARD_FOOTER_RE)
547
+ if footer is None:
548
+ return None
549
+ footer_start, footer_end = footer
550
+
551
+ selected = _selected_first_row(lines, footer_start)
552
+ if selected is None:
553
+ return None
554
+ selected_row, selected_label = selected
555
+
556
+ title = _find_standard_title_span(
557
+ lines,
558
+ before=selected_row,
559
+ approve_mcp=approve_mcp,
560
+ )
561
+ if title is None:
562
+ return None
563
+ title_start, _, kind = title
564
+
565
+ label = selected_label.casefold()
566
+ if not any(label.startswith(prefix) for prefix in POSITIVE_LABELS[kind]):
567
+ return None
568
+
569
+ option_block = lines[selected_row + 1 : footer_start]
570
+ if not _contains_any(option_block, NEGATIVE_SIGNALS[kind]):
571
+ return None
572
+
573
+ body = lines[title_start:selected_row]
574
+ if kind == "command" and not any(line.startswith("$ ") for line in body):
575
+ return None
576
+ if kind == "permissions" and not any(
577
+ line.casefold().startswith("permission rule:") for line in body
578
+ ):
579
+ return None
580
+
581
+ overlay = lines[title_start : footer_end + 1]
582
+ return PromptCandidate(kind=kind, signature=_candidate_signature(kind, overlay))
583
+
584
+
585
+ def detect_mcp_tool_approval(lines: list[str]) -> PromptCandidate | None:
586
+ footer = _matching_tail_span(lines, MCP_FORM_FOOTER_RE)
587
+ if footer is None:
588
+ return None
589
+ footer_start, footer_end = footer
590
+
591
+ selected = _selected_first_row(lines, footer_start)
592
+ if selected is None:
593
+ return None
594
+ selected_row, selected_label = selected
595
+ if not selected_label.casefold().startswith("allow"):
596
+ return None
597
+
598
+ lower = max(0, selected_row - MAX_OVERLAY_ROWS)
599
+ field_row = _last_matching_row(
600
+ lines,
601
+ lambda line: bool(re.match(r"^field 1/1$", line, re.IGNORECASE)),
602
+ before=selected_row,
603
+ )
604
+ if field_row is None or field_row < lower:
605
+ return None
606
+ overlay = lines[field_row : footer_end + 1]
607
+ if not _contains_any(overlay, ("run the tool and continue",)):
608
+ return None
609
+ if not _contains_any(overlay, ("cancel this tool call",)):
610
+ return None
611
+
612
+ return PromptCandidate(kind="mcp_tool", signature=_candidate_signature("mcp_tool", overlay))
613
+
614
+
615
+ def detect_trust_directory(lines: list[str]) -> PromptCandidate | None:
616
+ footer = _matching_tail_span(lines, TRUST_FOOTER_RE)
617
+ if footer is None:
618
+ return None
619
+ footer_start, footer_end = footer
620
+
621
+ selected = _selected_first_row(lines, footer_start)
622
+ if selected is None:
623
+ return None
624
+ selected_row, label = selected
625
+ if not label.casefold().startswith("yes, continue"):
626
+ return None
627
+
628
+ overlay = lines[max(0, selected_row - 20) : footer_end + 1]
629
+ if not _contains_any(overlay, ("do you trust the contents of this directory?",)):
630
+ return None
631
+ if not _contains_any(overlay, ("no, quit",)):
632
+ return None
633
+ return PromptCandidate(kind="trust_directory", signature=_candidate_signature("trust", overlay))
634
+
635
+
636
+ def detect_candidate(raw_lines: list[str], config: Config) -> PromptCandidate | None:
637
+ lines = [normalize_line(line) for line in raw_lines]
638
+ candidate = detect_standard_approval(lines, config.approve_mcp)
639
+ if candidate is not None:
640
+ return candidate
641
+ if config.approve_mcp:
642
+ candidate = detect_mcp_tool_approval(lines)
643
+ if candidate is not None:
644
+ return candidate
645
+ if config.auto_trust_directory:
646
+ return detect_trust_directory(lines)
647
+ return None
648
+
649
+
650
+ def _write_daemon_record(config: Config, record: DaemonRecord) -> None:
651
+ ensure_private_dir(config.runtime_dir)
652
+ temporary = config.pid_path.with_name(f".{config.pid_path.name}.{os.getpid()}.tmp")
653
+ data = {
654
+ "pid": record.pid,
655
+ "token": record.token,
656
+ "script": record.script,
657
+ "fingerprint": record.fingerprint,
658
+ }
659
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
660
+ try:
661
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
662
+ json.dump(data, handle, sort_keys=True)
663
+ handle.write("\n")
664
+ temporary.replace(config.pid_path)
665
+ config.pid_path.chmod(0o600)
666
+ finally:
667
+ try:
668
+ temporary.unlink()
669
+ except FileNotFoundError:
670
+ pass
671
+
672
+
673
+ def _read_daemon_record(config: Config) -> DaemonRecord | None:
674
+ try:
675
+ data = json.loads(config.pid_path.read_text(encoding="utf-8"))
676
+ return DaemonRecord(
677
+ pid=int(data["pid"]),
678
+ token=str(data["token"]),
679
+ script=str(data["script"]),
680
+ fingerprint=str(data["fingerprint"]),
681
+ )
682
+ except (FileNotFoundError, OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
683
+ return None
684
+
685
+
686
+ def _process_matches(record: DaemonRecord) -> bool:
687
+ try:
688
+ os.kill(record.pid, 0)
689
+ cmdline = Path(f"/proc/{record.pid}/cmdline").read_bytes().split(b"\0")
690
+ except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
691
+ return False
692
+
693
+ decoded = [part.decode("utf-8", "replace") for part in cmdline if part]
694
+ return (
695
+ record.token in decoded
696
+ and "--daemon" in decoded
697
+ and str(Path(record.script).resolve())
698
+ in {
699
+ str(Path(part).resolve())
700
+ for part in decoded
701
+ if part.startswith("/")
702
+ }
703
+ )
704
+
705
+
706
+ def daemon_status(config: Config) -> tuple[bool, DaemonRecord | None, str | None]:
707
+ record = _read_daemon_record(config)
708
+ if record is None or not _process_matches(record):
709
+ return False, record, None
710
+ mismatch = None
711
+ if record.fingerprint != config.daemon_fingerprint():
712
+ mismatch = (
713
+ f"running daemon settings are {record.fingerprint}, requested settings are "
714
+ f"{config.daemon_fingerprint()}"
715
+ )
716
+ return True, record, mismatch
717
+
718
+
719
+ def _acquire_daemon_lock(config: Config) -> TextIO | None:
720
+ ensure_private_dir(config.runtime_dir)
721
+ handle = config.lock_path.open("a+", encoding="utf-8")
722
+ try:
723
+ config.lock_path.chmod(0o600)
724
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
725
+ except (BlockingIOError, OSError):
726
+ handle.close()
727
+ return None
728
+ return handle
729
+
730
+
731
+ def daemon_loop(config: Config, token: str) -> int:
732
+ lock_handle = _acquire_daemon_lock(config)
733
+ if lock_handle is None:
734
+ return 0
735
+
736
+ tmux = TmuxClient(config)
737
+ record = DaemonRecord(
738
+ pid=os.getpid(),
739
+ token=token,
740
+ script=str(Path(__file__).resolve()),
741
+ fingerprint=config.daemon_fingerprint(),
742
+ )
743
+ _write_daemon_record(config, record)
744
+ tmux.log(
745
+ f"daemon_start pid={record.pid} prefix={config.session_prefix} "
746
+ f"settings={record.fingerprint}"
747
+ )
748
+
749
+ running = True
750
+
751
+ def handle_signal(signum, _frame) -> None:
752
+ nonlocal running
753
+ tmux.log(f"daemon_signal signum={signum}")
754
+ running = False
755
+
756
+ signal.signal(signal.SIGTERM, handle_signal)
757
+ signal.signal(signal.SIGINT, handle_signal)
758
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
759
+
760
+ states: dict[str, SessionState] = {}
761
+ idle_started_at: float | None = None
762
+
763
+ try:
764
+ while running:
765
+ panes = tmux.list_panes()
766
+ live_panes = [pane for pane in panes if not pane.pane_dead]
767
+ live_session_names = {pane.session_name for pane in live_panes}
768
+ for pane in panes:
769
+ if pane.pane_dead and pane.session_name not in live_session_names:
770
+ unregister_session(config, pane.session_name)
771
+ now = time.monotonic()
772
+ if not live_panes:
773
+ if idle_started_at is None:
774
+ idle_started_at = now
775
+ elif now - idle_started_at >= config.idle_exit_seconds:
776
+ tmux.log("daemon_exit reason=no_matching_panes")
777
+ break
778
+ else:
779
+ idle_started_at = None
780
+
781
+ visible_ids = {pane.pane_id for pane in live_panes}
782
+ for pane_id in list(states):
783
+ if pane_id not in visible_ids:
784
+ states.pop(pane_id, None)
785
+ tmux.log(f"pane_removed pane={pane_id}")
786
+
787
+ for pane in live_panes:
788
+ state = states.setdefault(pane.pane_id, SessionState())
789
+ candidate = detect_candidate(tmux.capture_pane(pane.pane_id), config)
790
+ if candidate is None:
791
+ if state.clear_candidate():
792
+ tmux.log(f"clear_active pane={pane.pane_id}")
793
+ continue
794
+
795
+ previous_pending = state.pending_signature
796
+ if not state.ready(candidate, now, config):
797
+ if previous_pending != candidate.signature and state.pending_signature:
798
+ tmux.log(
799
+ f"pending pane={pane.pane_id} kind={candidate.kind} "
800
+ f"sig={candidate.signature}"
801
+ )
802
+ continue
803
+
804
+ tmux.log(
805
+ f"approve pane={pane.pane_id} kind={candidate.kind} "
806
+ f"sig={candidate.signature}"
807
+ )
808
+ if tmux.confirm_selected_choice(pane.pane_id):
809
+ state.mark_approved(candidate, time.monotonic())
810
+ else:
811
+ tmux.log(f"approve_failed pane={pane.pane_id} kind={candidate.kind}")
812
+ state.pending_signature = None
813
+ state.pending_count = 0
814
+
815
+ time.sleep(config.poll_interval)
816
+ finally:
817
+ current = _read_daemon_record(config)
818
+ if current == record:
819
+ try:
820
+ config.pid_path.unlink()
821
+ except OSError:
822
+ pass
823
+ tmux.log("daemon_stop")
824
+ try:
825
+ fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN)
826
+ except OSError:
827
+ pass
828
+ lock_handle.close()
829
+ return 0
830
+
831
+
832
+ def start_daemon(config: Config) -> tuple[bool, str | None]:
833
+ alive, _, mismatch = daemon_status(config)
834
+ if alive:
835
+ return mismatch is None, mismatch
836
+
837
+ ensure_private_dir(config.runtime_dir)
838
+ try:
839
+ config.pid_path.unlink()
840
+ except OSError:
841
+ pass
842
+
843
+ token = secrets.token_hex(16)
844
+ command = [
845
+ sys.executable,
846
+ str(Path(__file__).resolve()),
847
+ "--daemon",
848
+ "--daemon-token",
849
+ token,
850
+ "--session-prefix",
851
+ config.session_prefix,
852
+ "--runtime-dir",
853
+ str(config.runtime_dir),
854
+ "--poll-interval",
855
+ str(config.poll_interval),
856
+ "--cooldown",
857
+ str(config.cooldown),
858
+ "--stability-polls",
859
+ str(config.stability_polls),
860
+ "--rearm-interval",
861
+ str(config.rearm_interval),
862
+ "--idle-exit-seconds",
863
+ str(config.idle_exit_seconds),
864
+ ]
865
+ if config.approve_mcp:
866
+ command.append("--approve-mcp")
867
+ if config.auto_trust_directory:
868
+ command.append("--auto-trust-directory")
869
+
870
+ with open(os.devnull, "rb") as devnull_in, open(os.devnull, "ab") as devnull_out:
871
+ subprocess.Popen(
872
+ command,
873
+ stdin=devnull_in,
874
+ stdout=devnull_out,
875
+ stderr=devnull_out,
876
+ start_new_session=True,
877
+ close_fds=True,
878
+ )
879
+
880
+ deadline = time.monotonic() + 5.0
881
+ while time.monotonic() < deadline:
882
+ alive, _, mismatch = daemon_status(config)
883
+ if alive:
884
+ return mismatch is None, mismatch
885
+ time.sleep(0.1)
886
+ return False, "daemon did not become ready within 5 seconds"
887
+
888
+
889
+ def stop_daemon(config: Config) -> str:
890
+ alive, record, _ = daemon_status(config)
891
+ if not alive or record is None:
892
+ try:
893
+ config.pid_path.unlink()
894
+ except OSError:
895
+ pass
896
+ return "not_running"
897
+
898
+ try:
899
+ os.kill(record.pid, signal.SIGTERM)
900
+ except ProcessLookupError:
901
+ pass
902
+
903
+ deadline = time.monotonic() + 5.0
904
+ while time.monotonic() < deadline:
905
+ if not _process_matches(record):
906
+ break
907
+ time.sleep(0.1)
908
+ if _process_matches(record):
909
+ return "timeout"
910
+ try:
911
+ config.pid_path.unlink()
912
+ except OSError:
913
+ pass
914
+ return "stopped"
915
+
916
+
917
+ def list_matching_sessions(config: Config) -> list[str]:
918
+ return sorted(
919
+ {
920
+ pane.session_name
921
+ for pane in TmuxClient(config).list_panes()
922
+ if not pane.pane_dead
923
+ }
924
+ )
925
+
926
+
927
+ def _normalize_executable(path: Path) -> Path | None:
928
+ try:
929
+ resolved = path.expanduser().resolve(strict=True)
930
+ except (FileNotFoundError, OSError):
931
+ return None
932
+ if not resolved.is_file() or not os.access(resolved, os.X_OK):
933
+ return None
934
+ return resolved
935
+
936
+
937
+ def probe_codex_binary(path: Path, timeout: float = 5.0) -> bool:
938
+ candidate = _normalize_executable(path)
939
+ if candidate is None:
940
+ return False
941
+ try:
942
+ result = subprocess.run(
943
+ [str(candidate), "--version"],
944
+ capture_output=True,
945
+ text=True,
946
+ timeout=timeout,
947
+ )
948
+ except (OSError, subprocess.SubprocessError):
949
+ return False
950
+ output = f"{result.stdout}\n{result.stderr}".casefold()
951
+ return result.returncode == 0 and ("codex-cli" in output or "openai codex" in output)
952
+
953
+
954
+ def find_codex(explicit: str | None = None) -> str | None:
955
+ if explicit:
956
+ normalized = _normalize_executable(Path(explicit))
957
+ if normalized is not None and probe_codex_binary(normalized):
958
+ return str(normalized)
959
+ return None
960
+
961
+ raw_candidates: list[Path] = []
962
+ on_path = shutil.which("codex")
963
+ if on_path:
964
+ raw_candidates.append(Path(on_path))
965
+ raw_candidates.append(Path.home() / ".local" / "bin" / "codex")
966
+
967
+ seen: set[Path] = set()
968
+ for raw in raw_candidates:
969
+ normalized = _normalize_executable(raw)
970
+ if normalized is None or normalized in seen:
971
+ continue
972
+ seen.add(normalized)
973
+ if probe_codex_binary(normalized):
974
+ return str(normalized)
975
+ return None
976
+
977
+
978
+ def validate_codex_args(parser: argparse.ArgumentParser, codex_args: list[str]) -> None:
979
+ idx = 0
980
+ while idx < len(codex_args):
981
+ arg = codex_args[idx]
982
+ key = arg.split("=", 1)[0]
983
+ if key in BLOCKED_CODEX_ARGS:
984
+ parser.error(
985
+ f"Codex option {arg!r} conflicts with the wrapper; use the wrapper's "
986
+ "--cd/--sandbox/--bypass options instead"
987
+ )
988
+ if arg in {"-h", "--help", "-V", "--version"}:
989
+ parser.error(
990
+ f"Codex option {arg!r} exits instead of starting an interactive session"
991
+ )
992
+ if arg == "--" or not arg.startswith("-"):
993
+ parser.error(
994
+ f"unexpected positional Codex argument {arg!r}; "
995
+ "use the wrapper's -p/--prompt for the initial or follow-up prompt"
996
+ )
997
+
998
+ if key in CODEX_VALUE_OPTIONS and "=" not in arg:
999
+ if idx + 1 >= len(codex_args):
1000
+ parser.error(f"Codex option {arg!r} requires a value")
1001
+ idx += 2
1002
+ else:
1003
+ idx += 1
1004
+
1005
+
1006
+ def build_codex_argv(
1007
+ codex_binary: str,
1008
+ prompt: str | None,
1009
+ cwd: Path,
1010
+ codex_args: list[str],
1011
+ codex_profile: str | None,
1012
+ sandbox: str,
1013
+ bypass: bool,
1014
+ resume_session: str | None = None,
1015
+ resume_last: bool = False,
1016
+ ) -> list[str]:
1017
+ if resume_session is not None and resume_last:
1018
+ raise ValueError("resume_session and resume_last are mutually exclusive")
1019
+ if resume_session == "" and prompt is not None:
1020
+ raise ValueError("the resume picker cannot accept an initial prompt")
1021
+
1022
+ resuming = resume_session is not None or resume_last
1023
+ argv = [codex_binary]
1024
+ if resuming:
1025
+ argv.append("resume")
1026
+ argv.extend(codex_args)
1027
+ if codex_profile:
1028
+ argv.extend(["--profile", codex_profile])
1029
+ argv.extend(["--no-alt-screen", "-C", str(cwd)])
1030
+ if bypass:
1031
+ argv.append("--dangerously-bypass-approvals-and-sandbox")
1032
+ else:
1033
+ argv.extend(
1034
+ [
1035
+ "-a",
1036
+ "on-request",
1037
+ "-s",
1038
+ sandbox,
1039
+ "-c",
1040
+ 'approvals_reviewer="user"',
1041
+ ]
1042
+ )
1043
+ if resume_last:
1044
+ argv.append("--last")
1045
+
1046
+ positionals: list[str] = []
1047
+ if resume_session:
1048
+ positionals.append(resume_session)
1049
+ if prompt is not None:
1050
+ positionals.append(prompt)
1051
+ if positionals:
1052
+ argv.append("--")
1053
+ argv.extend(positionals)
1054
+ return argv
1055
+
1056
+
1057
+ def unique_session_name(config: Config) -> str:
1058
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
1059
+ return f"{config.session_prefix}-{stamp}-{os.getpid()}-{secrets.token_hex(2)}"
1060
+
1061
+
1062
+ def run_launcher(args: argparse.Namespace, config: Config, prompt: str | None) -> int:
1063
+ if shutil.which("tmux") is None:
1064
+ print("Error: tmux is required", file=sys.stderr)
1065
+ return 1
1066
+
1067
+ codex_binary = find_codex(args.codex_binary)
1068
+ if codex_binary is None:
1069
+ detail = f" at {args.codex_binary}" if args.codex_binary else ""
1070
+ print(f"Error: no healthy Codex binary found{detail}", file=sys.stderr)
1071
+ return 1
1072
+
1073
+ cwd = Path(args.cd).expanduser().resolve()
1074
+ if not cwd.is_dir():
1075
+ print(f"Error: working directory does not exist: {cwd}", file=sys.stderr)
1076
+ return 1
1077
+
1078
+ argv = build_codex_argv(
1079
+ codex_binary=codex_binary,
1080
+ prompt=prompt,
1081
+ cwd=cwd,
1082
+ codex_args=args.codex_args,
1083
+ codex_profile=args.codex_profile,
1084
+ sandbox=args.sandbox,
1085
+ bypass=args.bypass,
1086
+ resume_session=args.resume,
1087
+ resume_last=args.resume_last,
1088
+ )
1089
+ session_name = unique_session_name(config)
1090
+ try:
1091
+ cols, rows = os.get_terminal_size()
1092
+ except OSError:
1093
+ cols, rows = 120, 40
1094
+
1095
+ tmux = TmuxClient(config)
1096
+ created, error = tmux.new_session(session_name, argv, cwd, cols, rows)
1097
+ if not created:
1098
+ print(
1099
+ f"Error: failed to create tmux session: {error or 'unknown tmux error'}",
1100
+ file=sys.stderr,
1101
+ )
1102
+ return 1
1103
+
1104
+ try:
1105
+ register_session(config, session_name)
1106
+ except OSError as exc:
1107
+ tmux.kill_session(session_name)
1108
+ print(f"Error: failed to register tmux session: {exc}", file=sys.stderr)
1109
+ return 1
1110
+
1111
+ ok, daemon_error = start_daemon(config)
1112
+ if not ok:
1113
+ unregister_session(config, session_name)
1114
+ tmux.kill_session(session_name)
1115
+ print(f"Error: failed to start approval daemon: {daemon_error}", file=sys.stderr)
1116
+ print(
1117
+ "If a daemon with different settings is running, stop it with --stop-daemon.",
1118
+ file=sys.stderr,
1119
+ )
1120
+ return 1
1121
+
1122
+ # Do not gate startup on another tmux health query. On busy shared tmux
1123
+ # servers even read-only queries can time out; treating that as a dead pane
1124
+ # previously killed healthy Codex sessions.
1125
+ tmux.log(f"launcher_ready session={session_name}")
1126
+
1127
+ print(f"Codex running in tmux session: {session_name}")
1128
+ print(f"Codex binary: {codex_binary}")
1129
+ print(f"Working directory: {cwd}")
1130
+ if args.resume_last:
1131
+ print("Resume mode: most recent session in this working directory")
1132
+ elif args.resume == "":
1133
+ print("Resume mode: interactive session picker")
1134
+ elif args.resume is not None:
1135
+ print(f"Resume mode: session {args.resume}")
1136
+ print(f"Approval log: {config.log_path}")
1137
+ print(f"Reattach: tmux attach -t {shlex.quote(session_name)}")
1138
+ if config.approve_mcp:
1139
+ print("MCP/app approval: enabled (external side effects may be approved)")
1140
+ if config.auto_trust_directory:
1141
+ print("Automatic directory trust: enabled")
1142
+ if args.bypass:
1143
+ print("WARNING: Codex approvals and sandbox are disabled for this session")
1144
+ print()
1145
+
1146
+ if args.detach:
1147
+ return 0
1148
+
1149
+ attach_result = 0
1150
+ try:
1151
+ attach_result = tmux.attach_or_switch(session_name)
1152
+ except KeyboardInterrupt:
1153
+ pass
1154
+ finally:
1155
+ if tmux.session_exists(session_name):
1156
+ print(f"Session still running: tmux attach -t {shlex.quote(session_name)}")
1157
+ if attach_result != 0:
1158
+ print("Error: failed to attach or switch to the tmux session", file=sys.stderr)
1159
+ return attach_result
1160
+
1161
+
1162
+ def parse_args(argv: list[str] | None = None) -> tuple[argparse.ArgumentParser, argparse.Namespace]:
1163
+ parser = argparse.ArgumentParser(
1164
+ description="Interactive Codex tmux launcher with strict auto-approval detection",
1165
+ epilog=(
1166
+ "Put Codex-specific options after '--'. The wrapper reserves -p for the "
1167
+ "initial or resumed-session follow-up prompt."
1168
+ ),
1169
+ )
1170
+ control = parser.add_mutually_exclusive_group()
1171
+ control.add_argument("--daemon", action="store_true", help=argparse.SUPPRESS)
1172
+ control.add_argument("--start-daemon", action="store_true", help="start the watcher only")
1173
+ control.add_argument("--stop-daemon", action="store_true", help="stop the watcher")
1174
+ control.add_argument("--status", action="store_true", help="show watcher and tmux status")
1175
+ control.add_argument(
1176
+ "--resume",
1177
+ nargs="?",
1178
+ const="",
1179
+ metavar="SESSION_ID",
1180
+ help=(
1181
+ "resume an interactive Codex session by ID/name; omit SESSION_ID "
1182
+ "to open Codex's session picker"
1183
+ ),
1184
+ )
1185
+ control.add_argument(
1186
+ "--resume-last",
1187
+ action="store_true",
1188
+ help="resume the most recent interactive session for --cd",
1189
+ )
1190
+
1191
+ prompt_group = parser.add_mutually_exclusive_group()
1192
+ prompt_group.add_argument(
1193
+ "-p",
1194
+ "--prompt",
1195
+ help="initial Codex prompt, or follow-up prompt when resuming",
1196
+ )
1197
+ prompt_group.add_argument(
1198
+ "--prompt-file",
1199
+ help="read the initial/follow-up prompt from a UTF-8 file, or - for stdin",
1200
+ )
1201
+
1202
+ parser.add_argument("-C", "--cd", default=os.getcwd(), help="Codex working directory")
1203
+ parser.add_argument("--codex-binary", help="explicit Codex executable")
1204
+ parser.add_argument("--codex-profile", help="Codex config profile (Codex's own -p)")
1205
+ parser.add_argument(
1206
+ "--sandbox",
1207
+ choices=("read-only", "workspace-write"),
1208
+ default="workspace-write",
1209
+ help="Codex sandbox used with interactive approvals",
1210
+ )
1211
+ parser.add_argument(
1212
+ "--bypass",
1213
+ action="store_true",
1214
+ help="DANGEROUS: disable Codex approvals and sandbox (isolated environments only)",
1215
+ )
1216
+ parser.add_argument(
1217
+ "--approve-mcp",
1218
+ action="store_true",
1219
+ help="also auto-approve MCP/app tool calls that may have external side effects",
1220
+ )
1221
+ parser.add_argument(
1222
+ "--auto-trust-directory",
1223
+ action="store_true",
1224
+ help="automatically trust a directory on Codex's first-run screen",
1225
+ )
1226
+ parser.add_argument(
1227
+ "--detach",
1228
+ action="store_true",
1229
+ help="create the session without attaching",
1230
+ )
1231
+ parser.add_argument(
1232
+ "--keep-dead-session",
1233
+ action="store_true",
1234
+ help="keep the tmux session after Codex exits",
1235
+ )
1236
+ parser.add_argument(
1237
+ "--session-prefix",
1238
+ default=DEFAULT_SESSION_PREFIX,
1239
+ help="tmux session prefix",
1240
+ )
1241
+ parser.add_argument("--runtime-dir", help="directory for daemon state and logs")
1242
+ parser.add_argument(
1243
+ "--poll-interval",
1244
+ type=float,
1245
+ default=DEFAULT_POLL_INTERVAL,
1246
+ help="watcher polling interval in seconds",
1247
+ )
1248
+ parser.add_argument(
1249
+ "--cooldown",
1250
+ type=float,
1251
+ default=DEFAULT_COOLDOWN,
1252
+ help="minimum seconds between approvals in one pane",
1253
+ )
1254
+ parser.add_argument(
1255
+ "--stability-polls",
1256
+ type=int,
1257
+ default=DEFAULT_STABILITY_POLLS,
1258
+ help="identical captures required before approval",
1259
+ )
1260
+ parser.add_argument(
1261
+ "--rearm-interval",
1262
+ type=float,
1263
+ default=DEFAULT_REARM_INTERVAL,
1264
+ help="retry an unchanged approval after this many seconds",
1265
+ )
1266
+ parser.add_argument(
1267
+ "--idle-exit-seconds",
1268
+ type=float,
1269
+ default=DEFAULT_IDLE_EXIT_SECONDS,
1270
+ help="watcher exits after no matching panes for this duration",
1271
+ )
1272
+ parser.add_argument("--daemon-token", help=argparse.SUPPRESS)
1273
+ parser.add_argument(
1274
+ "codex_args",
1275
+ nargs=argparse.REMAINDER,
1276
+ help="Codex options after --",
1277
+ )
1278
+
1279
+ args = parser.parse_args(argv)
1280
+ if args.codex_args and args.codex_args[0] == "--":
1281
+ args.codex_args = args.codex_args[1:]
1282
+
1283
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,47}", args.session_prefix):
1284
+ parser.error(
1285
+ "--session-prefix must contain only letters, digits, '_' or '-' "
1286
+ "(max 48 chars)"
1287
+ )
1288
+ if args.poll_interval < 0.05:
1289
+ parser.error("--poll-interval must be at least 0.05 seconds")
1290
+ if args.cooldown < 0:
1291
+ parser.error("--cooldown cannot be negative")
1292
+ if args.stability_polls < 1:
1293
+ parser.error("--stability-polls must be at least 1")
1294
+ if args.rearm_interval < args.cooldown:
1295
+ parser.error("--rearm-interval must be greater than or equal to --cooldown")
1296
+ if args.idle_exit_seconds < 1:
1297
+ parser.error("--idle-exit-seconds must be at least 1 second")
1298
+ if args.daemon and not args.daemon_token:
1299
+ parser.error("internal daemon token is required")
1300
+ if args.resume == "" and (args.prompt is not None or args.prompt_file is not None):
1301
+ parser.error(
1302
+ "the resume picker cannot be combined with -p/--prompt or --prompt-file; "
1303
+ "select a session first, then enter the prompt in Codex"
1304
+ )
1305
+ validate_codex_args(parser, args.codex_args)
1306
+ passthrough_keys = {
1307
+ value.split("=", 1)[0]
1308
+ for value in args.codex_args
1309
+ if value.startswith("-")
1310
+ }
1311
+ if "--last" in passthrough_keys:
1312
+ parser.error("Codex option '--last' conflicts with the wrapper; use --resume-last")
1313
+ selector_mode = args.resume == "" or args.resume_last
1314
+ for option in ("--all", "--include-non-interactive"):
1315
+ if option in passthrough_keys and not selector_mode:
1316
+ parser.error(
1317
+ f"Codex option {option!r} is only valid with --resume or --resume-last"
1318
+ )
1319
+ return parser, args
1320
+
1321
+
1322
+ def build_config(args: argparse.Namespace) -> Config:
1323
+ return Config(
1324
+ session_prefix=args.session_prefix,
1325
+ runtime_dir=build_runtime_dir(args.session_prefix, args.runtime_dir),
1326
+ poll_interval=args.poll_interval,
1327
+ cooldown=args.cooldown,
1328
+ stability_polls=args.stability_polls,
1329
+ rearm_interval=args.rearm_interval,
1330
+ idle_exit_seconds=args.idle_exit_seconds,
1331
+ approve_mcp=args.approve_mcp,
1332
+ auto_trust_directory=args.auto_trust_directory,
1333
+ keep_dead_session=args.keep_dead_session,
1334
+ )
1335
+
1336
+
1337
+ def load_prompt(args: argparse.Namespace) -> str | None:
1338
+ if args.prompt_file is None:
1339
+ return args.prompt
1340
+ if args.prompt_file == "-":
1341
+ return sys.stdin.read()
1342
+ return Path(args.prompt_file).expanduser().read_text(encoding="utf-8")
1343
+
1344
+
1345
+ def main(argv: list[str] | None = None) -> int:
1346
+ parser, args = parse_args(argv)
1347
+ config = build_config(args)
1348
+
1349
+ if args.daemon:
1350
+ return daemon_loop(config, args.daemon_token)
1351
+
1352
+ if args.stop_daemon:
1353
+ result = stop_daemon(config)
1354
+ if result == "stopped":
1355
+ print("Daemon stopped")
1356
+ return 0
1357
+ if result == "not_running":
1358
+ print("Daemon was not running")
1359
+ return 0
1360
+ print("Daemon did not stop within 5 seconds; state was preserved", file=sys.stderr)
1361
+ return 1
1362
+
1363
+ if args.start_daemon:
1364
+ ok, error = start_daemon(config)
1365
+ print("Daemon: running" if ok else f"Daemon: failed ({error})")
1366
+ return 0 if ok else 1
1367
+
1368
+ if args.status:
1369
+ alive, record, mismatch = daemon_status(config)
1370
+ print(f"Daemon: {'running' if alive else 'not running'}")
1371
+ if alive and record:
1372
+ print(f"Daemon PID: {record.pid}")
1373
+ print(f"Daemon settings: {record.fingerprint}")
1374
+ if mismatch:
1375
+ print(f"Settings mismatch: {mismatch}")
1376
+ sessions = list_matching_sessions(config)
1377
+ print(f"Active sessions: {', '.join(sessions) if sessions else 'none'}")
1378
+ print(f"Runtime dir: {config.runtime_dir}")
1379
+ return 0
1380
+
1381
+ try:
1382
+ prompt = load_prompt(args)
1383
+ except OSError as exc:
1384
+ parser.error(f"could not read --prompt-file: {exc}")
1385
+ return run_launcher(args, config, prompt)
1386
+
1387
+
1388
+ if __name__ == "__main__":
1389
+ raise SystemExit(main())
test_codex_auto_run.py ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ import os
6
+ import shlex
7
+ import stat
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ import codex_auto_run as app
14
+
15
+
16
+ COMMAND_APPROVAL = """
17
+
18
+ Would you like to run the following command?
19
+
20
+ Reason: tests need to run
21
+
22
+ $ python -m unittest
23
+
24
+ › 1. Yes, proceed (y)
25
+ 2. Yes, and don't ask again for commands that start with `python` (p)
26
+ 3. No, and tell Codex what to do differently (esc)
27
+
28
+ Press enter to confirm or esc to cancel
29
+ """
30
+
31
+ EDIT_APPROVAL = """
32
+ Would you like to make the following edits?
33
+
34
+ Reason: update the implementation
35
+
36
+ › 1. Yes, proceed (y)
37
+ 2. Yes, and don't ask again for these files (a)
38
+ 3. No, and tell Codex what to do differently (esc)
39
+
40
+ Press enter to confirm or esc to cancel
41
+ """
42
+
43
+ PERMISSIONS_APPROVAL = """
44
+ Would you like to grant these permissions?
45
+
46
+ Permission rule: network; write `/tmp/output`
47
+
48
+ › 1. Yes, grant these permissions for this turn (y)
49
+ 2. Yes, grant for this turn with strict auto review (r)
50
+ 3. Yes, grant these permissions for this session (a)
51
+ 4. No, continue without permissions (d)
52
+
53
+ Press enter to confirm or esc to cancel
54
+ """
55
+
56
+ NETWORK_APPROVAL = """
57
+ Do you want to approve network access to "example.com"?
58
+
59
+ Reason: fetch a dependency
60
+
61
+ › 1. Yes, just this once (y)
62
+ 2. Yes, and allow this host for this conversation (a)
63
+ 3. Yes, and allow this host in the future (p)
64
+ 4. No, and tell Codex what to do differently (esc)
65
+
66
+ Press enter to confirm or esc to cancel
67
+ """
68
+
69
+ MCP_APPROVAL = """
70
+ github needs your approval.
71
+
72
+ Server: github
73
+
74
+ Create an issue comment
75
+
76
+ › 1. Yes, provide the requested info (y)
77
+ 2. No, but continue without it (n)
78
+ 3. Cancel this request (esc)
79
+
80
+ Press enter to confirm or esc to cancel
81
+ """
82
+
83
+ MCP_TOOL_APPROVAL = """
84
+ Field 1/1
85
+ Allow Calendar to create an event
86
+
87
+ Calendar: primary
88
+ Title: Roadmap review
89
+
90
+ › 1. Allow Run the tool and continue.
91
+ 2. Cancel Cancel this tool call
92
+
93
+ enter to submit | esc to cancel
94
+ """
95
+
96
+ TRUST_DIRECTORY = """
97
+ > You are in /workspace/project
98
+
99
+ Do you trust the contents of this directory? Working with untrusted
100
+ contents comes with higher risk of prompt injection.
101
+
102
+ › 1. Yes, continue
103
+ 2. No, quit
104
+
105
+ Press enter to continue
106
+ """
107
+
108
+ FULL_ACCESS = """
109
+ Enable full access?
110
+
111
+ When Codex runs with full access, it can edit any file.
112
+
113
+ › 1. Yes, continue anyway
114
+ 2. Yes, and don't ask again
115
+ 3. Cancel
116
+
117
+ Press enter to confirm or esc to cancel
118
+ """
119
+
120
+ REQUEST_USER_INPUT = """
121
+ Questions 1/1
122
+ Which deployment target should be used?
123
+
124
+ › 1. Staging
125
+ 2. Production
126
+
127
+ enter to submit | esc to cancel
128
+ """
129
+
130
+
131
+ def rows(value: str) -> list[str]:
132
+ return value.strip("\n").splitlines()
133
+
134
+
135
+ def config(
136
+ runtime_dir: Path,
137
+ *,
138
+ approve_mcp: bool = False,
139
+ auto_trust_directory: bool = False,
140
+ stability_polls: int = 2,
141
+ ) -> app.Config:
142
+ return app.Config(
143
+ session_prefix="codex-auto-test",
144
+ runtime_dir=runtime_dir,
145
+ poll_interval=0.1,
146
+ cooldown=0.5,
147
+ stability_polls=stability_polls,
148
+ rearm_interval=2.0,
149
+ idle_exit_seconds=2.0,
150
+ approve_mcp=approve_mcp,
151
+ auto_trust_directory=auto_trust_directory,
152
+ keep_dead_session=False,
153
+ )
154
+
155
+
156
+ class DetectorTests(unittest.TestCase):
157
+ def setUp(self) -> None:
158
+ self.temp = tempfile.TemporaryDirectory()
159
+ self.runtime = Path(self.temp.name)
160
+
161
+ def tearDown(self) -> None:
162
+ self.temp.cleanup()
163
+
164
+ def test_standard_approval_types(self) -> None:
165
+ cfg = config(self.runtime)
166
+ expected = {
167
+ COMMAND_APPROVAL: "command",
168
+ EDIT_APPROVAL: "edit",
169
+ PERMISSIONS_APPROVAL: "permissions",
170
+ NETWORK_APPROVAL: "network",
171
+ }
172
+ for fixture, kind in expected.items():
173
+ with self.subTest(kind=kind):
174
+ candidate = app.detect_candidate(rows(fixture), cfg)
175
+ self.assertIsNotNone(candidate)
176
+ self.assertEqual(kind, candidate.kind)
177
+
178
+ def test_mcp_requires_explicit_opt_in(self) -> None:
179
+ safe = config(self.runtime)
180
+ enabled = config(self.runtime, approve_mcp=True)
181
+ for fixture in (MCP_APPROVAL, MCP_TOOL_APPROVAL):
182
+ with self.subTest(fixture=fixture[:30]):
183
+ self.assertIsNone(app.detect_candidate(rows(fixture), safe))
184
+ candidate = app.detect_candidate(rows(fixture), enabled)
185
+ self.assertIsNotNone(candidate)
186
+ self.assertIn(candidate.kind, {"mcp", "mcp_tool"})
187
+
188
+ def test_directory_trust_requires_explicit_opt_in(self) -> None:
189
+ self.assertIsNone(app.detect_candidate(rows(TRUST_DIRECTORY), config(self.runtime)))
190
+ candidate = app.detect_candidate(
191
+ rows(TRUST_DIRECTORY),
192
+ config(self.runtime, auto_trust_directory=True),
193
+ )
194
+ self.assertIsNotNone(candidate)
195
+ self.assertEqual("trust_directory", candidate.kind)
196
+
197
+ def test_unrelated_interactive_surfaces_are_not_approved(self) -> None:
198
+ cfg = config(self.runtime, approve_mcp=True, auto_trust_directory=True)
199
+ for fixture in (FULL_ACCESS, REQUEST_USER_INPUT):
200
+ with self.subTest(fixture=fixture[:30]):
201
+ self.assertIsNone(app.detect_candidate(rows(fixture), cfg))
202
+
203
+ def test_historical_approval_text_above_composer_is_not_approved(self) -> None:
204
+ transcript = COMMAND_APPROVAL + "\n\n• Command completed\n\n› write a follow-up"
205
+ self.assertIsNone(app.detect_candidate(rows(transcript), config(self.runtime)))
206
+
207
+ def test_selection_must_be_first_one_shot_option(self) -> None:
208
+ unsafe_selection = COMMAND_APPROVAL.replace(
209
+ "› 1. Yes, proceed (y)",
210
+ " 1. Yes, proceed (y)",
211
+ ).replace(
212
+ " 2. Yes, and don't ask again",
213
+ "› 2. Yes, and don't ask again",
214
+ )
215
+ self.assertIsNone(app.detect_candidate(rows(unsafe_selection), config(self.runtime)))
216
+
217
+ def test_soft_wrapped_mcp_description_still_matches(self) -> None:
218
+ wrapped = MCP_TOOL_APPROVAL.replace(
219
+ "› 1. Allow Run the tool and continue.",
220
+ "› 1. Allow\n Run the tool and continue.",
221
+ )
222
+ candidate = app.detect_candidate(
223
+ rows(wrapped),
224
+ config(self.runtime, approve_mcp=True),
225
+ )
226
+ self.assertIsNotNone(candidate)
227
+ self.assertEqual("mcp_tool", candidate.kind)
228
+
229
+ def test_long_mcp_parameter_block_still_matches(self) -> None:
230
+ params = "\n".join(f" Parameter {idx}: value" for idx in range(30))
231
+ expanded = MCP_TOOL_APPROVAL.replace(
232
+ " Calendar: primary",
233
+ f" Calendar: primary\n{params}",
234
+ )
235
+ candidate = app.detect_candidate(
236
+ rows(expanded),
237
+ config(self.runtime, approve_mcp=True),
238
+ )
239
+ self.assertIsNotNone(candidate)
240
+ self.assertEqual("mcp_tool", candidate.kind)
241
+
242
+ def test_wrapped_network_title_and_footer_still_match(self) -> None:
243
+ wrapped = NETWORK_APPROVAL.replace(
244
+ 'network access to "example.com"?',
245
+ 'network access to\n "example.com"?',
246
+ ).replace(
247
+ "Press enter to confirm or esc to cancel",
248
+ "Press enter to confirm or esc to\n cancel",
249
+ )
250
+ candidate = app.detect_candidate(rows(wrapped), config(self.runtime))
251
+ self.assertIsNotNone(candidate)
252
+ self.assertEqual("network", candidate.kind)
253
+
254
+
255
+ class SessionStateTests(unittest.TestCase):
256
+ def setUp(self) -> None:
257
+ self.temp = tempfile.TemporaryDirectory()
258
+ self.cfg = config(Path(self.temp.name))
259
+ self.candidate = app.PromptCandidate("command", "command:abc")
260
+
261
+ def tearDown(self) -> None:
262
+ self.temp.cleanup()
263
+
264
+ def test_stability_and_rearm(self) -> None:
265
+ state = app.SessionState()
266
+ self.assertFalse(state.ready(self.candidate, 10.0, self.cfg))
267
+ self.assertTrue(state.ready(self.candidate, 10.1, self.cfg))
268
+ state.mark_approved(self.candidate, 10.1)
269
+ self.assertFalse(state.ready(self.candidate, 11.0, self.cfg))
270
+ self.assertFalse(state.ready(self.candidate, 12.2, self.cfg))
271
+ self.assertTrue(state.ready(self.candidate, 12.3, self.cfg))
272
+
273
+ def test_screen_clear_rearms_immediately_after_cooldown(self) -> None:
274
+ state = app.SessionState()
275
+ state.mark_approved(self.candidate, 20.0)
276
+ self.assertTrue(state.clear_candidate())
277
+ self.assertFalse(state.ready(self.candidate, 20.6, self.cfg))
278
+ self.assertTrue(state.ready(self.candidate, 20.7, self.cfg))
279
+
280
+
281
+ class ArgumentTests(unittest.TestCase):
282
+ def test_prompt_and_passthrough(self) -> None:
283
+ _, args = app.parse_args(["-p", "hello world", "--", "--model", "gpt-5.4"])
284
+ self.assertEqual("hello world", args.prompt)
285
+ self.assertEqual(["--model", "gpt-5.4"], args.codex_args)
286
+
287
+ def test_resume_modes_parse_without_changing_passthrough(self) -> None:
288
+ _, exact = app.parse_args(
289
+ ["--resume", "019f-test", "-p", "continue", "--", "--search"]
290
+ )
291
+ self.assertEqual("019f-test", exact.resume)
292
+ self.assertFalse(exact.resume_last)
293
+ self.assertEqual(["--search"], exact.codex_args)
294
+
295
+ _, latest = app.parse_args(["--resume-last", "-C", "/tmp/project"])
296
+ self.assertIsNone(latest.resume)
297
+ self.assertTrue(latest.resume_last)
298
+
299
+ _, picker = app.parse_args(["--resume", "-C", "/tmp/project"])
300
+ self.assertEqual("", picker.resume)
301
+ self.assertFalse(picker.resume_last)
302
+
303
+ def test_resume_picker_rejects_initial_prompt(self) -> None:
304
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
305
+ app.parse_args(["--resume", "-p", "continue"])
306
+
307
+ def test_resume_selection_passthrough_is_mode_checked(self) -> None:
308
+ for argv in (
309
+ ["--resume", "019f-test", "--", "--last"],
310
+ ["--", "--all"],
311
+ ["--resume", "019f-test", "--", "--include-non-interactive"],
312
+ ):
313
+ with self.subTest(argv=argv):
314
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(
315
+ SystemExit
316
+ ):
317
+ app.parse_args(argv)
318
+
319
+ _, args = app.parse_args(
320
+ ["--resume-last", "--", "--all", "--include-non-interactive"]
321
+ )
322
+ self.assertEqual(
323
+ ["--all", "--include-non-interactive"],
324
+ args.codex_args,
325
+ )
326
+
327
+ def test_wrapper_rejects_conflicting_codex_options(self) -> None:
328
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
329
+ app.parse_args(["-p", "hello", "--", "-a", "never"])
330
+
331
+ def test_wrapper_rejects_subcommand_after_global_option(self) -> None:
332
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
333
+ app.parse_args(
334
+ ["-p", "hello", "--", "--model", "gpt-5.4", "exec"]
335
+ )
336
+
337
+ def test_build_codex_argv_preserves_prompt_as_one_argument(self) -> None:
338
+ prompt = "quoted ' prompt\nwith multiple lines"
339
+ argv = app.build_codex_argv(
340
+ "/opt/codex",
341
+ prompt,
342
+ Path("/tmp/project with spaces"),
343
+ ["--model", "gpt-5.4"],
344
+ "work",
345
+ "workspace-write",
346
+ False,
347
+ )
348
+ self.assertEqual(argv, shlex.split(shlex.join(argv)))
349
+ self.assertEqual(["--", prompt], argv[-2:])
350
+ self.assertIn('approvals_reviewer="user"', argv)
351
+ self.assertNotIn("exec", argv)
352
+
353
+ def test_bypass_is_explicit_and_omits_approval_settings(self) -> None:
354
+ argv = app.build_codex_argv(
355
+ "/opt/codex",
356
+ "go",
357
+ Path("/tmp/project"),
358
+ [],
359
+ None,
360
+ "workspace-write",
361
+ True,
362
+ )
363
+ self.assertIn("--dangerously-bypass-approvals-and-sandbox", argv)
364
+ self.assertNotIn("on-request", argv)
365
+
366
+ def test_build_exact_resume_argv(self) -> None:
367
+ argv = app.build_codex_argv(
368
+ "/opt/codex",
369
+ "continue safely",
370
+ Path("/tmp/project"),
371
+ ["--search"],
372
+ None,
373
+ "workspace-write",
374
+ False,
375
+ resume_session="019f-session",
376
+ )
377
+ self.assertEqual("resume", argv[1])
378
+ self.assertIn("--search", argv)
379
+ self.assertEqual(["--", "019f-session", "continue safely"], argv[-3:])
380
+
381
+ def test_build_resume_last_and_picker_argv(self) -> None:
382
+ latest = app.build_codex_argv(
383
+ "/opt/codex",
384
+ "continue",
385
+ Path("/tmp/project"),
386
+ [],
387
+ None,
388
+ "workspace-write",
389
+ False,
390
+ resume_last=True,
391
+ )
392
+ self.assertEqual("resume", latest[1])
393
+ self.assertIn("--last", latest)
394
+ self.assertEqual(["--", "continue"], latest[-2:])
395
+
396
+ picker = app.build_codex_argv(
397
+ "/opt/codex",
398
+ None,
399
+ Path("/tmp/project"),
400
+ [],
401
+ None,
402
+ "workspace-write",
403
+ False,
404
+ resume_session="",
405
+ )
406
+ self.assertEqual("resume", picker[1])
407
+ self.assertNotIn("--last", picker)
408
+ self.assertNotIn("--", picker)
409
+
410
+ def test_build_resume_rejects_ambiguous_internal_combinations(self) -> None:
411
+ with self.assertRaises(ValueError):
412
+ app.build_codex_argv(
413
+ "/opt/codex",
414
+ None,
415
+ Path("/tmp/project"),
416
+ [],
417
+ None,
418
+ "workspace-write",
419
+ False,
420
+ resume_session="019f-session",
421
+ resume_last=True,
422
+ )
423
+ with self.assertRaises(ValueError):
424
+ app.build_codex_argv(
425
+ "/opt/codex",
426
+ "ambiguous prompt",
427
+ Path("/tmp/project"),
428
+ [],
429
+ None,
430
+ "workspace-write",
431
+ False,
432
+ resume_session="",
433
+ )
434
+
435
+
436
+ class BinaryAndTmuxTests(unittest.TestCase):
437
+ def test_codex_probe(self) -> None:
438
+ with tempfile.TemporaryDirectory() as directory:
439
+ binary = Path(directory) / "codex"
440
+ binary.write_text("#!/bin/sh\necho 'codex-cli 9.9.9'\n", encoding="utf-8")
441
+ binary.chmod(binary.stat().st_mode | stat.S_IXUSR)
442
+ self.assertTrue(app.probe_codex_binary(binary))
443
+
444
+ def test_explicit_codex_path_does_not_fall_back(self) -> None:
445
+ self.assertIsNone(app.find_codex("/definitely/missing/codex"))
446
+
447
+ def test_tmux_prefix_filter_is_delimited(self) -> None:
448
+ with tempfile.TemporaryDirectory() as directory:
449
+ cfg = config(Path(directory))
450
+ app.register_session(cfg, "codex-auto-test-123")
451
+
452
+ class FakeTmux(app.TmuxClient):
453
+ def run(self, *args: str, timeout: float = 5.0):
454
+ del args, timeout
455
+ return (
456
+ 0,
457
+ "codex-auto-test-123\t%1\t0\n"
458
+ "codex-auto-testing\t%2\t0\n"
459
+ "other\t%3\t0\n",
460
+ "",
461
+ )
462
+
463
+ panes = FakeTmux(cfg).list_panes()
464
+ self.assertEqual(["%1"], [pane.pane_id for pane in panes])
465
+
466
+ def test_confirm_selected_choice_uses_rendered_enter_binding(self) -> None:
467
+ with tempfile.TemporaryDirectory() as directory:
468
+ cfg = config(Path(directory))
469
+ calls: list[tuple[str, ...]] = []
470
+
471
+ class FakeTmux(app.TmuxClient):
472
+ def run(self, *args: str, timeout: float = 5.0):
473
+ del timeout
474
+ calls.append(args)
475
+ return 0, "", ""
476
+
477
+ self.assertTrue(FakeTmux(cfg).confirm_selected_choice("%7"))
478
+ self.assertEqual(("send-keys", "-t", "%7", "Enter"), calls[0])
479
+
480
+ def test_session_exists_uses_target_session(self) -> None:
481
+ with tempfile.TemporaryDirectory() as directory:
482
+ cfg = config(Path(directory))
483
+ calls: list[tuple[str, ...]] = []
484
+
485
+ class FakeTmux(app.TmuxClient):
486
+ def run(self, *args: str, timeout: float = 5.0):
487
+ del timeout
488
+ calls.append(args)
489
+ return 0, "", ""
490
+
491
+ self.assertTrue(FakeTmux(cfg).session_exists("test-session"))
492
+ self.assertEqual(
493
+ ("has-session", "-t", "=test-session"),
494
+ calls[0],
495
+ )
496
+
497
+ def test_new_session_sets_working_directory_and_quotes_argv(self) -> None:
498
+ with tempfile.TemporaryDirectory() as directory:
499
+ cfg = config(Path(directory))
500
+ calls: list[tuple[str, ...]] = []
501
+
502
+ class FakeTmux(app.TmuxClient):
503
+ def run(self, *args: str, timeout: float = 5.0):
504
+ del timeout
505
+ calls.append(args)
506
+ return 0, "", ""
507
+
508
+ argv = ["/opt/codex", "--", "hello ' world\nsecond prompt line"]
509
+ ok, _ = FakeTmux(cfg).new_session(
510
+ "codex-auto-test-1",
511
+ argv,
512
+ Path("/tmp/project with spaces"),
513
+ 120,
514
+ 40,
515
+ )
516
+ self.assertTrue(ok)
517
+ new_session = calls[0]
518
+ self.assertIn("-c", new_session)
519
+ self.assertIn("/tmp/project with spaces", new_session)
520
+ self.assertEqual(tuple(argv), new_session[-len(argv) :])
521
+ self.assertEqual("set-window-option", calls[1][0])
522
+ self.assertEqual("=codex-auto-test-1:", calls[1][2])
523
+
524
+
525
+ class DaemonLifecycleTests(unittest.TestCase):
526
+ def test_stop_timeout_preserves_identity_record(self) -> None:
527
+ with tempfile.TemporaryDirectory() as directory:
528
+ cfg = config(Path(directory))
529
+ record = app.DaemonRecord(
530
+ pid=12345,
531
+ token="token",
532
+ script=str(Path(app.__file__).resolve()),
533
+ fingerprint=cfg.daemon_fingerprint(),
534
+ )
535
+ app._write_daemon_record(cfg, record)
536
+ with (
537
+ mock.patch.object(
538
+ app,
539
+ "daemon_status",
540
+ return_value=(True, record, None),
541
+ ),
542
+ mock.patch.object(app, "_process_matches", return_value=True),
543
+ mock.patch.object(app.os, "kill"),
544
+ mock.patch.object(app.time, "monotonic", side_effect=[0.0, 6.0]),
545
+ ):
546
+ self.assertEqual("timeout", app.stop_daemon(cfg))
547
+ self.assertTrue(cfg.pid_path.exists())
548
+
549
+
550
+ if __name__ == "__main__":
551
+ unittest.main()