English lecture · source-traced

OpenAI Codex source analysis (deepened): a local coding agent taken apart as an engineering system you can check point by point

**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

EN
lecture
source
file:line
no
embellishment
中文
sibling

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 .rs files · Cargo workspace 126 members · session/turn.rs 2741 lines · session/mod.rs 4154 lines npm: @openai/codex (codex-cli/package.json) → bin/codex.js Method: 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 explainer openai-codex-解析.html (5 runnable benches) Reference tree: 参考项目/codex/


Part 1

Contents

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


Part 2

Chapter 1 · Project overview: nail the product boundary first

Point 1.1 · “Local CLI” and “Codex Web” are not the same thing

① 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”?

Point 1.2 · One-sentence engineering definition

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*

Point 1.3 · License and distribution

  • Apache-2.0 · Copyright 2025 OpenAI (LICENSE)
  • Install: curl …/codex/install.sh · npm i -g @openai/codex · brew cask
  • The npm package is only a thin wrapper: codex-cli/package.json’s bin.codex → bin/codex.js; the real logic is in the Rust binary

Point 1.4 · “Species difference” from neighbors in this series

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.


Part 3

Chapter 2 · Panorama architecture: 126 crates and “resist core bloat”

Point 2.1 · Why split into a hundred-plus crates?

① 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.

Point 2.2 · Four-flight staircase

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

Point 2.3 · Size signals (this machine’s checkout)

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

Point 2.4 · Engineering culture (must-read to understand the architecture)

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.”


Part 4

Chapter 3 · Startup and multiple entries: MultitoolCli command by command

Point 3.1 · Default path: no subcommand → TUI

② 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.

Point 3.2 · Subcommand classification (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-server is marked with a /// [EXPERIMENTAL] doc comment at main.rs:206-207, but has no hide = true, so it is a publicly visible subcommand and should not be listed in the hidden group (verification correction 2026-08-04).

Point 3.3 · Design trade-offs

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"]

Part 5

Chapter 4 · Thread · Session · task system

Point 4.1 · CodexThread is the facade

① 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.

Point 4.2 · Session.submit family

From codex-rs/core/src/session/mod.rs:793:

  • submit(op)
  • submit_with_trace
  • submit_with_id
  • submit_user_input_with_client_user_message_id (813+)

The same session also has active_turn, input_queue, services (model client, exec policy, plugins…).

Point 4.3 · Interjection and idle start (inject)

codex-rs/core/src/session/inject.rs:

  • inject_if_running: while the model is running, stuff input into the current turn
  • try_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.”

Point 4.4 · Tasks are not only Regular

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.


Part 6

Chapter 5 · Outer loop: RegularTask

Point 5.1 · The file is short; its status is extremely high

codex-rs/core/src/tasks/regular.rs is about a hundred lines in full, but it is the adapter from UI turn → run_turn.

Point 5.2 · run() step by step

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

Point 5.3 · Two-layer loop comparison table (memorize)

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

Point 5.4 · Two details you only see in source

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_turn entry overhead again (pre-compact, MCP wake, step-context freeze all start over) — Codex chose control-flow clarity, not saving this one overhead.


Part 7

Chapter 6 · Inner loop: run_turn step by step (upper) — entry to sampling

File: codex-rs/core/src/session/turn.rs (2741 lines). Entry: run_turn @ 153.

Point 6.1 · What the function signature is declaring as a contract

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.

Point 6.2 · First thing at the door: pre_sampling_compact (167–184)

Calls run_pre_sampling_compact (defined @ 983):

  1. First maybe_run_previous_model_inline_compact — when the model changed / compaction hash changed, use the old model for compatibility compact (comments 1047–1056)
  2. Look at 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?

Point 6.3 · Resolve which MCP this round must wake (187–197)

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.

Point 6.4 · Freeze step_context (199–214)

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.

Point 6.5 · world_state and diff roots (215–218)

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.”

Point 6.6 · skills / plugins injection (220–230)

build_skills_and_plugins: on failure return Ok(None) early — skill assembly is not optional decoration; failure means do not start the run.

Point 6.7 · hooks and recording input (232–256)

  • run_pending_session_start_hooks
  • run_hooks_and_record_inputs
  • merge connector selection
  • record PreviousTurnSettings (model slug, comp_hash, realtime)
  • write injection_items into the conversation

④ Check: when a session start hook returns “should stop,” do you mistakenly think it is an empty model reply?

Point 6.8 · Two policy switches before entering the loop (assigned at 236 · comments 264–269)

  • can_drain_pending_input: often false at turn start — sample this round’s input first, then drain interjections
  • later auto-compact runs also postpone drain (comments 266–268)
flowchart 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"]

Part 8

Chapter 7 · Inner loop: run_turn step by step (lower) — after sampling and stop

Point 7.1 · Loop head: pending + reminders (loop from 270)

Each lap may:

  1. drain pending (if allowed) and run hooks
  2. rollout_budget::maybe_record_reminder
  3. get/rebuild step_context (when there is pending user input, recompute MCP from the input)
  4. time_reminder::maybe_record_current_time_reminder
  5. update world_state
  6. clone_history().for_prompt(modalities) to construct sampling input

Point 7.2 · Call sampling (344–356)

run_sampling_request(...) (defined @ 1308). On success you get:

  • model_needs_follow_up
  • last_agent_message

Point 7.3 · Post-sampling state machine (357–512) — branch by branch

Branch A · needs follow-up

  • accept_mailbox_delivery_for_current_turn (364–369)
  • can_drain_pending_input = true
  • compute needs_follow_up = model_needs_follow_up || has_pending_input
  • emit a large amount of token usage trace (387–401)

Branch B · should_roll_over (new window / token top) (419–458)

  • Condition: needs_follow_up && (new_context_window_request || token_limit_reached)
  • run_auto_compact(..., CompactionPhase::MidTurn), injection strategy BeforeLastUserMessage
  • after compact, may run session start hooks again
  • can_drain_pending_input = !model_needs_follow_up then continue

Comment 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)

  1. run_turn_stop_hooks
  2. If should_block and there are continuation fragments → record into history, open mailbox, stop_hook_active=true, continue (the hook demands continue-running)
  3. If block but no prompt → Warning then ignore
  4. If should_stop → break
  5. legacy after-agent hook may Ok(None)
  6. otherwise break

Branch D · errors

  • TurnAborted rethrown
  • other errors continue to be handled in later match arms (517+)

Point 7.4 · One “can it stop?” decision table

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.


Part 9

Chapter 8 · run_sampling_request and try_run_sampling_request

Point 8.1 · run_sampling_request (1308–1405): the retry-bearing shell

Step by step:

  1. take base_instructions
  2. build ToolCallRuntime (router + sess + step_context + diff tracker)
  3. start code_mode_service.start_turn_worker (code-mode parallel worker)
  4. retry loop:
  5. first time use the passed-in input; afterwards for_prompt from history
  6. build_prompt
  7. try_run_sampling_request
  8. on success return (output, original_input)
  9. ContextWindowExceeded / UsageLimitReached: update state then return the error
  10. other: if retryable, go handle_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.

Point 8.2 · try_run_sampling_request (2165+): the real stream receive

Key structure:

  • feedback_tags! stamps model, approval_policy, sandbox_policy, effort, auth, features (2176–2183) — observability first-class
  • client_session.stream(prompt, ...) opens the stream (2195–2208), cancellable
  • FuturesOrdered in_flight: future queue for parallel tool calls (2209)
  • needs_follow_up / last_agent_message accumulators
  • Plan-mode dedicated parser and PlanModeStreamState (2221–2223)
  • outer loop pulls stream events (2228+)

Whether the model supports parallel: supports_parallel_tool_calls (about line 1289, near the built path).

Point 8.3 · Why two layers of sampling functions?

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.

Point 8.4 · Reader experiments

  1. Simulate network drop: see whether the retryable path fires
  2. Send a message in Plan mode: see whether plan_mode branch events differ
  3. Open a model with parallel tools: observe whether FuturesOrdered overlaps multiple tasks

Part 10

Chapter 9 · The compact family: pre / mid / previous-model

Point 9.1 · Trigger-source comparison

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

Point 9.2 · comp_hash_changed (1014–1019)

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.

Point 9.3 · Previous-model compact preconditions (1032–1040)

⚠️ 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.

Point 9.4 · InitialContextInjection

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.”

Point 9.5 · Relation to the context constitution

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.


Part 11

Chapter 10 · The six context-window constitutions

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

  • Claude Code: CLAUDE.md collection rules + system-reminder
  • OpenCode/MiMo: external MEMORY/checkpoint
  • Codex: first police the cleanliness of “model-visible history” itself; external memory is another layer (memories crates exist, but the constitution writes about history)

④ 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?


Part 12

Chapter 11 · Tool assembly: ToolSpec · built_tools · Router

Point 11.1 · ToolSpec: the “menu JSON” for the Responses API

codex-rs/tools/src/tool_spec.rs:19-53:

Variant wire type Meaning
Function function ordinary function tool
Namespace namespace namespace grouping
ToolSearch tool_search tool search
WebSearch web_search hosted search (external/indexed switch)
Freeform custom freeform

name() @56-66; create_tools_json_for_responses_api @79 serializes into the request.

③ Codex does not hide from the model “this is the OpenAI Responses tool protocol” — a parallel universe to Claude Code’s Anthropic tool blocks.

Point 11.2 · built_tools (codex-rs/core/src/session/turn.rs:1459+): one turn’s tool universe

Verification 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 the tools/ directory); signature built_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.

Point 11.3 · The handlers directory is the implementation table of “hands”

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): CodeMode is not in the handlers/mod.rs export table — it is codex-rs/core/src/tools/code_mode/, declared by pub(crate) mod code_mode; at tools/mod.rs:2, a sibling module of handlers rather 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

Point 11.4 · ToolCallRuntime

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.


Part 13

Chapter 12 · Orchestrator: approval → sandbox → attempt → retry

Point 12.1 · The file header is the manual

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.

Point 12.2 · ExecApprovalRequirement three states (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

Point 12.3 · default_exec_approval_requirement (198–234)

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: Granular turned sandbox approval off, but when the filesystem is not Restricted, the result is Skip not Forbidden. Because Forbidden hangs on the premise of needs_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.

Point 12.4 · SandboxOverride (236–271)

sandbox_override_for_first_attempt:

  1. If policy contains denied-read, forbid unsandboxed (otherwise denied-read would fail) → NoOverride
  2. If ExecPolicy Allow and bypass_sandbox=true → first attempt may Bypass
  3. If escalated permissions were requested → Bypass
  4. otherwise NoOverride

unsandboxed_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.

Point 12.5 · Approvable / Sandboxable / ToolRuntime traits (312–406)

  • approval_keys: apply_patch can have many keys (many files)
  • should_bypass_approval: already approved or Never
  • wants_no_sandbox_approval: UnlessTrusted always; Never never; Granular looks at config
  • ToolRuntime::run(req, SandboxAttempt, ctx): actually execute

Point 12.6 · SandboxAttempt structure (408–425)

Carries: 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"]

Part 14

Chapter 13 · Shell path: from arguments to ExecApprovalRequirement

Point 13.1 · run_exec_like overall flow (codex-rs/core/src/tools/handlers/shell.rs:63)

  1. take filesystem, environment-variable overlays
  2. whether Feature::ExecPermissionApprovals is on
  3. apply_granted_turn_permissions merges rights already granted this turn
  4. normalize/validate additional permissions (109–120)
  5. raise-privilege guard (122–138): if sandbox override is requested and not pre-approved, and policy is not OnRequest → RespondToModel deny directly (copy says do not ask for raise-privilege under non-OnRequest)
  6. Intercept apply_patch (140–157): if the command is a patch, hand to intercept_apply_patch — secretly patching inside shell is still caught onto the structured path
  7. emit ToolEmitter begin event
  8. create_exec_approval_requirement_for_command (175–190)
  9. assemble ShellRequest, ToolOrchestrator::run + ShellRuntime

Point 13.2 · Why must shell intercept apply_patch?

Prevent 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_patch is something that must be wired together — otherwise you open a silent patch bypass.

Point 13.3 · Shell backend variants (tool_config.rs)

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?

Point 13.4 · Relation to the “safe command” narrative

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.


Part 15

Chapter 14 · apply_patch: handler + arg0 second personality

Point 14.1 · Independent crate

codex-rs/apply-patch: parse_patch, StreamingPatchParser, Hunk… can land on ExecutorFileSystem.

Point 14.2 · Handler

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).

Point 14.3 · arg0 second personality (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.

Point 14.4 · CLI codex apply

git 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"]

Part 16

Chapter 15 · Sandbox three platforms and Landlock rules

Point 15.1 · SandboxType and platform selection

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.

Point 15.2 · SandboxablePreference

Auto | Require | Forbid (54–58) — a tool can declare whether it wants a sandbox.

Point 15.3 · transform boundary (comments 107–110)

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.

Point 15.4 · Linux Landlock (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.

Point 15.5 · macOS Seatbelt

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.

Point 15.6 · Windows

codex-rs/core/src/windows_sandbox.rs: elevated setup, private desktop, mode persistence, metrics. Non-Windows targets bail directly.

Point 15.7 · bwrap (Linux filesystem sandbox main implementation)

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)

Point 15.8 · Orthogonal to approval (emphasized again)

Layer Question
AskForApproval does the human agree
SandboxType does the kernel allow

Both must pass; then the side effect happens.


Part 17

Chapter 16 · AskForApproval and approval cache

Point 16.1 · Four-gear semantics (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)

Point 16.2 · Approval cache with_cached_approval (71–117)

  • key is serializable
  • skip only when all are ApprovedForSession
  • after approve, write by key; subset requests can reuse

Point 16.3 · Network approval

The orchestrator also hangs begin_network_approval / deferred mode — network is not a gift of the FS sandbox; it is another approval belt.

Point 16.4 · Scenario scripts

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

Part 18

Chapter 17 · AGENTS.md discovery algorithm line by line

Point 17.1 · Constants and separators (codex-rs/core/src/agents_md.rs:38-44)

  • DEFAULT_AGENTS_MD_FILENAME = "AGENTS.md"
  • LOCAL_AGENTS_MD_FILENAME = "AGENTS.override.md"
  • Between user instructions and project docs: \n\n--- project-doc ---\n\n

Point 17.2 · load_project_instructions (53–81)

For each turn environment:

  1. take ExecutorFileSystem (can be remote)
  2. read_agents_md
  3. errors only log; do not destroy the whole load
  4. Some only if non-empty

③ Multiple environments (local + remote exec-server) can each have their own instructions.

Point 17.3 · Byte budget: a chain that is first-come-first-served, and can starve later ones

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.md writes 30 KB of general norms, apps/api/AGENTS.md writes 5 KB of service-specific rules — the latter is only read in 2 KB; remaining 3 KB silently lost, logs only have one warn. 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; truncating warn

Point 17.3b · Who wins in the same directory: candidate_filenames

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.md mental model: there it is multi-source stacking; here it is single-choice per layer + concatenation across layers.

Point 17.3c · The exit has two text suites: legacy vs environment-labeled

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\n is 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.

Point 17.4 · agents_md_paths (155–220) step by step

  1. Merge non-Project-layer config, parse project_root_markers (default .git)
  2. find_nearest_ancestor_with_markers finds the project root
  3. Walk from cwd to root; dirs list reverses into root→cwd
  4. If no root, search only cwd
  5. For each directory, probe files by candidate_filenames (override-priority logic is in the candidate list)
  6. Concurrent probe cap MAX_CONCURRENT_ANCESTOR_PROBES = 256 (49)

Point 17.5 · Contrast with Claude Code CLAUDE.md

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)

Point 17.6 · Dogfood recursion

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"]

Part 19

Chapter 18 · Skills · Plugins · Hooks · sub-agents

Point 18.1 · When Skills / Plugins enter the turn

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.

Point 18.2 · Hooks lifecycle

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.

Point 18.3 · MCP naming: prefix is legacy, dedup relies on SHA-1

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 ToolInfo for protocol calls, while callable_namespace / callable_name are 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 like mcp_______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 _2 serials: serials depend on traversal order — today serverA connects first and is called foo; tomorrow serverB connects first and it is called foo; 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_identity concatenated from server_name \0 callable_namespace \0 connector_id); the same tool is always the same name, independent of connection order. The explicit sort_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 _a1b2c3 at 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.

Point 18.4 · Sub-agent registry (codex-rs/core/src/agent/registry.rs)

  • limit same-session sub-thread count
  • nickname + “the 2nd” suffix
  • exceeds_thread_spawn_depth_limit
  • SessionSource::SubAgent carries depth

④ Check: is recursive spawn stuck by depth? On nickname conflict, is it readable?

Point 18.5 · ModeKind (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.


Part 20

Chapter 19 · TUI · Exec · Doctor

Point 19.1 · TUI is the default face

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.

Point 19.2 · Exec non-interactive

codex exec → codex_exec::Cli. Typical pairing: AskForApproval::Never + tight sandbox. Docs docs/exec.md link out to the official site.

Point 19.3 · Doctor

Diagnose install, config, auth, runtime. When something is wrong, codex doctor first, then read source.

Point 19.4 · Debug subcommands

debug models / debug prompt-input / app-server messages — print “what the model can see”; the debug exit of context discipline.


Part 21

Chapter 20 · App-server · MCP · Rollout

Point 20.1 · App-server = a socket for editors

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.

Point 20.2 · MCP dual role

Role Entry
Client codex mcp manages external servers
Server mcp-server stdio exposes itself

Inside a turn, lazy-wake required servers (Chapter 6).

Point 20.3 · Rollout session assets (codex-rs/rollout/src/lib.rs)

  • Directories: sessions / archived_sessions
  • Compact, materialize references, search snippets, state_db
  • Interaction sources: Cli, VSCode, custom atlas/chatgpt
  • CLI: resume/fork/archive/delete

③ A session is a first-class filesystem asset; backup/fork/audit are all built on this API.

Point 20.4 · Contrast with OpenCode’s “the server is the body”

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

Part 22

Chapter 21 · Auth and model channel

Point 21.1 · Login three-piece set (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

Point 21.2 · Commercial closed loop

Recommend ChatGPT plan login — symmetric with Claude Code’s “subscription” narrative: the CLI is the local surface of plan entitlements.

Point 21.3 · Models and tool protocol

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.

Point 21.4 · Auth premise of previous-model compact

Chapter 9: only Codex backend + OpenAI provider does previous-model compatibility compact. When switching to a third-party provider, do not expect this behavior.


Part 23

Chapter 22 · Distinctive designs, trade-offs, how to read, comparisons

Point 22.1 · Three most distinctive designs (expanded)

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).

Point 22.2 · Three trade-offs you must know

  1. core/turn is still huge — policy resists bloat; historical debt remains; the way to read is “jump by function,” not linear through-read.
  2. User docs are external — docs/ often jumps out; offline you rely only on source and this analysis.
  3. Tool surface leans shell+patch — aligned with some model distributions; fine-grained navigation teaching is less friendly than CC’s illustrated catalog; you have to rely on the sandbox to backstop an over-wide shell.

Point 22.3 · Suggested reading order (deepened edition)

  1. cli/src/main.rs subcommand map (30 minutes)
  2. codex-rs/core/src/tasks/regular.rs full text (15 minutes)
  3. 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/
  4. turn.rs:1308-1405 + 2165-2260 sampling shell (1 hour)
  5. orchestrator.rs header comments + codex-rs/core/src/tools/sandboxing.rs:156-300 (2 hours)
  6. handlers/shell.rs:63-220 (1 hour)
  7. agents_md.rs:1-220 (1 hour)
  8. manager.rs SandboxType + landlock.rs rule install (1 hour)
  9. Contrast Claude-Code-vs-OpenCode-vs-Codex deep comparison

Point 22.4 · Selection, bony

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"]

Point 22.5 · Unknown inventory (still honest after deepening)

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

Part 24

Appendix A · “One point, one page” index (jump-read by topic)

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
Part 25

Appendix B · Source-path quick lookup

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
Part 26

Appendix C · Scale snapshot (bb1af23)

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


Part VII · Scenario-level step-by-step walkthroughs

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.

Part 27

Chapter 23 · Scenario: executing `rm -rf build` in an interactive session

Story

You say in TUI: “delete the build directory.” The model decides to call shell.

Step-by-step walkthrough

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

Key forks

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).

Reader check

  • [ ] Can you point out whether “ask a human” happens in the orchestrator or the shell front guard? (both places have it)
  • [ ] After sandbox deny, will the escalate still pop a dialog? (by design: no re-review)

Part 28

Chapter 24 · Scenario: a multi-file apply_patch

Story

The model’s one patch changes src/a.ts and src/b.ts.

Step-by-step walkthrough

  1. The tool can be the apply_patch handler, or shell text intercepted by intercept_apply_patch (Chapters 13.2, 14).
  2. Approvable::approval_keys returns two path keys (sandboxing comments 316–321).
  3. with_cached_approval: skip only when both keys are already ApprovedForSession; after approve, write each.
  4. Next time only change a.ts → single key hits cache → may not ask.
  5. Landing on disk walks the apply-patch crate parsing Hunk.

Why this is better than “one boolean for the whole tool call”

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

Contrast Claude Code

CC’s Edit tool is usually single-file semantics; Codex explicitly writes “multi-file one patch” into the approval-cache contract.


Part 29

Chapter 25 · Scenario: mid-turn compact when the context window is about to blow

Story

In a long session the model still needs to call tools, but token_status shows it hit the top.

Step-by-step walkthrough

  1. sampling returns model_needs_follow_up=true (7.2).
  2. needs_follow_up is true; token_limit_reached or new_context_window_request → should_roll_over (419–420).
  3. run_auto_compact(..., MidTurn, BeforeLastUserMessage{world_state, step_context}) (431–443).
  4. If success: may run session start hooks again; can_drain_pending_input = !model_needs_follow_up; continue back to the loop.
  5. Comment 430: believe compact can pull the window back; accept potential extra laps.

Difference from pre-turn compact

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

Failure

Compact throws non-Abort → emit turn error → Ok(None) (449–452). User side appears as a turn error-end, not silently dropping history.


Part 30

Chapter 26 · Scenario: a stop hook demands continue-running

Story

The model seems finished speaking (no tool follow-up), but a Stop hook is installed to check “did tests pass.”

Step-by-step walkthrough

  1. !needs_follow_up enters the stop protocol (461+).
  2. run_turn_stop_hooks → should_block=true and carries continuation fragments.
  3. build_hook_prompt_message succeeds → record into conversation, accept mailbox, stop_hook_active=true, continue (470–486).
  4. Next-lap sampling treats the hook prompt as context; may run test commands again.
  5. If block but no prompt → Warning “ignoring the block” (488–494).

Product meaning

Hooks can veto “the model thought it was done” — this is an enterprise compliance/quality-gate hook, not UI decoration.

Contrast Open Design Design Jury

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.


Part 31

Chapter 27 · Scenario: `codex exec` + Never in CI

Stepwise differences (vs interactive)

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

Risk

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.


Part 32

Chapter 28 · Scenario: AGENTS.md chain vs override — who wins

Story

Repo root has AGENTS.md; apps/api/AGENTS.override.md exists; cwd is apps/api.

Step-by-step walkthrough

  1. markers find git root.
  2. search_dirs = [root, ..., apps/api] (root→cwd).
  3. each layer candidate_filenames: override preferred over default (Chapter 17).
  4. apps/api layer, if it reads the override file, uses it (same layer).
  5. concat is cut by project_doc_max_bytes.
  6. joined to user instructions with --- project-doc ---.

Common “not read” cause list

  • [ ] project_doc_max_bytes = 0
  • [ ] cwd is outside root and there are no markers
  • [ ] filename is neither AGENTS.md nor override / fallback
  • [ ] remote environment’s FS cannot see the local path
  • [ ] content all blank, skipped

Part 33

Appendix D · Same question, different answers: Claude Code / OpenCode / Codex detailed table

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

Part 34

Appendix E · Glossary (for reading source)

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
Part 35

Closing

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

↑