OpenAI Codex · Unified exec

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.

Course goalAfter this lesson you can explain two things. Once a command enters the unified door, tty, remote env, and whether it is still alive send it to PTY, pipe, or exec-server. A sandbox deny can retry by policy only if the process exits quickly.
Try it first · Which road after the door
Same command: fork the spawn path first, then watch the 150ms window
Command
Early-death deny, PTY session, late-death deny, remote backend — the four roads fork at different points.
Policy
These two only touch the retry gate after an early death. A command that outlives the window never gets here.
Unified door · exec_commandWaiting for a command
PTYtty is true
pipeDefault local path
exec-serverRemote or snapshot
150ms windowLamp not yet on
Escalateescalate
PolicyCan it run naked
deny-readCan it drop the sandbox
Ask againDoes the approval still count
Second trySandbox type
Waiting to start.
Logic trail · each animation step maps to a stretch of source
  1. Build a command-plus-cwd requestmod.rs L12
  2. Orchestrator approval: bypass, cache, or a popupmod.rs L14
  3. Pick a sandbox from the profile and transformmod.rs L15
  4. Fork to PTY, pipe, or exec-server by tty and remote envspawn.rs L97
  5. If it exits inside 150ms, check for a sandbox denyprocess.rs L349
  6. Heuristic or executor flag → mark Deniedprocess.rs L307
  7. Orchestrator walks escalate and the policy gateorchestrator.rs L356
  8. UnlessTrusted and already approved → do not ask againorchestrator.rs L411
  9. If unsandboxed is allowed, the second try uses Noneorchestrator.rs L459
  10. Outlive the window → store, then yieldprocess_manager.rs L535
  11. A late deny becomes an Ok receiptexec_command.rs L384
  12. User Esc only cancels the turnprotocol.rs L546
Hit Play and watch the same command fork after the door.
First forkLocally, tty picks PTY or pipe; remote goes to exec-server. The model still sees the same tool.
Second forkOnly an exit inside 150ms lets the orchestrator see a sandbox deny. Outlive the window and it is stored — later failure gets no second spawn.
The boundary you can changeFlip policy to Never, or turn on deny-read, and the main-path unsandboxed retry shuts off.
Teaching demo: Exit timings and deny copy are teaching fixtures, to show the fork conditions. Line numbers on the logic trail match openai/codex commit 4f39251a01.
Idea 1 · Two tools, three ways to spawn
What problem it solves

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.

What the idea is

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.

exec_command Unified door tty is true PTY Local default pipe Remote or snapshot Writable stdin — the model can send keys Ordinary characters hit a closed stdin exec-server backend
Teaching diagram: Same door, three spawn styles by features.
Why it lasts

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.

Idea 2 · The desk lamp stays on for 150ms
What problem it solves

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.

What the idea is

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

Just spawned Exits inside 150ms Check the deny — the orchestrator can see it Outlives the window Store first — orchestrator already has Ok Pass the gates → second spawn Gates shut → hand the deny body to the model Receipt is still Ok No second spawn
Teaching diagram: Lamp on and a retry is possible; lamp off and it is stored.
Why it lasts

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.

Idea 3 · Retry has gates; Esc does not kill the process
What problem it solves

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

What the idea is

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

Lamp on and a retry is possible; lamp off and it is stored. Esc only stops thinking.
Why it lasts

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.

Side-by-side · Where the persistent terminal lives

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.

Source checked on both sides · 2026-08-22 · DSH · Terminal sessions

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 checked on both sides · 2026-08-22

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

Classroom Exercise
01

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.

Takeaway:A command enters the unified door and forks to PTY, pipe, or exec-server by tty and remote env. A sandbox deny can retry by policy only if it is recognized inside the 150ms window. Whatever outlives the window is stored first. Esc only stops the turn.