Add interactive Codex tmux auto-approval launcher
Browse files- .gitignore +7 -0
- README.md +159 -0
- codex_auto_run.py +1389 -0
- 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()
|