Story
You say in TUI: “delete the build directory.” The model decides to call shell.
English lecture · source-traced
**Subject**: [openai/codex](https://github.com/openai/codex) (**Codex CLI**; ≠ ChatGPT web Codex Web) **Based on commit**: `bb1af235ea2822d7a40f75ef52e4d6a2cde84da2` (2026-07-28, `Load thread titles concurrently during session startup (#35779)`) **Analysis date**: 2026-07-28 · **Document version**: **deepened edition · including scenario walkthroughs (Chapters 23–28)** (rewritten as “one point, one exposition”; targe
Subject: openai/codex (Codex CLI; ≠ ChatGPT web Codex Web) Based on commit:
bb1af235ea2822d7a40f75ef52e4d6a2cde84da2(2026-07-28,Load thread titles concurrently during session startup (#35779)) Analysis date: 2026-07-28 · Document version: deepened edition · including scenario walkthroughs (Chapters 23–28) (rewritten as “one point, one exposition”; target granularity aligned with the Open Design / Claude Code deep editions) License: Apache-2.0 (LICENSE, Copyright 2025 OpenAI) Scale:codex-rs/≈63MB · 2783.rsfiles · Cargo workspace 126 members ·session/turn.rs2741 lines ·session/mod.rs4154 lines npm:@openai/codex(codex-cli/package.json) →bin/codex.jsMethod: every key point is fixed four beats — ① what this point is solving → ② what the source looks like (file:line) → ③ why it is designed this way → ④ reader checklist. Line numbers are not invented; uncertain places are marked. Companions: three-way deep comparison · interactive explaineropenai-codex-解析.html(5 runnable benches) Reference tree:参考项目/codex/
Part I · What it is 1. Project overview: nail the product boundary first 2. Panorama architecture: 126 crates and “resist core bloat” 3. Startup and multiple entries: MultitoolCli command by command
Part II · The heart (most important) 4. Thread · Session · task system 5. Outer loop: RegularTask 6. Inner loop: run_turn step by step (upper) — entry to sampling 7. Inner loop: run_turn step by step (lower) — after sampling and stop 8. run_sampling_request and try_run_sampling_request 9. The compact family: pre / mid / previous-model 10. The six context-window constitutions
Part III · Hands and gates 11. Tool assembly: ToolSpec · built_tools · Router 12. Orchestrator: approval → sandbox → attempt → retry 13. Shell path: from arguments to ExecApprovalRequirement 14. apply_patch: handler + arg0 second personality 15. Sandbox three platforms and Landlock rules 16. AskForApproval and approval cache
Part IV · Context and orchestration 17. AGENTS.md discovery algorithm line by line 18. Skills · Plugins · Hooks · sub-agents
Part V · UI and outward face 19. TUI · Exec · Doctor 20. App-server · MCP · Rollout 21. Auth and model channel
Part VI · Summary 22. Distinctive designs, trade-offs, how to read, comparisons
Part VII · Scenario-level step-by-step walkthroughs (connect the points into stories)
23. Scenario: executing rm -rf build in an interactive session
24. Scenario: a multi-file apply_patch
25. Scenario: mid-turn compact when the context window is about to blow
26. Scenario: a stop hook demands continue-running
27. Scenario: codex exec + Never in CI
28. Scenario: AGENTS.md chain vs override — who wins
① What it solves Users searching “Codex” hit three things: the ChatGPT web cloud Agent, the IDE extension, and this repo’s CLI. Mixing them produces the useless question “why is there no web session page in the source.”
② What the source / docs look like
Root README.md is explicit: CLI runs locally; IDE is linked elsewhere; Cloud goes to chatgpt.com/codex. This analysis covers only the local runtime implemented in this monorepo.
③ Why OpenAI uses one brand across multiple surfaces, but the engineering repo only carries the open-sourceable local machine library. The cloud product need not, and should not, appear in this tree.
④ Checklist
- [ ] Are you analyzing the codex binary or the web page?
- [ ] Are you mistakenly treating app-server as “the complete web backend”?
Codex CLI = a Rust Agent runtime + Responses API tool surface + OS sandbox + AGENTS.md instruction loading + rollout session assets.
Taken apart:
| Part | Lands at |
|---|---|
| Runtime | codex-rs/core (Session / turn) |
| Tool surface | codex-rs/tools + core/tools/handlers |
| Sandbox | sandboxing + linux-sandbox + macOS Seatbelt + Windows Token |
| Instructions | codex-rs/core/src/agents_md.rs |
| Session assets | rollout |
| Facade | cli / tui / exec / app-server* |
LICENSE)curl …/codex/install.sh · npm i -g @openai/codex · brew caskcodex-cli/package.json’s bin.codex → bin/codex.js; the real logic is in the Rust binary| Neighbor | Relation |
|---|---|
| Claude Code | same-class product (local coding Agent), closed-source, full tool family |
| OpenCode | same-class open source, Bun/Effect, the server is the body, multi-provider |
| Open Design | host: treats codex as one engine; does not write the loop itself |
| MiMo-Code | OpenCode fork; tool ABI leans toward Codex style |
flowchart LR Brand["Codex brand"] --> CLI["this-repo CLI"] Brand --> IDE["IDE extension"] Brand --> Web["Codex Web cloud"] CLI --> Core["codex-rs"]
🧠 One sentence: first admit “the brand is large, the repo is specialized”; every later chapter only stands because of that.
① A large Rust product uses crate boundaries instead of “folder convention,” forcing dependency direction.
② The members list in codex-rs/Cargo.toml is about 126 items: core, tui, cli, tools, protocol, sandboxing, apply-patch, rollout, login, app-server*…
③ Root AGENTS.md:72-83 explicitly resists stuffing more into codex-core — admitting history is already bloated.
④ Check: should a new feature go into a new crate or core? Official policy’s answer is the former.
flowchart TB
subgraph L1["Facade layer"]
CLI["cli Multitool"]
TUI["tui"]
EX["exec"]
AS["app-server*"]
end
subgraph L2["Session layer"]
TH["CodexThread"]
SE["Session"]
TK["tasks/*"]
end
subgraph L3["Turn layer"]
RT["run_turn"]
SR["run_sampling_request"]
OR["ToolOrchestrator"]
end
subgraph L4["Side-effect layer"]
HD["handlers"]
SB["SandboxManager"]
FS["exec-server FS"]
end
CLI --> TH
TUI --> TH
EX --> TH
AS --> TH
TH --> SE --> TK --> RT --> SR --> OR --> HD --> SB
| Path | Signal |
|---|---|
tui/ ~14MB |
the interactive surface is the product facade |
core/ ~11MB |
the war room; policy says do not stuff more |
turn.rs 2741 lines |
single-file heart |
session/mod.rs 4154 lines |
Session monolith |
orchestrator.rs 533 lines |
where the approval+sandbox state machine is concentrated |
Root AGENTS.md also stipulates: module target <500 LoC, split if over 800; add few methods to TUI center files; just test not bare cargo test; context injection must use ContextualUserFragment.
These are not style-guide decoration; they are the key to understanding “why the files look like this.”
② cli/src/main.rs:90-119: MultitoolCli; bin_name = "codex"; subcommand_negates_reqs = true; field interactive: TuiCli.
③ User mental model is “type codex and chat”; ops capabilities unfold as subcommands, not polluting the default.
Subcommand about lines 122–211)A. Run tasks
- exec/e: non-interactive
- review: non-interactive review
B. Identity
- login / logout
C. Extension surface
- mcp / mcp-server
- plugin
- app-server / remote-control / platform app
D. Session assets
- resume / fork / archive / unarchive / delete
E. Security and diagnostics
- sandbox: run a command in the sandbox (HostSandboxArgs)
- doctor
- debug (DebugCommand)
- execpolicy (hidden)
F. Other
- apply/a: git apply an agent diff
- cloud: cloud tasks (experimental)
- features / update / completion
- Hidden: responses-api-proxy, stdio-to-uds
Note:
exec-serveris marked with a/// [EXPERIMENTAL]doc comment atmain.rs:206-207, but has nohide = true, so it is a publicly visible subcommand and should not be listed in the hidden group (verification correction 2026-08-04).
| Trade-off | Meaning |
|---|---|
| Swiss-army-knife CLI | high learning cost, but the ops closed loop is all in one binary |
| Hide dangerous/internal commands | reduce misuse; when analyzing, compare --help with source |
macOS/Windows conditional compile app |
desktop is not first-class on every platform |
flowchart LR
A["codex"] --> B{"subcommand?"}
B -->|none| C["TUI"]
B -->|exec| D["non-interactive turn"]
B -->|resume| E["rollout restore"]
B -->|mcp-server| F["be MCP itself"]
B -->|sandbox| G["sandbox debug"]
① UI/protocol should not directly touch Session internal locks.
② codex-rs/core/src/codex_thread.rs:182 struct CodexThread; submit around line 225 hands the Op in.
③ Facade pattern lets TUI / app-server / exec share the same submit semantics.
④ Finding “the first stop after the user hits send” → follow from Thread.submit.
From codex-rs/core/src/session/mod.rs:793:
submit(op)submit_with_tracesubmit_with_idsubmit_user_input_with_client_user_message_id (813+)The same session also has active_turn, input_queue, services (model client, exec policy, plugins…).
codex-rs/core/src/session/inject.rs:
inject_if_running: while the model is running, stuff input into the current turntry_start_turn_if_idle: only start a new turn when idle; Plan mode refuses some automatic turns (about lines 89–92)③ This explains why “type while it is running” does not drop characters in the product, and why Plan is more “quiet.”
Under tasks/ there are also compact, review, and other task types (RegularTask is in codex-rs/core/src/tasks/regular.rs). Session uses spawn_task to hang different SessionTask implementations.
codex-rs/core/src/tasks/regular.rs is about a hundred lines in full, but it is the adapter from UI turn → run_turn.
| Step | Lines about | What it does |
|---|---|---|
| 1 | 47–58 | emit TurnStarted (including mode, context window, trace) |
| 2 | 59 | set_server_reasoning_included(false) |
| 3 | 60–74 | consume startup prewarm: Cancelled→early return; Ready→bring prewarmed session |
| 4 | 77–92 | outer loop: run_turn; if no pending, return; if pending, next_input=[] and run again |
| Layer | Location | Why it loops |
|---|---|---|
| Outer | RegularTask | the user interjected again / there are still trigger items in the queue |
| Inner | run_turn | the model still wants tools / stop hook continue-runs / compact then continue |
stateDiagram-v2 [*] --> Prep: TurnStarted + prewarm Prep --> Inner: run_turn Inner --> Inner: pending input Inner --> [*]: no pending
The outer loop in full is just these few lines (codex-rs/core/src/tasks/regular.rs:76-92), but two places are worth pointing out separately:
let mut next_input = input;
loop {
let last_agent_message = run_turn(
..., next_input,
prewarmed_client_session.take(), // ← ①
cancellation_token.child_token(), // ← ②
).await?;
if !sess.input_queue.has_pending_input(&sess.active_turn).await {
return Ok(last_agent_message);
}
next_input = Vec::new(); // ← ③
}
① .take(): prewarm is only for the first lap
prewarmed_client_session is Option; .take() empties it. So only the outer loop’s first run_turn gets the prewarmed connection; every later lap is None, going through sess.services.model_client.new_session().
③ Why: prewarm is to mask cold-start latency — the wait at the moment the user hits enter. The second lap is already in conversation; there is no first-token latency to mask, so occupying a prewarmed connection has no meaning.
② child_token(): one child token per lap
It does not pass the same cancellation_token down; each lap derives a child token. Parent-token cancel cascades to all children, but cancel inside a single lap does not pollute the whole task in reverse.
③ next_input = Vec::new(): from the second lap, no new input is carried
This is where “interjection” semantics land: the first lap runs with this user input; if after finishing it finds more in the queue (the user typed again during model output), the next lap passes empty input — because those interjections are already in input_queue, drained by logic inside run_turn; they must not be stuffed again from outside.
🧠 Trade-off: putting “interjection” on the outer layer rather than stuffing it into sampling retry avoids mixing user input and model tool-followup into the same control flow. The cost is that the outer layer pays a full
run_turnentry overhead again (pre-compact, MCP wake, step-context freeze all start over) — Codex chose control-flow clarity, not saving this one overhead.
File:
codex-rs/core/src/session/turn.rs(2741 lines). Entry:run_turn@ 153.
run_turn(sess, turn_context, turn_extension_data, input,
prewarmed_client_session, cancellation_token)
-> CodexResult<Option<String>>
The returned Option<String> is a “last agent message” class result, for the outer layer/hooks. cancellation_token runs through — cancel is not after-the-fact politeness; it is a first-class parameter.
Calls run_pre_sampling_compact (defined @ 983):
maybe_run_previous_model_inline_compact — when the model changed / compaction hash changed, use the old model for compatibility compact (comments 1047–1056)context_window_token_status; if token_limit_reached, capture step_context then run_auto_compact(..., CompactionPhase::PreTurn)① What it solves: avoid sampling while carrying already-blown-window history. ③ Why compact first then record new input: a TODO comment (163–166) admits the ideal is “estimate upcoming tokens then compact”; the current order is still a compromise. ④ Check: when you change compact thresholds, are pre-turn and mid-turn (later) semantically consistent?
required_mcp_servers_for_input: decide by user input which MCP to lazy-wake, rather than opening all every time.
On cancel, record hooks/inputs then return.
capture_step_context_with_required_mcp_servers: freeze this step’s visible tool set, world state, config snapshot.
Later sampling and tool calls must share the same view (comment 294: context / advertised tools / tool calls, same request view).
③ This is “a reproducible step” — when debugging “why didn’t this round have a certain tool,” look at step_context first, not global config.
tokio::join!: one side record_context_updates_and_set_reference_context_item, the other computes turn_diff_display_roots.
Parallelism is for startup latency; semantically both serve “this turn’s visible world.”
build_skills_and_plugins: on failure return Ok(None) early — skill assembly is not optional decoration; failure means do not start the run.
run_pending_session_start_hooksrun_hooks_and_record_inputsPreviousTurnSettings (model slug, comp_hash, realtime)④ Check: when a session start hook returns “should stop,” do you mistakenly think it is an empty model reply?
can_drain_pending_input: often false at turn start — sample this round’s input first, then drain interjectionsflowchart TB A["run_turn enter"] --> B["pre_sampling_compact"] B --> C["MCP required"] C --> D["capture_step_context"] D --> E["skills/plugins"] E --> F["hooks + record input"] F --> G["loop: sampling"]
Each lap may:
rollout_budget::maybe_record_reminderstep_context (when there is pending user input, recompute MCP from the input)time_reminder::maybe_record_current_time_reminderclone_history().for_prompt(modalities) to construct sampling inputrun_sampling_request(...) (defined @ 1308). On success you get:
model_needs_follow_uplast_agent_messageBranch A · needs follow-up
accept_mailbox_delivery_for_current_turn (364–369)can_drain_pending_input = trueneeds_follow_up = model_needs_follow_up || has_pending_inputBranch B · should_roll_over (new window / token top) (419–458)
needs_follow_up && (new_context_window_request || token_limit_reached)run_auto_compact(..., CompactionPhase::MidTurn), injection strategy BeforeLastUserMessagecan_drain_pending_input = !model_needs_follow_up then continueComment 430: believe compact can pull the window back, therefore not afraid of infinite loop — this is an explicit product bet.
Branch C · does not need follow-up → stop protocol (461–510)
run_turn_stop_hooksshould_block and there are continuation fragments → record into history, open mailbox, stop_hook_active=true, continue (the hook demands continue-running)should_stop → breakOk(None)breakBranch D · errors
TurnAborted rethrown| Signal | Behavior |
|---|---|
| model still wants tools | continue |
| user interjection in queue | continue |
| token top and still needs to continue | mid-turn compact then continue |
| stop hook block+prompt | inject continuation continue |
| stop hook stop | break |
| none of the above | break, bring last_agent_message back |
flowchart TB
S["sampling returns"] --> F{"needs_follow_up?"}
F -->|yes| R{"should_roll_over?"}
R -->|yes| C["mid-turn compact"] --> S
R -->|no| S
F -->|no| H["stop hooks"]
H --> B{"block?"}
B -->|yes+has prompt| S
B -->|no| K{"stop?"}
K -->|yes| X["break"]
K -->|no| X
🧠 One sentence: Codex’s “stop” is not
if !tool_calls; it is a three-party protocol of follow-up × compact × stop hook.
Step by step:
base_instructionsToolCallRuntime (router + sess + step_context + diff tracker)code_mode_service.start_turn_worker (code-mode parallel worker)for_prompt from historybuild_prompttry_run_sampling_request(output, original_input)ContextWindowExceeded / UsageLimitReached: update state then return the errorhandle_retryable_response_stream_error and record_sampling_retry③ Recoverable WebSocket/stream errors do not kill the whole turn; context-window blow and quota hard errors fail immediately.
Key structure:
feedback_tags! stamps model, approval_policy, sandbox_policy, effort, auth, features (2176–2183) — observability first-classclient_session.stream(prompt, ...) opens the stream (2195–2208), cancellableFuturesOrdered in_flight: future queue for parallel tool calls (2209)needs_follow_up / last_agent_message accumulatorsPlanModeStreamState (2221–2223)loop pulls stream events (2228+)Whether the model supports parallel: supports_parallel_tool_calls (about line 1289, near the built path).
| Layer | Duty |
|---|---|
run_sampling_request |
retry, swap prompt input, quota/window errors |
try_run_sampling_request |
single stream lifecycle, tool concurrency, UI events |
Split, tests and tracing (receiving_stream span) are cleaner.
plan_mode branch events differFuturesOrdered overlaps multiple tasks| Phase | Function / call site | Reason enum |
|---|---|---|
| PreTurn | run_pre_sampling_compact @983 |
token_limit_reached |
| MidTurn | should_roll_over inside run_turn @431 |
ContextLimit + new-window request |
| Previous model | maybe_run_previous_model_inline_compact @1051 |
comp_hash changed or smaller window |
Only triggers when both sides have a hash and they differ — missing hash does not compact blindly. This is to avoid “mis-compact when information is incomplete” destroying the cache.
⚠️ Verification correction (2026-08-04): the original draft treated lines 1032–1040 as previous-model compact’s “on/off preconditions.” That is putting the wrong hat on.
turn.rs:1032-1041 is actually capture_current_model_fallback_step_context (defined @1026); its doc comment states its use is "Captures the current model's request-scoped state for retrying previous-model compaction" — it only decides whether, on failure, you can get a fallback step context for retry, not the compact switch itself; its Ok(None) return is only “do not capture fallback context,” not “do not compact.”
The real trigger gate is maybe_run_previous_model_inline_compact (turn.rs:1057-1119); the three trigger paths none of them check whether the auth backend or provider is OpenAI:
1. comp_hash_changed is true (compact directly @1071); or
2. previous_model_limit_reached && slug different && old_context_window > new_context_window (compact @1116-1118).
Corrected conclusion: on third-party providers, previous-model compact still triggers as usual; the extra Codex-backend + OpenAI judgment is only for “whether, when compact fails, you can fall back to fallback step context,” not “whether to compact.” The original sentence “do not play this compatibility compact on third-party providers” should be deleted.
Mid-turn uses BeforeLastUserMessage { world_state, step_context } — after compact, world state must be stuffed back at the correct position, avoiding “the summary covering the last user utterance.”
Compact is one of the six constitutions’ executors: history only incremental, bounded, protect cache. Recklessly changing compact injection position directly violates AGENTS.md:91-100.
Root AGENTS.md:91-100 spirit, transcribed:
| # | Rule | Engineering meaning |
|---|---|---|
| 1 | No history rewrite | forbid “stealth-editing an already-sent prefix” |
| 2 | Move the prefix little | protect prompt cache, save money, cut latency |
| 3 | Bounded + hard cap | prevent memory-leak-style infinite injection |
| 4 | Single item ≤10K tokens | hard limit |
| 5 | New items that may be >1k = P0 human review | process gate |
| 6 | Must ContextualUserFragment |
type system forces registration |
Contrast
④ Checklist (for PR reviewers) - [ ] Does the new injection go through Fragment? - [ ] Did it rewrite an old history entry? - [ ] Could a single item be >1k? Is it marked P0?
codex-rs/core/src/session/turn.rs:1459+): one turn’s tool universeVerification correction (2026-08-04): the original draft only wrote “1459+” without a file path. The real location is
codex-rs/core/src/session/turn.rs:1459(not under thetools/directory); signaturebuilt_tools(sess, turn_context, environments, mcp, step_store, prepared_recommendations) -> (Vec<ToolInfo>, Arc<ToolRouter>).
Inputs: Session, TurnContext, environments, MCP binding, extension store, prepared recommendations.
Outputs: (Vec<ToolInfo>, Arc<ToolRouter>).
In between it: pulls MCP tools, merges connectors by apps_enabled, handles tool_suggest recommendation candidates, then assembles the router. ① “What tools the model can see this step” is computed, not a global constant.
codex-rs/core/src/tools/handlers/mod.rs exports: Shell, ApplyPatch, ViewImage, Plan, MCP, multi_agents, unified_exec, RequestUserInput/Permissions, Plugins, ContextRemaining, WaitForEnvironment, Dynamic…
Verification correction (2026-08-04):
CodeModeis not in thehandlers/mod.rsexport table — it iscodex-rs/core/src/tools/code_mode/, declared bypub(crate) mod code_mode;attools/mod.rs:2, a sibling module ofhandlersrather than a submodule.
Difference from Claude Code (state it clearly):
| Claude Code | Codex | |
|---|---|---|
| File read | often a standalone Read tool | often lands in shell / others |
| Patch | Edit/Write | apply_patch first-class |
| Search code | Grep/Glob | often shell; web uses hosted WebSearch |
| Parallel | product-defined | FuturesOrdered + model capability |
Constructed in run_sampling_request with router+session+step+diff (1323–1328). During streaming, tool calls are dispatched to it, not scattered across match arms.
codex-rs/core/src/tools/orchestrator.rs:4-7 comments write the order in stone:
approval → select sandbox → attempt → retry with escalated sandbox on denial (do not re-approve, because already approved)
This is the skeleton of the whole chapter.
codex-rs/core/src/tools/sandboxing.rs:156-175)| State | Meaning |
|---|---|
Skip { bypass_sandbox, proposed_amendment } |
no need to ask a human; first attempt may already skip sandbox |
NeedsApproval { reason, proposed_amendment } |
must ask a human; may attach a policy-amendment proposal “similar commands later, skip asking” |
Forbidden { reason } |
forbidden outright |
It is two-stage; the order cannot be mixed (easy to misread; expand):
// Stage 1: look only at policy × fs.kind, compute needs_approval
let needs_approval = match policy {
Never => false,
OnRequest | Granular(_) => matches!(fs.kind, FileSystemSandboxKind::Restricted),
UnlessTrusted => true,
};
// Stage 2: when needs_approval is true, Granular gets a chance to upgrade it to Forbidden
if needs_approval && matches!(policy, Granular(cfg) if !cfg.allows_sandbox_approval()) {
Forbidden { reason: "approval policy disallowed sandbox approval prompt" }
} else if needs_approval { NeedsApproval { .. } }
else { Skip { bypass_sandbox: false, .. } }
Truth table (allows_sandbox_approval() written as ASA):
| policy | FS = Restricted | FS ≠ Restricted |
|---|---|---|
Never |
Skip | Skip |
UnlessTrusted |
NeedsApproval | NeedsApproval (independent of FS) |
OnRequest |
NeedsApproval | Skip |
Granular · ASA=true |
NeedsApproval | Skip |
Granular · ASA=false |
Forbidden | Skip ← note this cell |
⚠️ The bottom-right cell is the easiest to get wrong:
Granularturned sandbox approval off, but when the filesystem is not Restricted, the result is Skip not Forbidden. BecauseForbiddenhangs on the premise ofneeds_approval— when you do not need to ask a human, the “must not ask a human” config does not participate in the judgment at all.③ The subtle point still holds, but say it accurately: when the system originally intended to pop a dialog, and policy forbids popping, Codex chooses Forbidden rather than silent allow — preventing the mental error “I turned off the dialog = I allow everything.” And in scenes that originally did not need to ask, it will not refuse one extra time for nothing. This is a precise rule, not a one-cut.
sandbox_override_for_first_attempt:
NoOverridebypass_sandbox=true → first attempt may Bypassunsandboxed_execution_allowed (279–283): if there is a denied-read restriction then false.
sandbox_permissions_preserving_denied_reads (285–298): when escalated meets denied-read, drop back to UseDefault, avoiding “raise privilege = tear down read-only deny.”
🧠 One sentence: when raise-privilege conflicts with “deny read,” Codex chooses keep the deny-read, not satisfy the raise.
approval_keys: apply_patch can have many keys (many files)should_bypass_approval: already approved or Neverwants_no_sandbox_approval: UnlessTrusted always; Never never; Granular looks at configToolRuntime::run(req, SandboxAttempt, ctx): actually executeCarries: SandboxType, whether sandbox was requested, permission portrait, linux-sandbox exe, legacy landlock, Windows level/private desktop, network proxy, cancellation…
This is the complete environment manual of “one attempt.”
flowchart LR R["Tool request"] --> A["ExecApprovalRequirement"] A -->|Forbidden| X["deny"] A -->|NeedsApproval| U["ask human / cache"] A -->|Skip| S["select SandboxType"] U --> S S --> T["SandboxAttempt.run"] T -->|sandbox deny and can escalate| S2["escalated retry no longer asks human"]
codex-rs/core/src/tools/handlers/shell.rs:63)Feature::ExecPermissionApprovals is onapply_granted_turn_permissions merges rights already granted this turnintercept_apply_patch — secretly patching inside shell is still caught onto the structured pathcreate_exec_approval_requirement_for_command (175–190)ShellRequest, ToolOrchestrator::run + ShellRuntimePrevent the model from using apply_patch <<'EOF' to walk a “pure shell string” around patch approval keys (multi-file keys) and the parser.
A structured patch must walk a structured gate.
And this intercept is not only in one place. Searching the whole repo for intercept_apply_patch there are only two call sites; they happen to be the two roads on which the model can run a command:
| Call site | What this road is |
|---|---|
codex-rs/core/src/tools/handlers/shell.rs:142 |
classic shell tool |
codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs:314 |
unified_exec backend (persistent shell session) |
🔑 This is the complete argument. Blocking only one road equals not blocking — the model switches backends and goes around. In other words: the invariant “a structured patch must walk a structured gate” is maintained by “inserting the same interceptor at every entry that can execute a command,” not by one tool’s self-discipline.
④ Check: if you add a third execution backend to Codex,
intercept_apply_patchis something that must be wired together — otherwise you open a silent patch bypass.
Classic shell vs unified_exec vs zsh-fork: a combination of feature flags decides the backend; comments emphasize you must not silently fall onto an un-enabled backend. ④ Check: when turning on zsh-fork, are feature + user shell + path all satisfied?
UnlessTrusted depends on a “known-safe read-only” judgment (protocol comment @ codex-rs/protocol/src/protocol.rs:909-911). What actually lands on the Shell path is exec_policy + orchestrator, not the model’s self-discipline alone.
codex-rs/apply-patch: parse_patch, StreamingPatchParser, Hunk… can land on ExecutorFileSystem.
codex-rs/core/src/tools/handlers/apply_patch.rs (666 lines): implements approval keys (per file), integrated with orchestrator.
One patch, many files → many approval keys (sandboxing comments 65–70, 316–321).
codex-rs/arg0/src/lib.rs)| Mechanism | Lines about | Role |
|---|---|---|
APPLY_PATCH_ARG0 = "apply_patch" |
20 | if executable filename matches, enter patch main |
Compatible applypatch |
21 | prevent misspelling |
| Special argv1 | 114+ | explicit internal call |
| UNIX symlink / Windows bat | 322+ | so PATH can type it directly |
① Distribution cost: in the user environment “there is an apply_patch command,” but it is actually the same Codex binary. ③ Same family as linux-sandbox’s arg0 trick — one artifact, multiple personalities.
codex applygit apply the agent-produced diff onto the worktree — a human-in-the-loop “accept the patch” exit, different from the model tool path.
flowchart TB M["model"] --> T1["apply_patch tool"] M --> T2["apply_patch text inside shell"] T2 --> I["intercept_apply_patch"] T1 --> H["ApplyPatchHandler"] I --> H H --> O["Orchestrator"] O --> P["apply-patch crate lands on disk"] U["user"] --> A["codex apply"] --> G["git apply"]
codex-rs/sandboxing/src/manager.rs:34-73:
None | MacosSeatbelt | LinuxSeccomp | WindowsRestrictedToken
get_platform_sandbox: macOS→Seatbelt; Linux→Seccomp; Windows looks at the switch→Token or None.
Auto | Require | Forbid (54–58) — a tool can declare whether it wants a sandbox.
SandboxExecRequest only converts PathUri to native paths at the execution boundary (exec-server / equivalent). The orchestration layer keeps URIs — remote execution and local execution, the same permission model.
codex-rs/linux-sandbox/src/landlock.rs)⚠️ Verification correction (2026-08-04): the original draft presented Landlock as Linux’s live filesystem sandbox; that is the framework inverted. Source itself says Landlock’s filesystem ruleset is currently not enabled (legacy/fallback path); the Linux filesystem sandbox actually running is bubblewrap (see 15.7).
landlock.rs:41 original: “Filesystem restrictions are intentionally handled by bubblewrap.” :135-136: “this is currently unused because filesystem sandboxing is performed via bubblewrap. It is kept for reference and potential fallback use.”
So what actually takes effect in the Landlock module is the network/process-side seccomp constraints (PR_SET_NO_NEW_PRIVS + network filter, landlock.rs:42-88), not the FS ruleset (:144-158’s / read-only rule is currently dead code). When reading this code, do not treat install_filesystem_landlock_rules_on_current_thread as the main path.
Root AGENTS.md:8-10: child processes carry CODEX_SANDBOX=seatbelt; tests under Seatbelt early-exit certain cases.
Sandbox is one dimension of the test matrix, not an optional plugin.
codex-rs/core/src/windows_sandbox.rs: elevated setup, private desktop, mode persistence, metrics. Non-Windows targets bail directly.
codex-rs/sandboxing/src/bwrap.rs: bubblewrap is the main implementation of Linux filesystem sandbox (Landlock FS ruleset has retreated to legacy/fallback). bwrap.rs:24 warns when system bwrap lacks user namespace — only in the few environments that cannot walk bubblewrap might it fall back to the Landlock FS path. Reading order should be swapped with 15.4: look at bwrap first, then Landlock’s “not enabled” comments, avoiding the mistaken belief that Landlock is carrying FS isolation. (Verification correction 2026-08-04)
| Layer | Question |
|---|---|
| AskForApproval | does the human agree |
| SandboxType | does the kernel allow |
Both must pass; then the side effect happens.
codex-rs/protocol/src/protocol.rs:908-931)| Gear | One sentence |
|---|---|
| UnlessTrusted / untrusted | only let known-safe read-only through; everything else ask a human |
| OnRequest (default) | the model decides when to request |
| Granular | allow/deny by class; can forbid the dialog but it becomes Forbidden |
| Never | do not ask; failure returns to the model (CI-friendly) |
ApprovedForSessionThe orchestrator also hangs begin_network_approval / deferred mode — network is not a gift of the FS sandbox; it is another approval belt.
| Scenario | Suggested combination |
|---|---|
| Interactive daily | OnRequest + platform default sandbox |
| CI | Never + Restricted FS + no network or proxy |
| High sensitivity | UnlessTrusted + denied-read list |
| Automated but need audit | Granular open critical classes, close dangerous classes |
codex-rs/core/src/agents_md.rs:38-44)DEFAULT_AGENTS_MD_FILENAME = "AGENTS.md"LOCAL_AGENTS_MD_FILENAME = "AGENTS.override.md"\n\n--- project-doc ---\n\nFor each turn environment:
ExecutorFileSystem (can be remote)read_agents_md③ Multiple environments (local + remote exec-server) can each have their own instructions.
This is the mechanism in the whole chapter easiest to miss, with the most real consequences. State the default first (codex-rs/config/src/config_toml.rs:68):
pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
codex-rs/core/src/config/mod.rs:207 aliases it as AGENTS_MD_MAX_BYTES; :4071 is the fallback. 0 disables the whole feature (agents_md.rs:95-99).
The key is that this 32 KiB is not “a cap per file,” but one budget shared by the whole chain (agents_md.rs:107-143):
remaining = max_total # :107
for p in paths: # paths are already root → cwd order
if remaining == 0: break # :111-113 budget exhausted; later files are not even read
data = read(p)
if size > remaining:
data.truncate(remaining) # :122-124 the file that crosses the budget is 【cut in half】
warn("project doc exceeds remaining budget; truncating")
text = utf8_lossy(data)
if text.strip(): # :133 blank files do not count
entries.push(...)
remaining -= len(data) # :144 only non-empty content deducts budget
Three non-intuitive consequences:
| Behavior | Consequence |
|---|---|
remaining decreases along root → cwd |
repo-root AGENTS.md eats the budget first; deeper directories are more likely to get none |
the file that crosses the budget is truncated, not skipped |
you get a half instruction manual — and it breaks in the middle, possibly cutting off the most important rule |
deduction is written inside if !text.trim().is_empty() |
blank / pure-empty-line AGENTS.md does not consume budget; leaving it around does not hurt |
⚠️ Practical trap: a monorepo, root
AGENTS.mdwrites 30 KB of general norms,apps/api/AGENTS.mdwrites 5 KB of service-specific rules — the latter is only read in 2 KB; remaining 3 KB silently lost, logs only have onewarn. You will think “why doesn’t the model follow our service’s rules,” but the problem is not the model.④ Check: - [ ] Is your root AGENTS.md already close to 32 KiB? - [ ] Are the more specific, more-should-win rules exactly at the end of the chain (the position most easily truncated)? - [ ] When something goes wrong, first look for the
project doc exceeds remaining budget; truncatingwarn
agents_md.rs:234-248 decides each directory which filenames to try, in what order:
names.push(LOCAL_AGENTS_MD_FILENAME); // "AGENTS.override.md" ← first
names.push(DEFAULT_AGENTS_MD_FILENAME); // "AGENTS.md"
for candidate in &config.project_doc_fallback_filenames { // user-configurable fallback names
if !candidate.is_empty() && !names.contains(&candidate) { names.push(candidate); }
}
On probe return Ok(Some(candidate)) — each directory takes only the first hit, no stacking (:220-227).
So the accurate semantics of “override” is: inside the same directory AGENTS.override.md makes AGENTS.md completely ineffective (not merge, not append), but does not affect other directories — other layers on the chain still participate as usual.
📌 This differs from Claude Code’s
CLAUDE.local.mdmental model: there it is multi-source stacking; here it is single-choice per layer + concatenation across layers.
LoadedAgentsMd stores a provenance-bearing entry list (entries: Vec<InstructionEntry>, each with InstructionProvenance), not one concatenated string. Real assembly is in text() (:310-316):
pub fn text(&self) -> String {
if self.has_multiple_project_environments() { self.environment_labeled_text() }
else { self.legacy_text() }
}
When multiple environments (local + remote exec-server both present) it switches to the environment-labeled version — because then “which machine does this rule belong to” becomes information that must be expressed. Single environment walks legacy.
The separator appears in only one place (:333-337, the comment is very blunt):
// The project-doc marker tells the model where workspace-scoped
// instructions begin, so it is only needed on the transition
// from user or internal instructions to project instructions.
let separator = if is_project && !previous_was_project { AGENTS_MD_SEPARATOR } else { "\n\n" };
🔑 Do not remember this wrong:
--- project-doc ---is not the separator between every AGENTS.md on the chain. It appears once — at the junction where “user/internal instructions” switch to “project instructions.” Between two adjacent project docs on the chain, ordinary\n\nis used.③ Why: this marker is a semantic landmark for the model (“from here on are this workspace’s rules”), not a typesetting divider. Putting it too many times dilutes its signal.
project_root_markers (default .git)find_nearest_ancestor_with_markers finds the project rootcandidate_filenames (override-priority logic is in the candidate list)MAX_CONCURRENT_ANCESTOR_PROBES = 256 (49)| CLAUDE.md | AGENTS.md (Codex) | |
|---|---|---|
| Direction | multi-source stack (managed/user/project…) | root→cwd chain + user instructions |
| Override | @imports etc. | AGENTS.override.md |
| Root judgment | product rules | project_root_markers configurable |
| Remote FS | depends on implementation | first-class (ExecutorFileSystem) |
openai/codex’s own root AGENTS.md constrains “the agent changing this repo” — Seatbelt env vars, do not touch sandbox constants, core-bloat policy, the six context constitutions.
The product loader and the engineering constitution share a name and a shape.
flowchart TB CWD["cwd"] --> UP["walk up looking for markers"] UP --> ROOT["project root"] ROOT --> DOWN["root→cwd directory list"] DOWN --> F["each layer AGENTS.md / override"] F --> CAT["concat + byte cap"] CAT --> CTX["inject into model context"]
run_turn build_skills_and_plugins before sampling (defined @720, called @220). Failure → whole turn Ok(None).
prepare_tool_recommendations / built_tools then handle tool_suggest and connector merge.
| Timing | Function clue |
|---|---|
| Session start | run_pending_session_start_hooks |
| Record user input | run_hooks_and_record_inputs |
| Pre tool | registry run_pre_tool_use_hooks |
| Post tool | run_post_tool_use_hooks |
| Turn stop | run_turn_stop_hooks (can block continue-run) |
| Legacy after agent | run_legacy_after_agent_hook |
A Stop hook can block ending and inject a continue-run prompt (Chapter 7) — hooks are a control plane, not logs.
This is much more complex than “add a prefix”; worth spreading out.
① The prefix is not unconditional (codex-rs/codex-mcp/src/tools.rs:22):
const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
Note LEGACY in the constant name. Whether to add is decided by Config::prefix_mcp_tool_names() (codex-rs/core/src/config/mod.rs:1756-1759):
pub(crate) fn prefix_mcp_tool_names(&self) -> bool {
!self.features.enabled(Feature::NonPrefixedMcpToolNames)
|| self.non_prefixed_mcp_tool_servers.is_some()
}
Reading: prefix by default; to remove it you must turn on Feature::NonPrefixedMcpToolNames; but as soon as non_prefixed_mcp_tool_servers is configured (per-server exemption list), it returns to global prefix + exemption inside the list (tools.rs:141).
② Names the model sees have a hard constraint (doc comment at tools.rs:107-109):
Raw MCP server/tool names are kept on each
ToolInfofor protocol calls, whilecallable_namespace/callable_nameare sanitized and, when necessary, hashed so every model-visible name is unique and <= 64 bytes.
MAX_TOOL_NAME_LENGTH = 64 (:226).
sanitize itself is rougher than imagined (codex-rs/codex-mcp/src/mcp/mod.rs:450-465):
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '_' { sanitized.push(c); }
else { sanitized.push('_'); }
}
if sanitized.is_empty() { "_".to_string() } else { sanitized }
The keep-set is only [A-Za-z0-9_]. Two consequences worth remembering:
| Input | Result |
|---|---|
repo-tools (hyphen) |
repo_tools — even - is not kept |
仓库管理 (Chinese) |
____ — each Chinese character becomes one underscore |
| all illegal characters | "_" |
⚠️ Chinese-named MCP servers will go wrong:
仓库管理and部署运维are both four characters; after sanitize they become exactly the same____, judged as a name collision, each hung with a hash suffix; the model finally sees something likemcp_______f61079/____— readability is completely lost, and tool-selection quality drops with it.④ Check: when connecting MCP servers, use ASCII for server / namespace / tool names, and do not use hyphens.
③ Dedup is two rounds, and uses a content hash not a serial number (:153-195):
| Round | What it detects | On collision |
|---|---|---|
| First | the same callable_namespace mapping to multiple different original identities |
append_namespace_hash_suffix — append a hash suffix to the namespace |
| Second | the same (namespace, name) corresponding to multiple original identities |
append_hash_suffix — append a hash suffix to the name |
The hash is SHA-1 taking the first several digits (:237-246):
let mut hasher = Sha1::new();
hasher.update(s.as_bytes());
...
format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN])
④ Finally sort by raw_tool_identity (:196) then produce.
🔑 Why hash rather than
_1_2serials: serials depend on traversal order — today serverA connects first and is calledfoo; tomorrow serverB connects first and it is calledfoo; the tool name the model sees drifts between two sessions, destroying prompt cache and the model’s tool memory. The hash is decided by content (raw_namespace_identityconcatenated fromserver_name \0 callable_namespace \0 connector_id); the same tool is always the same name, independent of connection order. The explicitsort_by(raw_tool_identity)at the end is a second insurance of the same motive.This design principle can be carried away directly: for any identifier that must be stably exposed to the model, the dedup suffix must be decided by content, not by order.
④ Check: when you see a
_a1b2c3at the tail of a tool name, do not think it is random — that is evidence of a name collision, meaning you connected two same-named servers/tools.
codex-rs/core/src/agent/registry.rs)exceeds_thread_spawn_depth_limit④ Check: is recursive spawn stuck by depth? On nickname conflict, is it readable?
codex-rs/protocol/src/config_types.rs:630)Easy to misread; say it clearly: the enum has 4 variants, but only 2 are externally visible.
pub enum ModeKind {
Plan,
#[default]
#[serde(alias = "code", alias = "pair_programming",
alias = "execute", alias = "custom")]
Default,
#[doc(hidden)] #[serde(skip_serializing, skip_deserializing)]
PairProgramming, // hidden variant
#[doc(hidden)] #[serde(skip_serializing, skip_deserializing)]
Execute, // hidden variant
}
| You write in config | Which variant it actually lands on |
|---|---|
plan |
Plan |
default / code / pair_programming / execute / custom |
all land on Default |
| — | PairProgramming / Execute cannot be reached from config (skip_deserializing) |
Key distinction: code, execute etc. are serde aliases hung on Default, not independent modes.
And PairProgramming / Execute are real variants that exist but are hidden — they skip_serializing + skip_deserializing + schemars(skip) + ts(skip), i.e. not into JSON, not into JSON Schema, not into TS type definitions.
“There are only two modes” is nailed by one constant (:653):
pub const TUI_VISIBLE_COLLABORATION_MODES: [ModeKind; 2] = [ModeKind::Default, ModeKind::Plan];
③ Why this: keep historically existing modes as variants (old rollout deserialization; internal code paths may still reference), but simultaneously cut all their external surfaces. This is a standard gradual-deprecation technique — safer than deleting the variants outright, cleaner than leaving them so users can configure them.
④ Check: when you see mode = "execute", do not think you entered the Execute variant — you entered Default.
No subcommand enters TUI. AGENTS.md on center files such as chatwidget.rs: add few methods, please split modules, target <500 LoC.
Read TUI by “orchestration file vs feature module”; do not look for business truth in a 1400-line app.rs — business is in core.
codex exec → codex_exec::Cli. Typical pairing: AskForApproval::Never + tight sandbox.
Docs docs/exec.md link out to the official site.
Diagnose install, config, auth, runtime. When something is wrong, codex doctor first, then read source.
debug models / debug prompt-input / app-server messages — print “what the model can see”; the debug exit of context discipline.
A cluster of crates: app-server, protocol, daemon, client, transport…
CLI: app-server, remote-control, platform app.
Root AGENTS.md has a dedicated section on app-server API best practices — breaking changes must search these surfaces.
| Role | Entry |
|---|---|
| Client | codex mcp manages external servers |
| Server | mcp-server stdio exposes itself |
Inside a turn, lazy-wake required servers (Chapter 6).
codex-rs/rollout/src/lib.rs)sessions / archived_sessionsresume/fork/archive/delete③ A session is a first-class filesystem asset; backup/fork/audit are all built on this API.
| OpenCode | Codex | |
|---|---|---|
| Body metaphor | HTTP server | core library + multiple frontends |
| IDE | client connects to server | app-server protocol cluster |
| Local default | TUI nested in server | TUI drives core directly |
login/)| Method | Clue |
|---|---|
| ChatGPT OAuth/PKCE | server.rs / pkce.rs |
| Device code | device_code_auth.rs (run_device_code_login @234) |
| API Key | CLI run_login_with_api_key; env telemetry distinguishes OPENAI_API_KEY / CODEX_API_KEY |
Recommend ChatGPT plan login — symmetric with Claude Code’s “subscription” narrative: the CLI is the local surface of plan entitlements.
Sampling walks Responses API shape (ToolSpec comments chain to platform.openai.com).
The workspace contains ollama, lmstudio and other crates — local/OSS paths exist; default routing treats runtime config / codex debug models as canonical; this lecture does not nail easily-changing default slugs.
Chapter 9: only Codex backend + OpenAI provider does previous-model compatibility compact. When switching to a third-party provider, do not expect this behavior.
A. OS sandbox as a first-class citizen Not a prompt writing “please be careful,” but Seatbelt/Landlock/Token + when denied-read conflicts with raise-privilege, keep the deny.
B. Approval × sandbox × execpolicy three-layer synthesis
Orchestrator synthesizes human review, kernel, and command policy into SandboxAttempt; Granular turning off the dialog → Forbidden to prevent misunderstanding.
C. Context constitution into the repo
The six rules + Fragment type + debug prompt-input (the real command is run_debug_prompt_input_command → codex_core::build_prompt_input; there is no debug_input identifier in the whole repo — verification correction 2026-08-04).
docs/ often jumps out; offline you rely only on source and this analysis.cli/src/main.rs subcommand map (30 minutes)codex-rs/core/src/tasks/regular.rs full text (15 minutes)codex-rs/core/src/session/turn.rs:153-520 entry+stop (2 hours) — note the repo has 3 same-named turn.rs; the real one is core/src/session/turn.rs:1308-1405 + 2165-2260 sampling shell (1 hour)orchestrator.rs header comments + codex-rs/core/src/tools/sandboxing.rs:156-300 (2 hours)handlers/shell.rs:63-220 (1 hour)agents_md.rs:1-220 (1 hour)manager.rs SandboxType + landlock.rs rule install (1 hour)flowchart TB Q["local coding Agent"] --> A["closed-source extreme experience"] Q --> B["open-source multi-model kitchen"] Q --> C["open-source official + OS sandbox"] A --> CC["Claude Code"] B --> OC["OpenCode"] C --> CX["Codex CLI"]
| Item | Status |
|---|---|
| Seatbelt profile generation details | need to continue drilling macOS-specific modules (this edition did not split the plist line by line) |
| App-server full RPC surface | boundary set; method table not fully recorded |
| Default model slug | volatile; use debug models |
| Cloud tasks protocol | marked experimental; not dug deep |
| memories read/write full product semantics | crates exist; not expanded as a main line |
| You want to understand | Go to chapter |
|---|---|
| Two-layer loop | Chapters 5–7 |
| Why it will not stop / suddenly stopped | Chapter 7 stop table |
| Stream retry | Chapter 8 |
| When compact happens | Chapter 9 |
| Can you change history | Chapter 10 |
| Where tools come from | Chapter 11 |
| Approval and sandbox, who first | Chapter 12 |
| Shell raise-privilege rejected | Chapter 13 |
| Multi-file patch asked only once | Chapters 14, 16 |
| Which directories Linux can write | Chapter 15 |
| Why AGENTS.md was not read | Chapter 17 |
| Stop hook continue-run | Chapters 7, 18 |
| How to back up a session | Chapter 20 |
| How to configure CI | Section 16.4 |
| Topic | Path |
|---|---|
| CLI | codex-rs/cli/src/main.rs |
| RegularTask | codex-rs/core/src/tasks/regular.rs |
| run_turn | codex-rs/core/src/session/turn.rs:153 |
| run_sampling_request | same file :1308 |
| try_run_sampling_request | same file :2165 |
| Orchestrator | codex-rs/core/src/tools/orchestrator.rs |
| Approval/sandbox traits | codex-rs/core/src/tools/sandboxing.rs |
| Shell | codex-rs/core/src/tools/handlers/shell.rs |
| ApplyPatch handler | .../handlers/apply_patch.rs |
| ToolSpec | codex-rs/tools/src/tool_spec.rs |
| AskForApproval | codex-rs/protocol/src/protocol.rs:908 |
| SandboxType | codex-rs/sandboxing/src/manager.rs:35 |
| Landlock | codex-rs/linux-sandbox/src/landlock.rs |
| AGENTS.md | codex-rs/core/src/agents_md.rs |
| arg0 | codex-rs/arg0/src/lib.rs |
| Rollout | codex-rs/rollout/src/lib.rs |
| Constitution | AGENTS.md |
| Item | Value |
|---|---|
| workspace members | 126 |
.rs files |
2783 |
| turn.rs | 2741 |
| session/mod.rs | 4154 |
| orchestrator.rs | 533 |
| apply_patch handler | 666 |
| agents_md.rs | 480 |
| sandboxing manager | 715 |
Earlier we split points by subsystem; here we string the points into a timeline by real user action. Every step points back to a chapter and line numbers.
You say in TUI: “delete the build directory.” The model decides to call shell.
| Step | What happens | Back-pointer |
|---|---|---|
| 1 | no subcommand, enter TUI, submit Op | Chapters 3, 4 |
| 2 | RegularTask emits TurnStarted, calls run_turn | Chapter 5 |
| 3 | pre compact / MCP / step_context / skills | Chapter 6 |
| 4 | run_sampling_request → stream → FunctionCall(shell) | Chapter 8 |
| 5 | Shell run_exec_like: merge permissions; if not raise-privilege, pass the guard |
Chapter 13 shell.rs:122-138 |
| 6 | create_exec_approval_requirement_for_command |
Chapter 13 |
| 7 | Orchestrator: if NeedsApproval → dialog; cache ApprovedForSession | Chapters 12, 16 |
| 8 | select SandboxType (e.g. macOS Seatbelt) | Chapter 15 |
| 9 | SandboxAttempt executes; if sandbox denies and can escalate → retry without asking a human again | orchestrator header comment |
| 10 | output back to model; needs_follow_up? may sample again to confirm | Chapter 7 |
A. Policy = Never (misconfigured in interactive) Do not ask a human; try directly; sandbox may still block. Failure returns to the model — the user may see the model “try failed” rather than a dialog.
B. Policy = UnlessTrusted
rm almost always asks.
C. Request escalated + not OnRequest
shell.rs:122-138 Reject directly; copy educates the model not to ask for raise-privilege.
D. A denied-read path exists
Even if raise-privilege is approved, sandbox_permissions_preserving_denied_reads may force continuing the sandbox (Section 12.4).
The model’s one patch changes src/a.ts and src/b.ts.
apply_patch handler, or shell text intercepted by intercept_apply_patch (Chapters 13.2, 14).Approvable::approval_keys returns two path keys (sandboxing comments 316–321).with_cached_approval: skip only when both keys are already ApprovedForSession; after approve, write each.a.ts → single key hits cache → may not ask.| Scheme | Problem |
|---|---|
| Whole-call approve | next time changing an unrelated file also skips asking; too wide |
| Per-file key | granularity aligns with real risk; subsets reusable |
CC’s Edit tool is usually single-file semantics; Codex explicitly writes “multi-file one patch” into the approval-cache contract.
In a long session the model still needs to call tools, but token_status shows it hit the top.
model_needs_follow_up=true (7.2).needs_follow_up is true; token_limit_reached or new_context_window_request → should_roll_over (419–420).run_auto_compact(..., MidTurn, BeforeLastUserMessage{world_state, step_context}) (431–443).can_drain_pending_input = !model_needs_follow_up; continue back to the loop.| PreTurn | MidTurn | |
|---|---|---|
| Timing | before sampling | after sampling when still continuing |
| Input | this round not yet the focus | must keep last user message’s relative position |
| Injection | DoNotInject etc. | BeforeLastUserMessage |
Compact throws non-Abort → emit turn error → Ok(None) (449–452). User side appears as a turn error-end, not silently dropping history.
The model seems finished speaking (no tool follow-up), but a Stop hook is installed to check “did tests pass.”
!needs_follow_up enters the stop protocol (461+).run_turn_stop_hooks → should_block=true and carries continuation fragments.build_hook_prompt_message succeeds → record into conversation, accept mailbox, stop_hook_active=true, continue (470–486).Hooks can veto “the model thought it was done” — this is an enterprise compliance/quality-gate hook, not UI decoration.
OD uses multi-round review inside a session; Codex stop hook is a locally scriptable latch. Similar direction (do not let it clock out easily), different mechanism.
codex exec "..."AskForApproval::Never| Point | Interactive | CI |
|---|---|---|
| Facade | TUI | exec |
| Approval | OnRequest common | Never |
| Failure | can ask a human to escalate | return to model or fail-exit directly |
| Session | resume/fork assets | often one-shot rollout |
Never is not “skip the sandbox.” If someone configures SandboxType as None + DangerFullAccess, CI becomes a true bare run — when reviewing config, look at both layers.
Repo root has AGENTS.md; apps/api/AGENTS.override.md exists; cwd is apps/api.
[root, ..., apps/api] (root→cwd).apps/api layer, if it reads the override file, uses it (same layer).project_doc_max_bytes.--- project-doc ---.project_doc_max_bytes = 0| Question | Claude Code | OpenCode | Codex (this lecture) |
|---|---|---|---|
| User interjection | product queue | depends on implementation | RegularTask outer + input_queue |
| Main-loop file | deep edition Ch.6 | prompt.ts | turn.rs run_turn |
| Stop | full set of termination reasons | Goal/doom etc. (MiMo strengthened) | follow-up × compact × stop hook |
| Patch | Edit/Write | edit or apply_patch | apply_patch + intercept |
| Permissions | five gates | ruleset | AskForApproval × ExecApprovalRequirement × Sandbox |
| Sandbox | mainly product layer | rules/optional | OS-level three platforms |
| Instructions | CLAUDE.md | memory/config | AGENTS.md chain |
| Session | product storage | DB/files | rollout file assets |
| Open source | no | MIT | Apache-2.0 |
| Term | Meaning |
|---|---|
| Turn | a user-triggered task unit; may contain multiple samplings |
| StepContext | a single-step frozen tool/config/world view |
| SamplingRequest | one model streaming request |
| ToolOrchestrator | approval+sandbox+retry hub |
| SandboxAttempt | one typed execution attempt |
| Rollout | session land-on-disk and replay assets |
| arg0 personality | same binary dispatched by argv0 |
| comp_hash | compact-compatibility hash |
The deepened edition takes Codex from “yet another Agent” apart into a control system you can check point by point: outer interjection, inner tools, compact opening a window, stop hook, approval three states, sandbox raise-privilege conflict, AGENTS.md hard budget. What you want is not more adjectives, but every point can point to a line number, state a trade-off, list a checklist — this lecture was rewritten to that standard.
This lecture was written by checking point by point against openai/codex commit bb1af235ea2822d7a40f75ef52e4d6a2cde84da2 (2026-07-28). All file:line citations were re-verified with grep -n / sed -n; ambiguous filenames (turn.rs / shell.rs / protocol.rs / config_types.rs each have 3–6 same-named files in this repo) always have the crate path filled in. Statistics come from a local full checkout (2,783 .rs under codex-rs, workspace 126 members); community data from the GitHub REST API on 2026-07-29 (102,170★ / 15,331 fork).
Contrast reading → three-way deep comparison · interactive explainer (5 runnable benches) · catalog · sideways comparison and overall evaluation