One door: a command forks by features
The model only sees exec_command and write_stdin. After the door, tty, remote env, and a 150ms window send the same command to PTY, pipe, exec-server, or the retry gate.
- Build a command-plus-cwd requestmod.rs L12
- Orchestrator approval: bypass, cache, or a popupmod.rs L14
- Pick a sandbox from the profile and transformmod.rs L15
- Fork to PTY, pipe, or exec-server by tty and remote envspawn.rs L97
- If it exits inside 150ms, check for a sandbox denyprocess.rs L349
- Heuristic or executor flag → mark Deniedprocess.rs L307
- Orchestrator walks escalate and the policy gateorchestrator.rs L356
- UnlessTrusted and already approved → do not ask againorchestrator.rs L411
- If unsandboxed is allowed, the second try uses Noneorchestrator.rs L459
- Outlive the window → store, then yieldprocess_manager.rs L535
- A late deny becomes an Ok receiptexec_command.rs L384
- User Esc only cancels the turnprotocol.rs L546
The model wants dependencies and emits npm install. In the same turn it opens vim to edit the README. Write a separate approval and sandbox stack per spawn style, and Guardian, the network proxy, and the approval cache get copied three times — change one, miss two.
Outside there are only exec_command and write_stdin. The first opens a process; the second writes into one that already exists; an empty write is a poll. Internally it tracks process_id; the model-facing argument is session_id. The manager only prepares the request. Approval, sandbox pick, and retry go to the orchestrator.
Source:codex-rs/core/src/unified_exec/mod.rs lines 12–17
Local spawn picks one of three from two switches. tty true walks PTY. tty false with stdin open walks a pipe that keeps stdin. Otherwise a pipe without stdin. Remote env, or a request with a shell snapshot, skips local spawn and goes to exec-server. A Windows restricted token is its own backend.
Source:codex-rs/sandboxing/src/spawn.rs lines 97–127
A default tool call is often pipe. The module comment’s “spawn a PTY” only covers the tty=true branch. Full terminal capability needs the model to turn tty on explicitly. Env vars also pin TERM=dumb, PAGER=cat, and a batch of others — interactive programs get shaved first.
Policy logic stays in one place; process shape can change. Rewrite in another language and it is still one door, spawn style by features. How PTY is implemented can stay in your own repo.
The sandbox denies a write to /etc/hosts; stderr says Operation not permitted. If the runtime treats that as a bad command, the model rewrites source, swaps paths, adds sudo. Recognize it, and it may retry by policy — or feed the deny body to the model to ask for permission.
After a local process is caught, a deny check runs only if the exit channel already has a code, the channel closed, or it exited inside 150ms. Past that window it only hangs a background waiter and hands back the still-living process. Orchestrator retry depends on SandboxDenied from here. Live past 150ms and the orchestrator already has Ok — a later death cannot enter a second spawn.
Source:codex-rs/core/src/unified_exec/process.rs line 38 · lines 349–367
The exec-server path has the same timeout. The checker itself waits 20ms first, so an output notice can arrive. Three short-circuits: process not exited yet, let it through; already SandboxType::None and the executor did not report a deny, let it through. Everything else runs the shared heuristic.
Source:codex-rs/core/src/unified_exec/process.rs lines 290–324
A process that outlives the window is stored first, then yield starts. Interrupt a turn and you must not kill the background process just because the last Arc was dropped. If yield waits until the process has already exited, the manager runs the deny check again. The orchestrator has long since returned Ok. That error goes straight back to the handler as a tool receipt with a body, process_id emptied.
Source:codex-rs/core/src/unified_exec/process_manager.rs lines 535–556 · codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs lines 384–407
A one-shot command can wait until the end to judge. A durable process needs a deadline. Miss the cutoff and a command that fails after a few seconds is treated as a deny and run naked again. Side effects are already on disk; the second try is another process; the source has no rollback.
The module header says: after a deny, retry with SandboxType::None by policy, and lean on the cache so you do not ask again. Never, OnRequest, Guardian strict, and a profile with deny-read all shut off “do not ask” or the unsandboxed retry. If Esc takes vim with it, the next turn cannot find this session.
Source:codex-rs/core/src/unified_exec/mod.rs lines 7–8
The orchestrator recognizes one error: SandboxErr::Denied. After that it walks five gates. unified_exec declares it will escalate. Never and OnRequest default to no unsandboxed retry and surface the deny with its original text. A profile with deny-read would silently allow those denied reads if the sandbox were bypassed, so unsandboxed turns off. Guardian’s strict auto-review lets the first approval cover only the in-sandbox try. When a second try is allowed unsandboxed, it lands on None.
Source:codex-rs/core/src/tools/runtimes/unified_exec.rs lines 159–161 · codex-rs/core/src/tools/sandboxing.rs lines 330–337 · lines 269–278 · codex-rs/core/src/tools/orchestrator.rs lines 411–415 · lines 444–460
User hits Esc in the TUI; the protocol entry is Interrupt: abort the current task, do not kill the background terminal. To kill every background job there is CleanBackgroundTerminals. Store first, then yield — when the turn token is cancelled the process Arc is still there. A pipe session cannot take ordinary keystrokes; only \u{3} walks interrupt.
Source:codex-rs/protocol/src/protocol.rs lines 546–552
The isolation level the user picked must not be quietly rewritten by the runtime. Stopping thought and stopping a terminal are two jobs. Cancel only stops waiting; it does not kill a registered process.
DeepSeek Harness: six terminal tools, plus jobs
DSH makes the durable PTY its own tool family: open, write, read, signal, close, list. Background send reuses ctx.jobs; collect via job_output; stop via job_kill. The system prompt says: use the terminal only when you need terminal state across calls or interactive stdin; one-shot work prefers shell or the read/write tools.
Codex folds open and write into two tools; read merges into the next write_stdin or an empty poll. DSH has no matching “orchestrator auto-retries unsandboxed after a sandbox deny.” After a failure, who runs it again? The two sides answer differently.
Grok: pick durable or not when the session starts
Grok has no unified_exec module. It makes durability a session-level backend pick: reuse the parent session, ACP client terminal, local durable, local non-durable. Once the backend is chosen, the whole run stays in that shape.
Codex puts the same question on a single exec_command: if the process outlives yield, it sends back a process_id. Grok shares one backend for the whole session; a child agent reuses the parent backend. Both sides admit a one-shot bash -c cannot keep cwd or interactive state. They land in different places.
Source:packages/terminal/tool-terminal/src/index.ts lines 156–160 · crates/codegen/xai-grok-shell/src/session/acp_session_impl/spawn.rs lines 2048–2070
Which run gets a second spawn
The same npm install, first on UnlessTrusted, then flipped to Never. Then swap the command for sleep 2. Walk it: which run gets a second spawn, which receipt still has a process_id, and why the orchestrator cannot see the deny after the lamp goes off.