One door: a command forks by features
After a command enters the unified exec door, it forks by tty, remote env, and a 150ms window: PTY, pipe, exec-server — and only an early death can retry by policy
THE QUESTION THIS PAGE ANSWERS
ANSWER FIRSTWhat is the key idea behind “One door: a command forks by features”?
After a command enters the unified exec door, it forks by tty, remote env, and a 150ms window: PTY, pipe, exec-server — and only an early death can retry by policy
Follow the handoffs, not the demo. A system becomes dependable at the boundaries between model, tools, state, permissions, and people. Read each handoff as a place where you can observe, test, and recover.
Name the input, owner, approval, and recovery action for one automated step.
A successful run that cannot explain what happened or be safely repeated.
- 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.
The handoffs inside “Try it first · Which road after the door”
“The model wants dependencies and emits npm install .” shows that an Agent is not defined by the model alone. Each handoff between model, context, tools, state, permissions, and people affects both progress and recovery.
Write the state before adding capability
Starting from “Outside there are only exec_command and write_stdin .”, split the workflow into starting state, next action, tool result, state update, and stop condition. Debugging then means finding the first lost piece of information or authority instead of saying vaguely that the model “got worse”.
- Build a command-plus-cwd request mod.rs L12
- Orchestrator approval: bypass, cache, or a popup mod.rs L14
- Pick a sandbox from the profile and transform mod.rs L15
A happy path is not reliability
Use “The same npm install , first on UnlessTrusted , then flipped to Never .” to replay one successful and one failed run. Record the context, tool result, and owner at each turn; the workflow is maintainable when a second person can follow it without the original builder.
From “Try it first · Which road after the door” to “Idea 1 · Two tools, three ways to spawn”
“Try it first · Which road after the door” grounds the problem in “Same command: fork the spawn path first, then watch the 150ms window Play Step Reset Command npm install vim sleep 2 Remote env Early-death deny, PTY session, late-death deny, remote backend — the four roads fo…”. “Idea 1 · Two tools, three ways to spawn” then moves it toward “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 appro…”. Together, they show that the lesson is not just a conclusion to remember, but a claim with conditions.
Carry the judgment into the next situation
When analyzing an Agent, trace state, action, tool result, and next step in order. Each handoff should explain where information came from, who confirmed it, and where failure stops.
- “Try it first · Which road after the door”: Same command: fork the spawn path first, then watch the 150ms window Play Step Reset Command npm install vim sleep 2 Remote env Early-death deny, PTY session, late-death deny, remote backend — the four roads fo…
- “Idea 1 · Two tools, three ways to spawn”: 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 appro…
- “The closing point”: If it exits inside 150ms, check for a sandbox deny process.rs L349
The final “The closing point” brings the discussion to “If it exits inside 150ms, check for a sandbox deny process.rs L349”. The useful thing to carry forward is knowing which judgments must be revisited when input, scale, or risk changes.
I turned one judgment from this article into a small experiment I could run today. Knowing what to observe next is more useful than simply remembering the conclusion.
After reading this, I first looked for the conditions behind the idea instead of copying the method into a project. That order made the later trade-offs much clearer.
When this judgment reaches real work, which constraint should be added first? I am curious which step matters most between reading and the first practical attempt.
No discussion on this article yet.