
Openai Codex Rust Patterns
- 147 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
openai-codex-rust-patterns: A skill for development. This provides functionality for development workflows.
Key points
- openai-codex-rust-patterns
Openai Codex Rust Patterns by the numbers
- 147 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,511 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill openai-codex-rust-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 147 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use openai-codex-rust-patterns for development tasks?
Use openai-codex-rust-patterns for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with openai-codex-rust-patterns.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use openai-codex-rust-patterns for development tasks, or when openai-codex-rust-patterns: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to openai-codex-rust-patterns: openai-codex-rust-patterns.
Files
OpenAI Codex Rust Best Practices
Distilled from `openai/codex` codex-rs/ — a 119-crate, 2,008-file Rust workspace that ships the Codex CLI coding agent. Contains 63 rules across 11 categories, each citing the exact file in codex-rs where the pattern lives, so you can write Rust the way its top contributors (Michael Bolin, jif-oai, Ahmed Ibrahim, Eric Traut, Pavel Krymets) actually ship it. Citations were refreshed against main at commit 8a94430 (2026-05-25).
When to Apply
Reference these guidelines when:
- Writing or reviewing async Rust code that spawns tokio tasks, owns cancellation tokens, or manages long-lived background workers.
- Designing error enums,
Resultflows, retry loops, or layer boundaries in a library or service. - Building a CLI tool that spawns subprocesses, enforces sandboxing, or runs LLM-generated code safely.
- Architecting a Cargo workspace with more than ~5 crates, deciding what to split out, and how to manage shared dependencies.
- Adding tests to a Rust codebase where existing tests are inline
mod tests { ... }blocks and scaling is becoming painful. - Implementing a JSON-RPC or custom wire protocol with serde — especially one that must evolve without breaking clients.
- Reading API keys or other secrets into memory, or hardening a binary that handles credentials against core dumps, debugger attach, and
LD_PRELOAD. - Enforcing a network egress allowlist that must survive DNS rebinding, or loading untrusted plugins/extensions.
- Wiring OpenTelemetry traces, logs, or metrics into a service that has privacy constraints around PII.
- Building a Ratatui-based TUI that streams LLM output, handles paste bursts, or manages raw-mode terminal state.
- Any time you find yourself reaching for
.unwrap(),.lock().unwrap(),anyhow::Result<()>, or#[cfg(feature = "test")]— this skill explains what codex does instead.
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Defensive Coding & Panic Discipline | CRITICAL | defensive- |
| 2 | Error Handling & Result Discipline | CRITICAL | errors- |
| 3 | Async, Concurrency & Cancellation | HIGH | async- |
| 4 | Sandboxing & Process Isolation | HIGH | sandbox- |
| 5 | Secrets & Process Hardening | HIGH | secrets- |
| 6 | Type Design & Invariants | HIGH | types- |
| 7 | Testing Architecture | MEDIUM-HIGH | testing- |
| 8 | Protocol & Serde Design | MEDIUM-HIGH | proto- |
| 9 | Workspace & Crate Organization | MEDIUM | workspace- |
| 10 | Observability & Tracing | MEDIUM | otel- |
| 11 | TUI (Ratatui) Rendering | MEDIUM | tui- |
Quick Reference
1. Defensive Coding & Panic Discipline (CRITICAL)
- `defensive-deny-unwrap-workspace-wide` — Deny unwrap and expect at the workspace level, opt in locally.
- `defensive-debug-assert-with-early-return` — Use debug_assert(false) with a safe fallback on unreachable branches.
- `defensive-recover-poisoned-lock` — Recover a poisoned lock with into_inner instead of unwrapping it.
- `defensive-banned-interpreter-prefixes` — Avoid learning allowlist rules for general-purpose interpreters.
- `defensive-head-tail-output-buffer` — Cap subprocess output with a head-and-tail ring buffer.
- `defensive-io-drain-timeout-grandchildren` — Time out the I/O drain task separately from the child process.
- `defensive-refuse-to-run-unsandboxed` — Refuse to run when the sandbox cannot enforce the requested policy.
- `defensive-canonicalize-approval-cache-key` — Canonicalize shell wrappers before hashing approval keys.
- `defensive-fault-isolate-plugin-load` — Isolate plugin load failures and sanitize manifest text before the model sees it.
2. Error Handling & Result Discipline (CRITICAL)
- `errors-exhaustive-retryable-match` — Classify retryable errors with an exhaustive match on every variant.
- `errors-transient-permanent-type-split` — Encode transient vs permanent outcomes as two enum variants.
- `errors-boundary-error-translator` — Translate errors at the layer boundary in a single function.
- `errors-carry-retry-delay-in-variant` — Carry the server-requested retry delay inside the error variant.
- `errors-struct-display-payload` — Put display-relevant error state in a struct, not a preformatted string.
- `errors-tool-call-respond-vs-fatal` — Split tool errors into respond-to-model and fatal variants.
- `errors-io-error-with-context-struct` — Wrap io::Error in a struct with a context field instead of anyhow.
3. Async, Concurrency & Cancellation (HIGH)
- `async-abort-on-drop-handle` — Store JoinHandles as AbortOnDropHandle so Drop cancels them.
- `async-graceful-then-forceful-cancel` — Cancel cooperatively first, then abort after a grace deadline.
- `async-biased-select-for-cancellation` — Use biased select to make cancellation always win race ties.
- `async-bounded-vs-unbounded-channel-split` — Bound the submission channel but leave the event channel unbounded.
- `async-child-cancellation-tokens` — Give spawned sub-tasks child tokens, not clones of the parent.
- `async-shared-boxfuture-joinhandle` — Wrap a background JoinHandle in Shared<BoxFuture> for multi-waiter joins.
4. Sandboxing & Process Isolation (HIGH)
- `sandbox-shared-policy-data-model` — Keep sandbox policy as shared data, not per-platform code.
- `sandbox-staged-restrictions-re-exec` — Stage incompatible restrictions by re-executing the same binary.
- `sandbox-resolve-before-allow-dns-rebinding` — Resolve hostnames and reject private IPs to defeat DNS rebinding.
- `sandbox-dev-null-first-missing-mount` — Mount /dev/null over the first missing path to block mkdir escapes.
- `sandbox-three-layer-network-isolation` — Stack env vars, seccomp, and namespaces for network isolation.
- `sandbox-env-clear-pre-exec` — Clear the env and tether children via pre_exec before every spawn.
- `sandbox-argv0-multiplex-binary` — Multiplex helper binaries via argv[0] and symlinks.
5. Secrets & Process Hardening (HIGH)
- `secrets-read-into-locked-buffer` — Read a secret into a zeroized stack buffer, then mlock it — never through stdin().
- `secrets-ctor-pre-main-hardening` — Harden a secret-handling process before main() runs, and fail closed.
- `secrets-manual-debug-elide` — Write a manual Debug impl that elides credentials instead of deriving it.
6. Type Design & Invariants (HIGH)
- `types-thread-local-raii-serde` — Pass deserializer context via a thread-local RAII guard.
- `types-try-from-newtype-validation` — Use serde try_from on a newtype to run validation on every parse.
- `types-non-exhaustive-public-enums` — Mark every public wire-level enum non_exhaustive from the start.
- `types-unknown-variant-forward-compat` — Preserve unrecognized values in an Unknown variant.
7. Testing Architecture (MEDIUM-HIGH)
- `testing-path-attribute-sibling-tests` — Attach tests as sibling files via #[path] instead of inline mod tests.
- `testing-wiremock-sse-fakes` — Fake the network with wiremock and small SSE event constructors.
- `testing-atomic-bool-test-opt-in` — Gate test-only behavior with an AtomicBool, not a cargo feature.
- `testing-insta-snapshot-tui-rendering` — Snapshot terminal rendering with insta for stable UI diffs.
- `testing-paused-runtime-advance` — Use start_paused and advance to make timing-dependent tests deterministic.
8. Protocol & Serde Design (MEDIUM-HIGH)
- `proto-internally-tagged-rpc-dispatch` — Dispatch JSON-RPC by an internally tagged enum with a macro.
- `proto-double-option-tri-state` — Use Option<Option<T>> to distinguish absent, null, and set.
- `proto-rename-alias-wire-migration` — Pair rename and alias to migrate wire names without breaking clients.
- `proto-experimental-runtime-gate` — Gate experimental fields by runtime presence, not capability flags.
- `proto-sse-idle-timeout-terminator` — Treat SSE streams as idle-timeout with required terminator.
- `proto-internal-vs-wire-error-split` — Split internal error enums from wire error enums.
- `proto-removed-feature-tombstone` — Keep removed feature flags as parseable no-op tombstones.
9. Workspace & Crate Organization (MEDIUM)
- `workspace-layered-transport-api-core` — Stack HTTP layers as transport, api, and core crates.
- `workspace-lint-config-package` — Encode policy in workspace.lints and clippy.toml.
- `workspace-utils-microcrate-fanout` — Place shared utilities in single-purpose microcrates under utils/.
- `workspace-test-support-as-member-crates` — Register shared test helpers as workspace member crates.
- `workspace-ban-per-crate-features` — Avoid per-crate features; use target-cfg or separate crates instead.
10. Observability & Tracing (MEDIUM)
- `otel-log-only-vs-trace-safe-targets` — Route PII to log-only targets and keep traces cardinality-safe.
- `otel-field-empty-then-record` — Declare span fields as field::Empty, then record them when known.
- `otel-layered-subscribers-env-filter` — Build per-layer EnvFilter instances with boxed fmt layers.
- `otel-w3c-traceparent-propagation` — Propagate W3C traceparent via env vars, JSON-RPC, and HTTP headers.
- `otel-instrument-at-trace-level` — Default #[instrument] to trace level, reserve info for network calls.
11. TUI (Ratatui) Rendering (MEDIUM)
- `tui-two-gear-hysteresis-chunking` — Replace fixed throttles with hysteresis-gated smooth and catch-up modes.
- `tui-schedule-frame-coalescer` — Coalesce redraws through a FrameRequester actor and rate limiter.
- `tui-drop-guard-panic-hook-chain` — Restore terminal state via a Drop guard and a chained panic hook.
- `tui-paste-burst-state-machine` — Detect unbracketed paste bursts via a character timing state machine.
- `tui-event-broker-pause-resume` — Pause the event stream by dropping it before a subprocess handoff.
How to Use
Read individual reference files for detailed explanations and code examples cited from codex-rs/:
- Section definitions — Category structure, impact levels, and prefixes
- AGENTS.md — Auto-generated navigation document compiling every rule
Each rule file contains:
- Imperative title matching its frontmatter
- 2–4 sentence explanation of the WHY
- Incorrect example showing the naive approach
- Correct example from codex-rs with the file path cited
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Auto-built TOC document compiling every rule |
| README.md | Skill repository docs — contribution, structure, commands |
| references/_sections.md | Category definitions and ordering |
| gotchas.md | Failure points discovered while applying these rules |
| metadata.json | Version, discipline, references to codex-rs |
Rust
Version 1.1.0 OpenAI May 2026
---
Abstract
Distilled Rust coding patterns extracted from openai/codex (codex-rs, 2,008 Rust files across 119 workspace crates), refreshed against main at commit 8a94430 (2026-05-25). Captures the end-to-end craft of its top contributors — Michael Bolin, jif-oai, Ahmed Ibrahim, Eric Traut, and others — across defensive coding, error discipline, async cancellation, sandboxing, secret handling and process hardening, type invariants, testing, protocol design, workspace organization, observability, and Ratatui TUI architecture. Each rule cites the exact codex-rs file and shows a minimal incorrect/correct pair so the reader can internalize the judgment, not just the syntax.
---
Table of Contents
1. Defensive Coding & Panic Discipline — CRITICAL
- 1.1 Avoid learning allowlist rules for general-purpose interpreters — CRITICAL (prevents approval amendments from green-lighting arbitrary interpreter flags)
- 1.2 Canonicalize shell wrappers before hashing approval keys — HIGH (avoids re-prompting users for logically identical commands and blocks cache collisions between wrapped scripts)
- 1.3 Cap subprocess output with a head-and-tail ring buffer — CRITICAL (prevents OOM on runaway output and preserves the most informative tail lines)
- 1.4 Deny unwrap and expect at the workspace level — CRITICAL (prevents unreviewed panic sites across a 75-crate workspace)
- 1.5 Load untrusted plugins fault-isolated and sanitize their model-facing text — HIGH (one broken or hostile plugin can't fail startup or inject the model's prompt)
- 1.6 Recover a poisoned lock with into_inner instead of unwrapping it — CRITICAL (stops one thread's panic from cascading a poisoned lock into every other holder)
- 1.7 Refuse to run when the sandbox cannot enforce the policy — CRITICAL (prevents silent privilege erosion when the sandbox backend lacks the required primitive)
- 1.8 Register a drain timeout to escape grandchild pipe leaks — CRITICAL (prevents the whole agent from hanging when a killed child leaves grandchildren holding stdout)
- 1.9 Use debug_assert with safe fallback on unreachable branches — CRITICAL (prevents release-mode panics while keeping bugs loud in tests)
2. Error Handling & Result Discipline — CRITICAL
- 2.1 Carry the server-requested retry delay inside the error variant — HIGH (eliminates out-of-band retry-after plumbing through the error flow)
- 2.2 Classify retryable errors via an exhaustive match — CRITICAL (prevents silent retry drift when a new error variant is added)
- 2.3 Encode transient vs permanent failures as two enum variants — CRITICAL (prevents boolean retry-policy checks that drift out of sync with the error source)
- 2.4 Split tool errors into respond-to-model and fatal variants — HIGH (eliminates downcasting every failure to decide whether the LLM can recover)
- 2.5 Store display-relevant error state in a struct, not a string — HIGH (enables plan-specific tests to assert against error state instead of fragile English sentences)
- 2.6 Translate errors at the layer boundary in one function — CRITICAL (eliminates reqwest error status inspection scattered across business logic)
- 2.7 Wrap io::Error in a struct with a context field — MEDIUM-HIGH (enables PartialEq tests against IoError without dragging anyhow into a library crate)
3. Async, Concurrency & Cancellation — HIGH
- 3.1 Bound submissions but leave events unbounded — HIGH (avoids session loop stalls on slow consumers while rate-limiting misbehaving producers)
- 3.2 Cancel cooperatively first, then abort after a grace deadline — HIGH (prevents unbounded shutdown latency while still letting well-behaved tasks clean up)
- 3.3 Give spawned sub-tasks child tokens, not parent clones — HIGH (prevents cancelling one child from cascading into all siblings)
- 3.4 Store JoinHandles as AbortOnDropHandle so Drop cancels them — HIGH (prevents leaked background tasks when a session or turn is cleared)
- 3.5 Use biased select when cancellation must win ties — HIGH (prevents rare approval races where cancel and response fire in the same poll)
- 3.6 Wrap background JoinHandle in Shared BoxFuture for multi-waiter joins — MEDIUM-HIGH (enables multiple independent callers to await the same background task completion)
4. Sandboxing & Process Isolation — HIGH
- 4.1 Clear the env and tether children via pre_exec before every spawn — HIGH (prevents LD_PRELOAD inheritance and orphaned grandchildren after a parent kill)
- 4.2 Keep sandbox policy as shared data, not per-platform code — HIGH (prevents three independently-drifting notions of "workspace-write")
- 4.3 Mount /dev/null over the first missing path component — HIGH (prevents mkdir-and-write escapes through non-existent protected paths)
- 4.4 [Multiplex helper binaries via argv[0] and symlinks](references/sandbox-argv0-multiplex-binary.md) — MEDIUM-HIGH (eliminates TOCTOU risk and packaging overhead of shipping multiple binaries)
- 4.5 Resolve hostnames and reject private IPs before allowing egress — HIGH (defeats DNS-rebinding bypass of a string-based egress allowlist)
- 4.6 Stack env, syscalls, and namespace for network isolation — HIGH (prevents network escape through any single uncooperative tool)
- 4.7 Stage incompatible restrictions via re-executing the same binary — HIGH (eliminates the "seccomp breaks bwrap" conflict via two-stage application)
5. Secrets & Process Hardening — HIGH
- 5.1 Harden a secret-handling process before main() runs, and fail closed — HIGH (closes the core-dump / ptrace / LD_PRELOAD window before any arg parsing or allocation)
- 5.2 Read a secret into a zeroized stack buffer, then mlock it — never through stdin() — HIGH (guarantees exactly one in-memory copy of an API key, locked out of swap and core dumps)
- 5.3 Write a manual Debug impl that elides credentials instead of deriving it — HIGH (stops tokens and credential providers leaking into {:?} and tracing output)
6. Type Design & Invariants — HIGH
- 6.1 Mark public wire-level enums non_exhaustive from the start — HIGH (prevents breaking external match statements when a variant is added)
- 6.2 Pass deserializer context via a thread-local RAII guard — HIGH (enables serde to run path resolution without DeserializeSeed plumbing)
- 6.3 Preserve unrecognized wire values in an Unknown variant — HIGH (prevents older readers from crashing on configs written by newer versions)
- 6.4 Use serde try_from on newtypes to run validation on every parse — HIGH (eliminates forgotten validation calls at construction sites via parse-don't-validate)
7. Testing Architecture — MEDIUM-HIGH
- 7.1 Attach tests as sibling files via a path attribute — MEDIUM-HIGH (prevents 5000-line modules where implementation hides inside a mile-long test body)
- 7.2 Enable test-only behavior via AtomicBool, not a cargo feature — MEDIUM-HIGH (avoids doubling the build matrix while keeping deterministic IDs for tests)
- 7.3 Snapshot terminal rendering with insta for stable TUI diffs — MEDIUM-HIGH (enables 1400 reviewable terminal snapshots that diff cleanly in PRs)
- 7.4 Use start_paused and advance for deterministic timing tests — MEDIUM-HIGH (eliminates wall-clock flakes from timing-dependent tests)
- 7.5 Use wiremock and small SSE constructors instead of mocking HTTP traits — MEDIUM-HIGH (enables serialization, retry, and streaming coverage on every test)
8. Protocol & Serde Design — MEDIUM-HIGH
- 8.1 Dispatch JSON-RPC via an internally tagged enum with a macro — MEDIUM-HIGH (eliminates hand-rolled method dispatch that drifts from typed param validation)
- 8.2 Gate experimental fields by runtime presence, not capability flags — MEDIUM-HIGH (enables adding unstable fields to stable methods without duplicating the request type)
- 8.3 Keep removed feature flags as parseable no-op tombstones — MEDIUM-HIGH (lets old and new configs round-trip across versions without parse failures)
- 8.4 Pair rename and alias to migrate wire names without breaking clients — MEDIUM-HIGH (prevents flag-day migrations by keeping old wire names as read-only aliases)
- 8.5 Split internal error enums from wire error enums — MEDIUM-HIGH (enables internal error refactors without breaking the stable wire contract)
- 8.6 Treat SSE streams as idle-timeout with a required terminator — MEDIUM-HIGH (prevents long turns from being killed by wall-clock deadlines and silent half-closes)
- 8.7 Use double-nested Options to distinguish absent, null, and set — MEDIUM-HIGH (eliminates invented FieldAction enums for PATCH-like update APIs)
9. Workspace & Crate Organization — MEDIUM
- 9.1 Avoid per-crate features; use target-cfg or split crates — MEDIUM (prevents combinatorial build matrix explosion across a ~100-crate workspace)
- 9.2 Encode design policy in workspace.lints and clippy.toml — MEDIUM (prevents policy drift from review-only conventions)
- 9.3 Place shared utilities in single-purpose microcrates under utils/ — MEDIUM (enables parallel compilation and minimal dependency graphs per concern)
- 9.4 Register shared test helpers as workspace member crates — MEDIUM (enables cross-crate test helper reuse without path-attribute hacks)
- 9.5 Stack HTTP layers as transport, api, and core crates — MEDIUM (enables client crate reuse and prevents business logic from pulling in retries)
10. Observability & Tracing — MEDIUM
- 10.1 Build per-layer EnvFilter instances with boxed fmt layers — MEDIUM (enables independently-filtered sinks without per-layer generic divergence)
- 10.2 Declare span fields as field Empty then record when known — MEDIUM (reduces duplicate child spans by keeping one parent span renamed at the apm)
- 10.3 Default instrument spans to trace level, reserve info for network calls — MEDIUM (enables free internal instrumentation that costs zero in normal operation)
- 10.4 Propagate W3C traceparent via env, RPC, and HTTP headers — MEDIUM (enables distributed tracing from CI runner through codex to backend APIs)
- 10.5 Route PII to log-only targets and keep traces cardinality-safe — MEDIUM (prevents PII from leaking into wider-access trace backends)
11. TUI (Ratatui) Rendering-rendering) — MEDIUM
- 11.1 Coalesce redraws through a FrameRequester actor — MEDIUM (reduces redraw count when multiple producers request frames in the same tick)
- 11.2 Detect unbracketed paste bursts via a character timing state machine — MEDIUM (prevents mid-paste shortcut key interpretation on terminals without bracketed paste)
- 11.3 Pause the event stream by dropping it before subprocess handoff — MEDIUM (prevents stdin race with child processes after handing off the terminal)
- 11.4 Replace fixed throttles with hysteresis-gated smooth and catch-up modes — MEDIUM (prevents visible lag on bursts without sacrificing the typewriter cadence feel)
- 11.5 Restore terminal state via a Drop guard and chained panic hook — MEDIUM (prevents wedged terminals that require manual
resetafter a panic)
---
References
1. https://github.com/openai/codex 2. https://github.com/openai/codex/tree/main/codex-rs 3. https://github.com/openai/codex/blob/main/AGENTS.md 4. https://developers.openai.com/codex
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Gotchas
Append failure points discovered while applying the rules in this skill. Each entry should be specific enough that a future reader can avoid it, not just "be careful with X".
Patterns in this skill were last refreshed against openai/codex main at commit 8a94430 on 2026-05-25. codex-rs refactors aggressively, so line references — and sometimes whole file paths — drift fast. Cross-check with the live repo before quoting an exact location.
codex-rs moves fast: verify file paths, not just line numbers
In the ~6 weeks between the first extraction (2026-04-12) and the first refresh (2026-05-25) the repo grew from 1,418 to 2,008 Rust files (72 → 119 crates), and only 5 of 60 citations were still byte-for-byte correct. Two structural moves caused most of the churn:
- `core/src/codex.rs` was deleted and split into
core/src/session/mod.rs,core/src/session/turn.rs, andcore/src/codex_delegate.rs. Any rule that citedcore/src/codex.rsnow points at one of those. When re-validating, grep for the symbol (a struct/fn name from the Correct block), not the old path. - `FunctionCallError` was extracted into a new `codex-tools` crate (
tools/src/function_call_error.rs);core/src/function_tool.rsis now just apub usere-export. Watch for similar "type lifted into its own crate" moves.
The repo migrated to Bazel — the justfile is gone
Build/policy that used to live in the justfile now lives in BUILD.bazel + docs/bazel.md. Don't cite the justfile. Relatedly, the absolute claim "there is not a single [features] section in the workspace" is no longer true: code-mode and v8-poc each declare [features] sandbox = ["v8/v8_enable_sandbox"] to forward a native-dependency build flag. State conventions as "codex avoids X except where Y," not as absolutes — absolutes rot.
A few docs were pruned
docs/tui-chat-composer.md and docs/tui-stream-chunking-tuning.md (and the older -review.md) were removed; docs/ now holds only bazel.md, codex_mcp_interface.md, and protocol_v1.md. Prefer citing source .rs files over docs/*.md, which are deleted more readily.
Don't trust "never uses X" claims without grepping
A mining pass proposed a rule that codex "never uses #[async_trait], always spells out impl Future + Send." A git grep found 78 live #[async_trait] uses — both styles coexist. Before encoding an absolute behavioral claim, count occurrences in the live tree.
{
"version": "1.1.0",
"organization": "OpenAI",
"technology": "Rust",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Distilled Rust coding patterns extracted from openai/codex (codex-rs, 2,008 Rust files across 119 workspace crates), refreshed against main at commit 8a94430 (2026-05-25). Captures the end-to-end craft of its top contributors — Michael Bolin, jif-oai, Ahmed Ibrahim, Eric Traut, and others — across defensive coding, error discipline, async cancellation, sandboxing, secret handling and process hardening, type invariants, testing, protocol design, workspace organization, observability, and Ratatui TUI architecture. Each rule cites the exact codex-rs file and shows a minimal incorrect/correct pair so the reader can internalize the judgment, not just the syntax.",
"references": [
"https://github.com/openai/codex",
"https://github.com/openai/codex/tree/main/codex-rs",
"https://github.com/openai/codex/blob/main/AGENTS.md",
"https://developers.openai.com/codex"
]
}
OpenAI Codex Rust Patterns — Skill Repository
Overview
This skill distills non-obvious Rust coding patterns from `openai/codex` — specifically the codex-rs/ workspace, a 72-crate, 1,418-file Rust codebase that implements the Codex CLI coding agent. Every rule here is extracted from actual production code written by the codex team (Michael Bolin, jif-oai, Ahmed Ibrahim, Eric Traut, Pavel Krymets, and others) and cites the exact file where the pattern lives.
Unlike most Rust "best practices" skills, the rules here are not copied from the Rust book or tutorial sites. They come from reading the code of people who ship a production coding agent that must survive LLM-generated input, cross-platform sandboxing, bursty streaming, and a 75-crate workspace — and they encode judgment that isn't obvious until you've been burned.
Getting Started
pnpm install
pnpm build
pnpm validateThe skill is read by Claude Code automatically when it triggers. Agents can also browse references/ directly to read individual rules.
To regenerate AGENTS.md after adding rules:
node /Users/pedroproenca/.claude/plugins/marketplaces/dot-claude/plugins/dev-skill/scripts/build-agents-md.js ~/.claude/skills/.experimental/openai-codex-rust-patternsTo validate the skill after changes:
node /Users/pedroproenca/.claude/plugins/marketplaces/dot-claude/plugins/dev-skill/scripts/validate-skill.js ~/.claude/skills/.experimental/openai-codex-rust-patternsCreating a New Rule
1. Pick a category prefix from references/_sections.md (e.g. async, defensive, errors). 2. Create references/{prefix}-{slug}.md — filename is all lowercase kebab-case. 3. Fill in the frontmatter: title, impact, impactDescription, tags (first tag must equal the category prefix). 4. Write the body: H2 heading matching the title, a 2–4 sentence explanation of the WHY, then **Incorrect (annotation):** and **Correct (annotation):** code blocks with language specifiers. 5. Re-run build-agents-md.js and validate-skill.js.
Rule File Structure
Every rule file has this shape:
---
title: Imperative Verb + Noun Phrase
impact: CRITICAL | HIGH | MEDIUM-HIGH | MEDIUM | LOW-MEDIUM | LOW
impactDescription: Quantified outcome (e.g., "prevents silent data loss on cancel")
tags: {prefix}, {technique}, {tool}, {concept}
---
## Imperative Verb + Noun Phrase
Explanation of the pattern and why it works — the reasoning the reader should
internalize so they can apply it to novel situations. 2–4 sentences.
**Incorrect (describes the failure mode):**
// naive / broken version
**Correct (describes the benefit):**
// the codex-rs pattern — cited with file:line in the explanation
Reference: `codex-rs/{crate}/src/{path}.rs`File Naming Convention
- Directory:
references/ - Pattern:
{prefix}-{slug}.md - Example:
async-abort-on-drop-handle.md - The prefix must match one of the category prefixes defined in
references/_sections.md. - Slugs are lowercase kebab-case, ideally short enough to read in a TOC.
Impact Levels
The skill uses six levels, ordered highest to lowest:
| Level | When to use |
|---|---|
| CRITICAL | Pattern prevents a class of production outages or silent corruption. The reader should never ship without it. |
| HIGH | Pattern saves meaningful debugging time, prevents common correctness bugs, or unblocks a whole architecture. |
| MEDIUM-HIGH | Pattern is load-bearing for a specific concern (tests, protocols) and is non-obvious. |
| MEDIUM | Pattern cleans up a real friction point. The codebase suffers without it but does not crash. |
| LOW-MEDIUM | Pattern is specific to a UI layer or tooling surface; broadly applicable but narrower in scope. |
| LOW | Minor stylistic or convention-level guidance. |
Impact inflation is a red flag — the distillation rubric expects at most 1–2 CRITICAL categories.
Scripts
| Script | Purpose |
|---|---|
dev-skill/scripts/validate-skill.js | Runs structural + substance validation. |
dev-skill/scripts/build-agents-md.js | Regenerates AGENTS.md from rule files. |
Both scripts live in the dev-skill plugin, not inside the skill itself.
Contributing
Additions should:
1. Come from real production code, not invented examples. 2. Cite the exact codex-rs/ file path for traceability. 3. Explain the WHY the pattern matters — what goes wrong without it. 4. Include both an incorrect and a correct example that differ minimally. 5. Pass validate-skill.js with zero errors before submission.
Rules that merely restate Rust book material (use Result, prefer enums, avoid unwrap) are rejected — the quality bar is "surprising to a mid-level Rust engineer".
Rule Categories
This document defines the category structure, impact levels, and file-name prefixes used by every rule in references/. Categories are ordered CRITICAL → LOW so the reader sees highest-impact patterns first.
1. Defensive Coding & Panic Discipline (defensive)
Impact: CRITICAL Description: Patterns that prevent panics in production and turn "should never happen" into grepable tombstones. Codex cannot crash when a tool handler sees malformed input, a subprocess spawns a grandchild, a lock is poisoned by another task, or a sandbox backend cannot enforce the requested policy — so the defensive rules are load-bearing for service availability. Also covers treating loaded plugins as untrusted input.
2. Error Handling & Result Discipline (errors)
Impact: CRITICAL Description: Patterns that shape how Result and error enums flow through the system. Retry classification, transient-vs-permanent splits, layer-boundary translators, and struct-typed display payloads — the things that decide whether a user sees a clean message or a cryptic trace.
3. Async, Concurrency & Cancellation (async)
Impact: HIGH Description: Tokio patterns for long-lived agents where tasks must clean up reliably, cancellation must win race ties, and channels must balance throughput against responsiveness. Covers AbortOnDropHandle, CancellationToken discipline, and the bounded-vs-unbounded channel split.
4. Sandboxing & Process Isolation (sandbox)
Impact: HIGH Description: Cross-platform sandbox patterns from running LLM-generated commands under Seatbelt, Landlock, seccomp, and Windows restricted tokens. Policy-as-data, argv[0] multiplexing, staged restrictions, refusing to run when enforcement is impossible, and resolving hostnames to defeat DNS-rebinding bypass of an egress allowlist.
5. Secrets & Process Hardening (secrets)
Impact: HIGH Description: Patterns for protecting credentials and the process itself, drawn from the responses-api proxy, process-hardening, and the auth crates. Reading a secret into a single zeroized, mlock'd copy; #[ctor] pre-main hardening that fails closed; and hand-written Debug impls that elide credentials so they never reach a log.
6. Type Design & Invariants (types)
Impact: HIGH Description: Newtype, enum, and trait patterns that encode invariants at compile time — thread-local RAII for serde context, try_from-driven validation, and forward-compatible enum variants.
7. Testing Architecture (testing)
Impact: MEDIUM-HIGH Description: Test organization patterns from a codebase with multi-thousand-line test files. Sibling foo_tests.rs files via #[path], wiremock-based fakes for SSE streams, AtomicBool test opt-ins, insta snapshot tests for Ratatui, and start_paused deterministic timing.
8. Protocol & Serde Design (proto)
Impact: MEDIUM-HIGH Description: Serde-based protocol patterns for a JSON-RPC-like wire format with streaming, experimental fields, and forward compatibility — macro-generated dispatchers, Option<Option<T>>, rename+alias migration, runtime experimental gating, and removed-feature tombstones for config round-tripping.
9. Workspace & Crate Organization (workspace)
Impact: MEDIUM Description: Cargo workspace patterns from a ~100-crate monorepo with near-zero per-crate features, layered transport/api/core crates, test-support as member crates, utils microcrate fan-out, and workspace-level lint enforcement via clippy.toml.
10. Observability & Tracing (otel)
Impact: MEDIUM Description: tracing and OpenTelemetry patterns for services with privacy constraints — log-only vs trace-safe targets, field::Empty placeholders, W3C traceparent propagation across env vars and RPC envelopes, and trace-level #[instrument] as the default.
11. TUI (Ratatui) Rendering (tui)
Impact: MEDIUM Description: Ratatui patterns from a streaming LLM TUI — two-gear hysteresis chunking, frame-request coalescing, panic-hook terminal restoration, unbracketed-paste burst detection, and pausing the event stream before a subprocess handoff.
Store JoinHandles as AbortOnDropHandle so Drop cancels them
A raw JoinHandle forces every cleanup path to remember handle.abort(); one missed error branch and the task leaks, running on against state that's being torn down. Codex stores tasks as tokio_util::task::AbortOnDropHandle inside its state structs, so dropping the owner aborts the task automatically — clearing the turn's task map is enough, with no abort call to forget. When a task is meant to outlive its owner, that intent is made explicit with .detach() rather than left implicit.
Incorrect (easy to leak tasks on error paths):
pub(crate) struct RunningTask {
pub(crate) handle: JoinHandle<()>,
}
fn clear_turn(turn: &mut ActiveTurn) {
for task in turn.drain_tasks() {
task.handle.abort(); // every error branch must remember this
}
}Correct (Drop does the work; detach is the deliberate opt-out):
// core/src/state/turn.rs
pub(crate) struct RunningTask {
pub(crate) cancellation_token: CancellationToken,
pub(crate) handle: AbortOnDropHandle<()>,
/* ... */
}
// core/src/tasks/mod.rs — construction site
let handle = tokio::spawn(async move { /* task body */ }.instrument(task_span));
turn.add_task(RunningTask { handle: AbortOnDropHandle::new(handle), /* ... */ });
// core/src/state/turn.rs — when removal should NOT cancel, say so explicitly
let task = self.tasks.swap_remove(sub_id)?;
task.handle.detach(); // intentionally let it finish after removalClearing the IndexMap<String, RunningTask> drops every AbortOnDropHandle, which aborts each underlying task — you never grep for "where is the abort". The one place a task should survive removal calls detach(), so the exception is visible in the code rather than being an accidental leak. Pair with [[async-child-cancellation-tokens]] for cooperative shutdown before the hard abort.
Reference: codex-rs/core/src/state/turn.rs:77, codex-rs/core/src/tasks/mod.rs:445.
Use biased select when cancellation must win ties
tokio::select! picks a ready branch at random by default — deliberate to avoid starvation, but it means a cancelled token and a just-arrived response can coin-flip. One run in fifty, cancellation loses the race and an approval appears granted. Adding biased; evaluates branches top-down instead, so the cancellation arm always wins when both are ready. Critically, the cancel arm does not just break — it actively notifies the parent waiter with an empty response so any pending consumer unwinds instead of hanging on an orphaned approval.
Incorrect (plain select, occasional lost cancellation):
tokio::select! {
_ = cancel_token.cancelled() => { /* tear down */ }
response = fut => { return response; }
}Correct (biased + active unwind):
// core/src/codex_delegate.rs
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
let empty = RequestUserInputResponse {
answers: HashMap::new(),
};
parent_session
.notify_user_input_response(sub_id, empty.clone())
.await;
empty
}
response = fut => response.unwrap_or_else(|| {
RequestUserInputResponse { answers: HashMap::new() }
}),
}biased; plus an active unwind of the parent's wait queue. Cancellation does not just drop the future; it converts to a synthetic decline that every waiter can observe — which is what prevents the "ghost approval" bug.
Reference: codex-rs/core/src/codex_delegate.rs:779.
Bound submissions but leave events unbounded
Defaulting every channel to mpsc::channel(1024) looks safe, but it creates two opposite bugs: an internal session loop stalls when a UI pauses (because its event channel is full), or an input queue OOMs when a client floods. Codex deliberately splits the two halves of its submission-event pair — user-facing submissions are bounded (clients backpressure when they submit too fast, which rate-limits them — a feature), and outbound events are unbounded (the event producer is the session loop itself, which must never block on a slow UI consumer or the whole agent stalls).
Incorrect (uniform bounded channels for both directions):
let (submission_tx, submission_rx) = mpsc::channel(1024);
let (event_tx, event_rx) = mpsc::channel(1024);
// Session loop blocks when event_tx fills, even during critical lock sections.Correct (split: bounded submissions, unbounded events):
// core/src/session/mod.rs
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();The decision rule is "who is the producer, and can they afford to wait?". External caller? Bounded, backpressure is a feature. Internal task holding a critical lock? Unbounded, blocking corrupts the session. For latency-sensitive data (audio frames) codex goes further with try_send plus drop-on-full: TrySendError::Full logs a warning and drops the frame rather than blocking.
Reference: codex-rs/core/src/session/mod.rs:480, codex-rs/core/src/realtime_conversation.rs:418.
Give spawned sub-tasks child tokens, not parent clones
CancellationToken::clone() and CancellationToken::child_token() look similar but have opposite semantics. Cloning gives you the same token — cancelling any clone cancels every other clone, including the parent. child_token() creates a derived token that inherits cancellation from its parent but can be cancelled independently. Codex uses child_token() religiously so a failed leg can be torn down without nuking its siblings, and a top-level cancel still cascades through the whole tree.
Incorrect (clone cascades an unrelated failure):
let events_cancel = cancel_token.clone();
let ops_cancel = cancel_token.clone();
tokio::spawn(async move { forward_events(rx, events_cancel).await });
tokio::spawn(async move { forward_ops(tx, ops_cancel).await });
// Cancelling events_cancel ALSO cancels ops — not what you want.Correct (child tokens scope independently):
// core/src/codex_delegate.rs
let cancel_token_events = cancel_token.child_token();
let cancel_token_ops = cancel_token.child_token();
tokio::spawn(async move {
forward_events(rx, cancel_token_events).await;
});
tokio::spawn(async move {
forward_ops(tx, cancel_token_ops).await;
});
// core/src/tasks/mod.rs — body derives ITS OWN child, so outer
// code can't accidentally observe the local token
let task_cancellation_token = cancellation_token.child_token();
let handle = tokio::spawn(async move {
task_for_run
.run(ctx, input, task_cancellation_token.child_token())
.await;
if !task_cancellation_token.is_cancelled() {
sess.on_task_finished(/* ... */).await;
}
});The local is_cancelled() check after run(...) is how Codex decides whether to emit the completion event: "finished normally" and "finished via cancellation" are both "the future returned" — the token is how you distinguish them.
Reference: codex-rs/core/src/codex_delegate.rs:119, codex-rs/core/src/tasks/mod.rs:388.
Cancel cooperatively first, then abort after a grace deadline
CancellationToken::cancel() is a request, not a kill — the task must reach an .await point that observes it. Plain handle.abort() skips cleanup entirely. Codex races the task's self-reported "done" Notify against a grace timeout in select!: signal cancellation, wait for up to GRACEFULL_INTERRUPTION_TIMEOUT_MS, then fall back to handle.abort(). Well-behaved tasks get time to flush rollouts and emit finalization events; stuck tasks are bounded.
Incorrect (immediate abort skips cleanup):
async fn shutdown_turn(turn: ActiveTurn) {
for task in turn.drain_tasks() {
task.handle.abort(); // rollouts, events, locks: all lost
}
}Correct (cancel, wait, then abort as fallback):
// core/src/tasks/mod.rs
async fn handle_task_abort(
self: &Arc<Self>,
task: RunningTask,
reason: TurnAbortReason,
) {
if task.cancellation_token.is_cancelled() {
return;
}
task.cancellation_token.cancel();
// Cancel sub-trackers owned by the task ...
select! {
_ = task.done.notified() => {}
_ = tokio::time::sleep(
Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS),
) => {
warn!(
"task {sub_id} didn't complete gracefully after {}ms",
GRACEFULL_INTERRUPTION_TIMEOUT_MS,
);
}
}
task.handle.abort(); // hard kill, no-op if task already exited
}task.done.notified() is the task's way of volunteering that it reached its cleanup tail; handle.abort() is the hard kill. Both always run — abort() is a no-op if the task already returned.
Reference: codex-rs/core/src/tasks/mod.rs:846.
Wrap background JoinHandle in Shared BoxFuture for multi-waiter joins
A JoinHandle can only be awaited once — it takes self. When several call sites need to know "has this background task finished?" (shutdown, parent supervisor, tests), you either hand out the handle and pray, or wrap it in Arc<Mutex<Option<JoinHandle>>> and the "first waiter" semantics break the second. Codex uses futures::future::Shared<BoxFuture<'static, ()>>: the combinator turns any future into one that can be cloned and polled from multiple places, with every clone resolving to the same result when the inner future completes.
Incorrect (single handle, second waiter panics):
pub struct SessionHandle {
pub join: JoinHandle<()>, // owned; only one caller can await
}
// Caller 1: await session.join — consumed.
// Caller 2: cannot even observe completion.Correct (Shared lets every caller await the same future):
// core/src/session/mod.rs
pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
pub(crate) fn session_loop_termination_from_handle(
handle: JoinHandle<()>,
) -> SessionLoopTermination {
async move {
let _ = handle.await;
}
.boxed()
.shared()
}
// Codex struct holds one of these; any number of callers can clone + await.The closure swallows handle.await's Result (panic detail is dropped on purpose — callers only care when it ends). Shared requires the output to be Clone, which () trivially is — that is why the function returns () instead of propagating the result.
Reference: codex-rs/core/src/session/mod.rs:380, codex-rs/core/src/session/mod.rs:819.
Avoid learning allowlist rules for general-purpose interpreters
When a system learns approvals and offers "always allow commands with this prefix", a single careless click on python3 -c "import os; os.system(...)" would green-light arbitrary code execution forever. Codex keeps a BANNED_PREFIX_SUGGESTIONS list of interpreter prefixes that the amendment suggester refuses to propose, using exact-length-and-sequence matching rather than starts_with, so legitimate rules like python3 myscript.py remain allowable while escape hatches like python3 -c are blocked.
Incorrect (learns an unbounded allowlist rule):
// User approved "python3 -c 'print(1)'", offer to remember the prefix
fn derive_amendment_from_approval(argv: &[String]) -> Option<Rule> {
let prefix = argv.iter().take(2).cloned().collect();
Some(Rule::allow_prefix(prefix)) // allows EVERY `python3 -c ...` forever
}Correct (explicit interpreter denylist, exact-sequence match):
// core/src/exec_policy.rs
static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[
&["python3", "-c"], &["python", "-c"],
&["bash", "-lc"], &["sh", "-c"], &["sh", "-lc"],
&["pwsh", "-Command"], &["node", "-e"],
&["perl", "-e"], &["ruby", "-e"], &["osascript"],
];
if BANNED_PREFIX_SUGGESTIONS.iter().any(|banned| {
prefix_rule.len() == banned.len()
&& prefix_rule.iter().map(String::as_str).eq(banned.iter().copied())
}) {
return None; // refuse to suggest a permanent allow rule
}The match is exact-length and exact-sequence — python3 script.py is still policy-able (different length), but python3 -c is not. Adding a new interpreter takes one line in a central list, not a code audit across every call site.
Reference: codex-rs/core/src/exec_policy.rs:52, codex-rs/core/src/exec_policy.rs:876.
Canonicalize shell wrappers before hashing approval keys
A naive approval cache keyed on argv re-prompts every time the same command arrives with a different shell wrapper — bash -lc vs /bin/bash -lc vs a heredoc. Codex canonicalizes first: unwrap simple sh -lc "cargo test" wrappers into their inner argv, and for unparseable scripts replace the shell path with a sentinel (__codex_shell_script__) while keeping the exact script text. The sentinel never collides with a real executable, so a match means "identical script", not "close enough".
Incorrect (caches on raw argv — re-prompts on every wrapper variation):
fn approval_key(command: &[String]) -> String {
command.join(" ")
}
// "bash -lc 'cargo test'" and "/bin/bash -lc 'cargo test'"
// hash to different keys even though they run the same script.Correct (unwrap simple wrappers, replace interpreter with sentinel otherwise):
// core/src/command_canonicalization.rs
pub(crate) fn canonicalize_command_for_approval(
command: &[String],
) -> Vec<String> {
if let Some(parsed) = parse_shell_lc_plain_commands(command)
&& let [single_command] = parsed.as_slice()
{
return single_command.clone();
}
if let Some((_shell, script)) = extract_bash_command(command) {
let shell_mode = command.get(1).cloned().unwrap_or_default();
return vec![
CANONICAL_BASH_SCRIPT_PREFIX.to_string(),
shell_mode,
script.to_string(),
];
}
command.to_vec()
}The && let [single_command] = ... guard refuses to collapse to an inner argv unless the parse produced exactly one command — a compound script cannot be mistakenly matched against an approval for one of its sub-commands. The sentinel constant CANONICAL_BASH_SCRIPT_PREFIX is chosen so it cannot appear as a legitimate binary name.
Reference: codex-rs/core/src/command_canonicalization.rs:14.
Use debug_assert with safe fallback on unreachable branches
unreachable!() and panic!() fire in both debug and release, so a single wrong assumption crashes production. Codex reaches for debug_assert!(false, "…") followed by an early return with a conservative fallback: loud failure in tests, graceful degradation in release. This is strictly distinct from unreachable!(), which is reserved for cases the type system already ruled out.
Incorrect (panics in production when a new git subcommand is added):
match subcommand {
"status" | "diff" | "log" => true,
other => panic!("unexpected git subcommand: {other}"),
}Correct (loud in debug, safe in release):
// shell-command/src/command_safety/is_safe_command.rs
match subcommand {
"status" | "diff" | "log" => true,
other => {
debug_assert!(false, "unexpected git subcommand from matcher: {other}");
false
}
}The fallback chooses the safer answer — false for "is this command safe?" — so a missed invariant never weakens security when it matters most. Tests observe the assertion and catch the regression during development; production users see a command fall through to the approval path instead of a crash.
Reference: codex-rs/shell-command/src/command_safety/is_safe_command.rs:192, codex-rs/core/src/codex_thread.rs:359.
Deny unwrap and expect at the workspace level
Panics in production are almost always .unwrap() or .expect() calls that slipped through review. Codex inverts the default: [workspace.lints.clippy] sets unwrap_used = "deny" and expect_used = "deny" so panicking becomes a compile error, and clippy.toml relaxes the ban inside tests only. Every intentional panic site must be annotated locally, turning each exception into a grepable tombstone whose reason is spelled out next to the code.
Incorrect (panic site slips through review):
// Buried in a helper function — reviewers can't scan for this
fn absolute_tmp_root() -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path("/tmp")
.expect("/tmp is absolute")
}Correct (workspace-wide deny, local annotated escape hatch):
# Cargo.toml — workspace root lints
[workspace.lints.clippy]
expect_used = "deny"
unwrap_used = "deny"// protocol/src/permissions.rs — escape hatch is visible and justified
FileSystemSpecialPath::SlashTmp => {
#[allow(clippy::expect_used)]
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp")
.expect("/tmp is absolute");
/* ... */
}The #[allow] attribute is the declaration that this expect is intentional, and the adjacent comment documents the invariant that makes it safe. Reviewers can git grep expect_used across the repo to audit every panic site in minutes.
Reference: codex-rs/Cargo.toml:438, codex-rs/protocol/src/permissions.rs:1476.
Load untrusted plugins fault-isolated and sanitize their model-facing text
A plugin manifest is untrusted input, so the two reflexive choices are both wrong: ?-propagating a load error lets one malformed plugin abort startup for everyone, and forwarding the manifest's description straight into the model's capability summary hands an attacker a prompt-injection channel. Codex treats each plugin as a fault domain — a failed load becomes an inert record, not a hard error — and runs every manifest string through a sanitizer before it can reach the model.
Incorrect (one bad plugin kills startup; manifest text reaches the model raw):
for cfg in configs {
let plugin = load_plugin(cfg)?; // a single failure aborts the whole load
summary.push(plugin.manifest_description.unwrap_or_default()); // unbounded, injectable
}Correct (error captured per plugin; description sanitized and capped):
// plugin/src/load_outcome.rs
pub struct LoadedPlugin<M> {
pub error: Option<String>, // a load failure is recorded, not propagated
/* ... */
}
impl<M> LoadedPlugin<M> {
pub fn is_active(&self) -> bool {
self.enabled && self.error.is_none() // errored plugins are silently excluded
}
}
pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?.split_whitespace().collect::<Vec<_>>().join(" ");
(!description.is_empty())
.then(|| description.chars().take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN).collect())
}Whitespace is collapsed (defeating layout-based injection) and the result is hard-capped at 1024 chars before it ever enters a model-facing summary. Plugin id segments are separately validated to [A-Za-z0-9_-] so a manifest can't smuggle ../ into the on-disk cache path. The shape generalizes: untrusted extension input is isolated per-unit and sanitized at the boundary.
Reference: codex-rs/plugin/src/load_outcome.rs:32, codex-rs/plugin/src/plugin_id.rs:51.
Cap subprocess output with a head-and-tail ring buffer
Trailing truncation (output[..MAX]) is wrong twice: it OOMs before the cap because you buffer everything first, and it discards the last lines — usually the most informative, containing errors and stack traces. Codex streams output into a fixed head budget plus a ring-buffer tail, tracks omitted_bytes between them, and uses saturating_* arithmetic everywhere so oversized chunks cannot panic. The reader also keeps consuming bytes past the cap so the child process does not deadlock on a full pipe.
Incorrect (trailing truncate loses the tail and still OOMs):
let mut collected = Vec::new();
while let Ok(read_count) = reader.read(&mut chunk_buf).await {
if read_count == 0 { break; }
collected.extend_from_slice(&chunk_buf[..read_count]);
}
collected.truncate(MAX_OUTPUT_BYTES); // last lines silently droppedCorrect (bounded head, ring-buffer tail, keeps draining after cap):
// core/src/unified_exec/head_tail_buffer.rs
pub(crate) fn push_chunk(&mut self, chunk: Vec<u8>) {
if self.max_bytes == 0 {
self.omitted_bytes = self.omitted_bytes.saturating_add(chunk.len());
return;
}
if self.head_bytes < self.head_budget {
let remaining_head = self.head_budget.saturating_sub(self.head_bytes);
if chunk.len() <= remaining_head {
self.head_bytes = self.head_bytes.saturating_add(chunk.len());
self.head.push_back(chunk);
return;
}
/* split head / tail */
}
/* push into ring buffer tail, updating omitted_bytes */
}
// core/src/exec.rs — keep draining after cap to avoid back-pressure
fn append_capped(dst: &mut Vec<u8>, src: &[u8], max_bytes: usize) {
if dst.len() >= max_bytes { return; }
let remaining = max_bytes.saturating_sub(dst.len());
let take = remaining.min(src.len());
dst.extend_from_slice(&src[..take]);
}The "keep draining after cap" rule is load-bearing: stop reading and a long-running child process deadlocks on its own full pipe, hanging the agent forever.
Reference: codex-rs/core/src/unified_exec/head_tail_buffer.rs:65, codex-rs/core/src/exec.rs:856.
Register a drain timeout to escape grandchild pipe leaks
Killing a timed-out child is not enough. If the child already forked grandchildren, they inherit the stdout and stderr pipes and hold them open, so the read() on the pipe never returns — hanging the whole agent. Codex runs the stdout collector in its own tokio::spawn and applies a separate IO_DRAIN_TIMEOUT_MS to joining that task, aborting the drain if it exceeds the deadline and returning an empty StreamOutput. The in-file comment explicitly pins the reasoning in place so no one removes the timeout as "redundant".
Incorrect (single timeout on the child — hangs on inherited pipes):
let output = tokio::time::timeout(
child_deadline,
child.wait_with_output(),
).await?;
// If the child is killed mid-run, grandchildren still hold stdout.
// wait_with_output() never returns — agent hangs forever.Correct (separate drain timeout with grepable justification):
// core/src/exec.rs:73 — comment pins the invariant
// If the child process spawned grandchildren that inherited its
// stdout/stderr file descriptors those pipes may stay open after we
// `kill` the direct child on timeout. That would cause the `read_capped`
// tasks to block on `read()` indefinitely, effectively hanging the whole
// agent.
pub const IO_DRAIN_TIMEOUT_MS: u64 = 2_000;
async fn await_output(
handle: &mut JoinHandle<io::Result<StreamOutput<Vec<u8>>>>,
drain_timeout: Duration,
) -> io::Result<StreamOutput<Vec<u8>>> {
match tokio::time::timeout(drain_timeout, &mut *handle).await {
Ok(join_res) => join_res?,
Err(_elapsed) => {
handle.abort();
Ok(StreamOutput { text: Vec::new(), truncated_after_lines: None })
}
}
}The killing order matters too: kill_child_process_group(&mut child) runs before child.start_kill() so the grandchildren get a SIGKILL through the process group, and the drain timeout is the belt-and-braces safety net if that fails.
Reference: codex-rs/core/src/exec.rs:81, codex-rs/core/src/exec.rs:1391.
Recover a poisoned lock with into_inner instead of unwrapping it
mutex.lock().unwrap() is the idiomatic-looking default, and in a long-lived multi-task agent it is a latent outage: if any thread panics while holding the lock, the Mutex becomes poisoned, and from then on every other .lock().unwrap() panics too. A single recoverable failure in one task thereby cascades into a process-wide crash. On its long-lived paths codex recovers the guarded data from the poison error instead of unwrapping — consistent with the workspace setting unwrap_used = "deny" (see [[defensive-deny-unwrap-workspace-wide]]).
Incorrect (poison turns one panic into a chain reaction):
let mut emitted = self.app_used_emitted_keys.lock().unwrap(); // panics forever once poisonedCorrect (recover the data and keep serving):
// analytics/src/client.rs — recover through the PoisonError
let mut emitted = self
.app_used_emitted_keys
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// the match form is equivalent and used where `?`-style reads better:
let guard = match self.cache.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};PoisonError::into_inner hands back the same MutexGuard, so the surviving threads keep working instead of inheriting an unrelated task's panic. Recover like this when the panicking section didn't leave the guarded value half-updated; if a broken invariant is possible, reset the state explicitly rather than blindly trusting it. The idiom recurs at ~40 call sites across ~17 crates precisely because process longevity depends on it.
Reference: codex-rs/analytics/src/client.rs:95, codex-rs/keyring-store/src/lib.rs:128.
Refuse to run when the sandbox cannot enforce the policy
Most software follows graceful degradation: if a feature is unsupported, do the best you can. For security boundaries, Codex does the opposite — it returns an error rather than running the command with weaker confinement. Every refusal message repeats the phrase "refusing to run unsandboxed" so the string is grepable across the codebase and obviously load-bearing to anyone tempted to soften it into an Ok(None) to make a test pass.
Incorrect (silent privilege erosion):
fn prepare_windows_sandbox(roots: &[WritableRoot]) -> Result<SandboxArgs> {
if windows_cannot_enforce_split_roots(roots) {
// Couldn't enforce — best effort, run without this restriction
tracing::warn!("split roots not enforceable, running anyway");
return Ok(SandboxArgs::default());
}
/* ... */
}Correct (fail closed with a grepable refusal string):
// core/src/exec.rs
let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| {
normalize_windows_override_path(candidate.root.as_path())
.is_ok_and(|candidate_path| candidate_path == split_root_path)
}) else {
return Err(
"windows unelevated restricted-token sandbox cannot enforce split \
writable root sets directly; refusing to run unsandboxed"
.to_string(),
);
};refusing to run unsandboxed appears verbatim in every refusal message across the sandbox backends, turning it into an audit grep. When ops debugs a blocked command, the error says exactly which primitive the backend lacks — not "permission denied" or "sandbox failed" which look like transient errors worth retrying.
Reference: codex-rs/core/src/exec.rs:1053, codex-rs/core/src/exec.rs:1094.
Translate errors at the layer boundary in one function
When layers are transport → api → core → protocol, letting ? bubble a reqwest::Error straight up to the retry loop forces every caller to re-parse HTTP status codes, headers, and JSON bodies — inconsistently. Codex centralizes the translation in one map_api_error function per boundary. This function is the only place that parses error-body JSON, pulls cf-ray and x-request-id headers, and invents protocol-level semantic variants like ServerOverloaded and UsageLimitReached.
Incorrect (HTTP inspection scattered across business logic):
// In three different files:
let resp = client.post(url).send().await?;
if resp.status() == StatusCode::SERVICE_UNAVAILABLE {
return Err(CodexErr::ServerOverloaded);
}
// Meanwhile in another caller: forgets the overloaded check entirely.Correct (one function owns the translation):
// codex-api/src/api_bridge.rs
pub fn map_api_error(err: ApiError) -> CodexErr {
match err {
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
ApiError::QuotaExceeded => CodexErr::QuotaExceeded,
ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay),
ApiError::Transport(transport) => match transport {
TransportError::Http { status, body, .. } => {
let body_text = body.unwrap_or_default();
if status == http::StatusCode::SERVICE_UNAVAILABLE
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
&& matches!(
value.get("error").and_then(|e| e.get("code"))
.and_then(serde_json::Value::as_str),
Some("server_is_overloaded" | "slow_down")
)
{
return CodexErr::ServerOverloaded;
}
/* other status-specific conversions */
CodexErr::UnexpectedStatus(status)
}
/* transport-level errors */
},
}
}The layer below returns a flat TransportError::Http { status, headers, body } and knows nothing about product semantics. The layer above never talks HTTP. Refactoring the reqwest client is now local — nothing above the boundary cares.
Reference: codex-rs/codex-api/src/api_bridge.rs:18.
Carry the server-requested retry delay inside the error variant
When the server sends Retry-After headers or encodes per-error delays, the naive approach is to thread retry_after: Option<Duration> alongside the error as a second return value, or stash it on the session struct. Codex puts the delay inside the error variant itself, so the retry loop pattern-matches to pick between "server said wait 2s" and "default exponential backoff". No extra argument plumbing, no side-channel state.
Incorrect (side-channel delay, prone to drift):
fn send_turn(&self) -> Result<Turn, (CodexErr, Option<Duration>)> { /* ... */ }
match self.send_turn() {
Err((err, Some(d))) => tokio::time::sleep(d).await,
Err((err, None)) => tokio::time::sleep(default_backoff()).await,
Ok(turn) => return Ok(turn),
}Correct (delay lives inside the variant):
// protocol/src/error.rs
#[derive(Debug, thiserror::Error)]
pub enum CodexErr {
/// Optionally includes the requested delay before retrying the turn.
#[error("stream disconnected before completion: {0}")]
Stream(String, Option<Duration>),
/* other variants */
}
// core/src/session/turn.rs — retry loop reads the hint directly
let delay = match &err {
CodexErr::Stream(_, requested_delay) => {
requested_delay.unwrap_or_else(|| backoff(retries))
}
_ => backoff(retries),
};
tokio::time::sleep(delay).await;The two-tuple variant Stream(String, Option<Duration>) is unusual — most thiserror users would define a struct variant. The positional form makes the "this carries a delay hint" fact visible at every construction site.
Reference: codex-rs/protocol/src/error.rs:79, codex-rs/core/src/session/turn.rs:993.
Classify retryable errors via an exhaustive match
A retry loop that uses matches!(err, ErrorA | ErrorB) or string-matching on error messages silently breaks every time a new variant is added — retryability is decided by whoever last touched the match site, not by the author of the new error. Codex defines is_retryable(&self) -> bool as a single match self listing every variant in both arms. The enum is deliberately NOT #[non_exhaustive], so adding a variant forces a compile error in this function until the author classifies it.
Incorrect (positive list with wildcard — new variants silently fall through):
impl CodexErr {
pub fn is_retryable(&self) -> bool {
matches!(
self,
CodexErr::Stream(..) | CodexErr::Timeout | CodexErr::Io(_)
)
// A new CodexErr::BrokenPipe returns false by default.
}
}Correct (exhaustive match, no wildcard arm):
// protocol/src/error.rs
pub fn is_retryable(&self) -> bool {
match self {
CodexErr::TurnAborted
| CodexErr::Interrupted
| CodexErr::EnvVar(_)
| CodexErr::Fatal(_) => false,
CodexErr::Stream(..)
| CodexErr::Timeout
| CodexErr::UnexpectedStatus(_)
| CodexErr::ResponseStreamFailed(_)
| CodexErr::ConnectionFailed(_)
| CodexErr::Io(_)
| CodexErr::Json(_)
| CodexErr::TokioJoin(_) => true,
#[cfg(target_os = "linux")]
CodexErr::LandlockRuleset(_)
| CodexErr::LandlockPathFd(_) => false,
}
}Both arms list every variant. The companion retry loop becomes a one-liner: if !err.is_retryable() { return Err(err); }. Impact is compile-time — a PR that adds BrokenPipe cannot merge until the author picks which bucket it belongs to.
Reference: codex-rs/protocol/src/error.rs:173, codex-rs/core/src/session/turn.rs:968.
Wrap io::Error in a struct with a context field
anyhow::Context gives you the "operation context plus underlying cause" shape, but it forces anyhow on every downstream consumer and makes PartialEq test assertions impossible — which matters when you are testing the shape of an error enum, not just the message. Codex defines a named struct with a context: String field and a #[source] source: std::io::Error field, derives thiserror::Error with #[error("{context}: {source}")], and adds a blanket From<io::Error> that supplies a default context for bare ? propagation.
Incorrect (anyhow leaks into a library crate):
// apply-patch/src/lib.rs
pub fn parse_patch(path: &Path) -> anyhow::Result<Patch> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
/* ... */
}
// Consumers must take an anyhow dependency; no PartialEq on anyhow::Error.Correct (named struct with context, no anyhow):
// apply-patch/src/lib.rs
#[derive(Debug, thiserror::Error)]
#[error("{context}: {source}")]
pub struct IoError {
context: String,
#[source]
source: std::io::Error,
}
impl PartialEq for IoError {
fn eq(&self, other: &Self) -> bool {
self.context == other.context
&& self.source.to_string() == other.source.to_string()
}
}
impl From<std::io::Error> for ApplyPatchError {
fn from(err: std::io::Error) -> Self {
ApplyPatchError::IoError(IoError {
context: "I/O error".to_string(),
source: err,
})
}
}
// Callers upgrade the context where they know better:
return Err(ApplyPatchError::IoError(IoError {
context: format!("Failed to read {}", path.display()),
source: e,
}));#[source] (not #[from]) on the field is deliberate — the derivation intentionally does not auto-wrap raw io::Error into IoError; that is what the hand-written From is for, so the default context is visible at the boundary. The custom PartialEq uses source.to_string() because io::Error does not implement PartialEq.
Reference: codex-rs/apply-patch/src/lib.rs:82, codex-rs/apply-patch/src/invocation.rs:192.
Store display-relevant error state in a struct, not a string
When an error needs rich user-facing rendering (plan-specific wording, retry timestamps, request ids), the temptation is to format the final message at construction time: CodexErr::UsageLimit(format!("You've hit...")). Every test that wants to check a plan-specific code path then does substring matching on a fragile English sentence. Codex keeps the raw inputs in a struct, hand-writes impl Display, and embeds the struct in the error enum — so tests assert against structured state and the UI still gets a clean error string.
Incorrect (format at construction, lose the state):
fn usage_limit_error(plan: PlanType, reset: DateTime<Utc>) -> CodexErr {
let msg = format!(
"You've reached your {} plan limit. Resets at {}.",
plan.name(),
reset.format("%H:%M"),
);
CodexErr::UsageLimitReached(msg)
}
// Test: assert!(err.to_string().contains("Pro plan")); — breaks on wording changeCorrect (structured payload, Display renders lazily):
// protocol/src/error.rs
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub plan_type: Option<PlanType>,
pub resets_at: Option<DateTime<Utc>>,
pub rate_limits: Option<Box<RateLimitSnapshot>>,
pub promo_message: Option<String>,
}
impl std::fmt::Display for UsageLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self.plan_type.as_ref() {
Some(PlanType::Known(KnownPlan::Plus)) => format!(/* ... */),
/* other plans */
_ => "Usage limit reached".to_string(),
};
write!(f, "{message}")
}
}
#[derive(Debug, thiserror::Error)]
pub enum CodexErr {
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
/* ... */
}rate_limits is boxed to keep the enum variant small enough to pass the workspace's large-error-threshold = 256 clippy lint. Tests assert against err.plan_type and err.resets_at directly; the UI still gets err.to_string().
Reference: codex-rs/protocol/src/error.rs:450.
Split tool errors into respond-to-model and fatal variants
In an agent loop, some tool failures should be surfaced back to the LLM as function-call output so the model can react ("file not found — try another path"), while others should abort the whole turn (auth expired, disk full). Codex defines FunctionCallError with variant names that encode where the error flows, not what went wrong. Every tool handler in the crate returns Result<T, FunctionCallError>. The upper layer has one match that converts RespondToModel into a conversation item and Fatal into a terminal CodexErr::Fatal.
Incorrect (anyhow::Error forces downstream downcasting):
fn handle_apply_patch(argv: &[String]) -> anyhow::Result<String> {
if /* missing file */ {
anyhow::bail!("patch rejected: file not found");
}
/* ... */
}
// Upper layer: try to downcast or string-match to decide what to do.Correct (two variants encoded at the construction site):
// tools/src/function_call_error.rs
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum FunctionCallError {
#[error("{0}")]
RespondToModel(String),
#[error("LocalShellCall without call_id or id")]
MissingLocalShellCallId,
#[error("Fatal error: {0}")]
Fatal(String),
}
// core/src/stream_events_utils.rs — upper layer matches once
Err(FunctionCallError::RespondToModel(message)) => {
let response = ResponseInputItem::FunctionCallOutput {
call_id: String::new(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
..Default::default()
},
};
output.needs_follow_up = true;
}
Err(FunctionCallError::Fatal(message)) => {
return Err(CodexErr::Fatal(message));
}Handlers convert every downstream error at the construction site — apply_patch turns a patch rejection into RespondToModel(...) while authentication failures become Fatal. There is no #[from] conversion: the crate author wants callers to consciously choose.
Reference: codex-rs/tools/src/function_call_error.rs:5, codex-rs/core/src/stream_events_utils.rs:425.
Encode transient vs permanent failures as two enum variants
When a failure can be either "log in again" (fatal) or "try in 2s" (retryable), a single error type plus a fn is_retryable(&self) -> bool is a refactor hazard — the decision is recomputed at every call site. Codex defines two variants whose names encode the retry policy, not the cause, and implements From conversions that explicitly map each variant to the right downstream error kind. The decision is made once, at the lowest level that has the information, and never recomputed.
Incorrect (one variant, boolean policy decided by callers):
pub struct RefreshError {
pub kind: RefreshErrorKind,
pub message: String,
}
impl RefreshError {
pub fn is_retryable(&self) -> bool {
matches!(self.kind, RefreshErrorKind::Transient)
}
}Correct (two variants, conversions decide policy once):
// login/src/auth/manager.rs
#[derive(Debug, Error)]
pub enum RefreshTokenError {
#[error("{0}")]
Permanent(#[from] RefreshTokenFailedError),
#[error(transparent)]
Transient(#[from] std::io::Error),
}
// core/src/client.rs — caller branches once, never recomputes
Err(RefreshTokenError::Permanent(failed)) => {
Err(CodexErr::RefreshTokenFailed(failed))
}
Err(RefreshTokenError::Transient(other)) => {
Err(CodexErr::Io(other))
}Permanent maps to a dedicated user-facing variant; Transient routes through CodexErr::Io, which is_retryable() reports as true. The retry loop handles it automatically — the two paths never need a shared conditional.
Reference: codex-rs/login/src/auth/manager.rs:102, codex-rs/core/src/client.rs:2011.
Declare span fields as field Empty then record when known
#[instrument] captures field values at entry, but the span's "true name" is often only known several statements later — after peeking at the next SSE event, or after dispatching a tool call by name. Spawning a new child span duplicates the parent's fields and loses start-to-first-byte timing on the original. Codex declares identifying fields as field::Empty up front, then calls span.record("field_name", value) once the fact is known. OpenTelemetry has a special field, otel.name, which tracing-opentelemetry uses to override the span name at export.
Incorrect (spawn a new child span once the identity is known):
let parent = trace_span!("receiving_stream");
let event = stream.next().await?;
// Lose parent's timing; duplicate fields on child
let child = trace_span!(parent: &parent, "tool_call", tool_name = ?event.tool);Correct (Empty placeholder, record when facts arrive):
// core/src/session/turn.rs
let receiving_span = trace_span!("receiving_stream");
let handle_responses = trace_span!(
parent: &receiving_span,
"handle_responses",
otel.name = field::Empty,
tool_name = field::Empty,
from = field::Empty,
);
// otel/src/events/session_telemetry.rs
pub fn record_responses(
&self,
handle_responses_span: &Span,
event: &ResponseEvent,
) {
handle_responses_span.record(
"otel.name",
SessionTelemetry::responses_type(event),
);
match event {
ResponseEvent::OutputItemDone(item) => {
handle_responses_span.record("from", "output_item_done");
if let ResponseItem::FunctionCall { name, .. } = item {
handle_responses_span.record("tool_name", name.as_str());
}
}
/* ... */
}
}
// core/src/tools/parallel.rs — default field + record-on-change
let dispatch_span = trace_span!(
"dispatch_tool_call",
otel.name = display_name.as_str(),
tool_name = display_name.as_str(),
call_id = call.call_id.as_str(),
aborted = false,
);
// ... later, inside tokio::select! on cancel:
dispatch_span.record("aborted", true);field::Empty is load-bearing — tracing-subscriber will not emit the field if record is never called, so empty placeholders reserve schema slots without producing null-like noise. The aborted = false default plus a single record("aborted", true) on cancel is how Codex tracks abort rates without a counter.
Reference: codex-rs/core/src/session/turn.rs:1750, codex-rs/otel/src/events/session_telemetry.rs:401.
Default instrument spans to trace level, reserve info for network calls
Sprinkling info_span! or #[instrument] at default level on every helper drowns stderr at INFO and pays the formatting cost for every call. Codex uses level = "trace" for almost all internal #[instrument] attributes (tool dispatch, turn sampling, parallel execution). Only functions that actually issue a network request are tagged level = "info". Since the default subscriber filter is codex_core=info, internal spans cost zero at runtime — the subscriber evaluates the static metadata and returns before formatting any arguments.
Incorrect (default-level instrument drowns stderr):
#[instrument] // defaults to INFO — fires on every tool dispatch
async fn dispatch_tool_call(
call: ToolCall,
turn: &TurnContext,
) -> ToolResult { /* ... */ }
// Stderr floods with "dispatch_tool_call" records in normal operation.Correct (trace default, info for network boundary, skip_all):
// core/src/session/turn.rs — internal code path
#[instrument(
level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
cwd = %turn_context.cwd.display(),
),
)]
async fn run_sampling_request(
/* ... */
) -> CodexResult<SamplingRequestResult> { /* ... */ }
// core/src/client.rs — network boundary gets INFO
#[instrument(
name = "model_client.websocket_connection",
level = "info",
skip_all,
fields(
provider = %self.client.state.provider.name,
wire_api = %self.client.state.provider.wire_api,
transport = "responses_websocket",
api.path = "responses",
turn.has_metadata_header = params.turn_metadata_header.is_some(),
),
)]
async fn websocket_connection(
&mut self,
params: WebsocketConnectParams<'_>,
) -> WebsocketResult { /* ... */ }
// core/src/tools/router.rs
#[instrument(level = "trace", skip_all, err)]
pub async fn build_tool_call(/* ... */) -> ToolResult { /* ... */ }The err argument is the idiomatic shortcut for "if this function returns Err, record it on the span automatically" — no manual error logging. Fields use % (Display) not ? (Debug) for paths and ids, because Display is bounded where Debug can explode. turn.has_metadata_header = ... .is_some() is a booleanization pattern — the field is always present with cardinality 2, never the raw header value.
Reference: codex-rs/core/src/client.rs:1121, codex-rs/core/src/session/turn.rs:892.
Build per-layer EnvFilter instances with boxed fmt layers
A single global EnvFilter shared across sinks forces every layer to accept the same threshold — so you cannot have INFO stderr logs while JSON-file logs capture TRACE. And swapping format conditionally between pretty and JSON forces you to duplicate the entire registry build because the two fmt layer types diverge. Codex builds one registry() with every layer chained via .with(...), gives each layer its own EnvFilter via a closure, and uses .boxed() inside a match on the format enum so both arms produce the same Layer trait object.
Incorrect (shared filter, duplicated registry):
let filter = EnvFilter::from_default_env();
if json_logs {
tracing_subscriber::registry()
.with(fmt::layer().json().with_filter(filter))
.init();
} else {
// Have to rebuild the entire registry — every layer duplicated.
tracing_subscriber::registry()
.with(fmt::layer().with_filter(filter))
.init();
}Correct (per-layer filters, boxed fmt to unify types):
// tui/src/lib.rs
let env_filter = || {
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
})
};
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_target(true)
.with_ansi(false)
.with_span_events(
tracing_subscriber::fmt::format::FmtSpan::NEW
| tracing_subscriber::fmt::format::FmtSpan::CLOSE,
)
.with_filter(env_filter());
// app-server/src/lib.rs — .boxed() unifies divergent generic types
let stderr_fmt: StderrLogLayer = match log_format_from_env() {
LogFormat::Json => tracing_subscriber::fmt::layer()
.json()
.with_writer(std::io::stderr)
.with_span_events(FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env())
.boxed(),
LogFormat::Default => tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_span_events(FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env())
.boxed(),
};
let _ = tracing_subscriber::registry()
.with(stderr_fmt)
.with(feedback_layer)
.with(log_db_layer)
.with(otel_logger_layer)
.with(otel_tracing_layer)
.try_init();FmtSpan::NEW | FmtSpan::CLOSE emits one record at span entry and one at close — giving timing for every instrumented function without writing any info!("started") / info!("done") pairs. try_init (vs init) is used because tests may have already set a subscriber.
Reference: codex-rs/tui/src/lib.rs:1202, codex-rs/app-server/src/lib.rs:619.
Route PII to log-only targets and keep traces cardinality-safe
Traces and logs typically go to different backends with different privacy tiers — traces to a wider-access APM, logs to a restricted pipeline. Per-field redaction is fragile; Codex gates at the target level. Two sentinel tracing targets — codex_otel.log_only and codex_otel.trace_safe — are installed on the logger layer and trace layer via filter functions that route on meta.target(). Events under log_only (carrying user.email, user.account_id) silently vanish from the trace exporter.
Incorrect (one target, per-field redaction after the fact):
tracing::info!(
user.email = metadata.account_email,
user.account_id = metadata.account_id,
conversation.id = %metadata.conversation_id,
"conversation started"
);
// Downstream processor has to remember to drop account_email per span.Correct (target routing via two macros):
// otel/src/events/shared.rs
macro_rules! log_event {
($self:expr, $($fields:tt)*) => {{
tracing::event!(
target: $crate::targets::OTEL_LOG_ONLY_TARGET,
tracing::Level::INFO,
$($fields)*
event.timestamp = %$crate::events::shared::timestamp(),
conversation.id = %$self.metadata.conversation_id,
user.account_id = $self.metadata.account_id,
user.email = $self.metadata.account_email,
model = %$self.metadata.model,
);
}};
}
// trace_event! — same expansion, but drops account_id / email.
// otel/src/provider.rs — filter functions on each layer
pub fn log_export_filter(meta: &tracing::Metadata<'_>) -> bool {
is_log_export_target(meta.target())
}
pub fn trace_export_filter(meta: &tracing::Metadata<'_>) -> bool {
meta.is_span() || is_trace_safe_target(meta.target())
}The log_and_trace_event! composite macro forces callers to explicitly classify extra fields as log:-only, trace:-only, or common:. Sensitive shapes go to log:; their cardinality-bounded counterparts (counts, booleans) go to trace:. Even the auth env fingerprint is a boolean, never the key itself.
Reference: codex-rs/otel/src/events/shared.rs:4, codex-rs/otel/src/provider.rs:184.
Propagate W3C traceparent via env, RPC, and HTTP headers
When Codex is one hop in a distributed trace — downstream of a CI system, upstream of an API server — each entry point needs to accept an incoming trace context and every outbound request needs to emit one. Codex funnels four entry points through the same TraceContextPropagator: TRACEPARENT env vars read once via OnceLock, typed W3cTraceContext fields inside JSON-RPC envelopes, traceparent headers on outbound HTTP, and warn! on invalid inbound contexts so nothing panics or fabricates a new root.
Incorrect (home-grown request_id UUID in log lines):
let request_id = Uuid::new_v4();
tracing::info!("request_id={request_id} starting turn");
// Correlating across services requires scraping logs.Correct (W3C traceparent in, W3C traceparent out, OnceLock env cache):
// otel/src/trace_context.rs
pub fn traceparent_context_from_env() -> Option<Context> {
TRACEPARENT_CONTEXT
.get_or_init(load_traceparent_context)
.clone()
}
fn load_traceparent_context() -> Option<Context> {
let traceparent = env::var(TRACEPARENT_ENV_VAR).ok()?;
let tracestate = env::var(TRACESTATE_ENV_VAR).ok();
match context_from_trace_headers(
Some(&traceparent),
tracestate.as_deref(),
) {
Some(context) => {
debug!("continuing parent trace context");
Some(context)
}
None => {
warn!("TRACEPARENT is set but invalid; ignoring");
None
}
}
}
pub fn span_w3c_trace_context(span: &Span) -> Option<W3cTraceContext> {
let context = span.context();
if !context.span().span_context().is_valid() {
return None;
}
let mut headers = HashMap::new();
TraceContextPropagator::new()
.inject_context(&context, &mut headers);
Some(W3cTraceContext {
traceparent: headers.remove("traceparent"),
tracestate: headers.remove("tracestate"),
})
}// app-server/src/app_server_tracing.rs — request > env > new root
fn attach_parent_context(
span: &Span,
method: &str,
request_id: &impl std::fmt::Display,
parent_trace: Option<&W3cTraceContext>,
) {
if let Some(trace) = parent_trace {
if !set_parent_from_w3c_trace_context(span, trace) {
tracing::warn!(
rpc_method = method,
rpc_request_id = %request_id,
"ignoring invalid inbound request trace carrier"
);
}
} else if let Some(context) = traceparent_context_from_env() {
set_parent_from_context(span, context);
}
}The env-var load is gated behind OnceLock because TRACEPARENT is set at process start — reading it every span creation would be wasteful. The fallback priority (request-provided > env > new root) is the inverse of what most codebases get wrong.
Reference: codex-rs/otel/src/trace_context.rs:91, codex-rs/app-server/src/app_server_tracing.rs:132.
Use double-nested Options to distinguish absent, null, and set
In a PATCH-like update API a plain Option<T> collapses "leave this field alone" and "explicitly clear this field" into one state. Option<Option<T>> recovers the third state — but only if you wire up the deserializer, because serde's default maps a JSON null straight to the outer None, making it indistinguishable from an omitted field. Codex routes these fields through serde_with::rust::double_option so None = omitted (leave unchanged), Some(None) = JSON null (clear), and Some(Some(v)) = set.
Incorrect (plain Option, or a bare `Option<Option<T>>` without the helper):
// Both of these collapse "clear" into "unchanged":
service_tier: Option<String>, // {"service_tier": null} == field omitted
service_tier: Option<Option<String>>, // null still deserializes to the OUTER NoneCorrect (double-option helper wired via deserialize_with):
// app-server-protocol/src/protocol/v2/thread.rs
#[serde(
default,
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
pub service_tier: Option<Option<String>>,
// app-server-protocol/src/protocol/serde_helpers.rs — one shared implementation
pub fn deserialize_double_option<'de, T, D>(d: D) -> Result<Option<Option<T>>, D::Error>
where T: Deserialize<'de>, D: Deserializer<'de> {
serde_with::rust::double_option::deserialize(d)
}#[serde(default)] supplies the omitted → None case; deserialize_double_option forces a present-but-null value to Some(None) instead of letting it fold back to None. Centralizing the helper in serde_helpers.rs means every mutation-shaped field (ThreadStartParams, TurnOptions, process/realtime params) shares one implementation — no per-field FieldAction<T> { Unchanged, Clear, Set(T) } enum with three mappings each.
Reference: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:100, codex-rs/app-server-protocol/src/protocol/serde_helpers.rs:16.
Gate experimental fields by runtime presence, not capability flags
Adding an unstable field to a stable method normally forces you to either duplicate the whole request type (StableThreadStartParams vs ExperimentalThreadStartParams) or make every caller opt into an "experimental" capability just to call the stable part. Codex has a #[derive(ExperimentalApi)] proc-macro plus #[experimental("method.fieldName")] attributes. The generated impl walks the struct at runtime and returns a reason string only if that field is actually present with a non-default value — empty Vec, false, or None all count as "not using the experimental feature".
Incorrect (duplicate request types for every experimental field):
// Two parallel types, each adds bloat for every stable field:
pub struct StableThreadStartParams { /* 20 fields */ }
pub struct ExperimentalThreadStartParams {
/* 20 fields + experimental_dynamic_tools: Vec<Tool> */
}Correct (runtime presence check via derive macro):
// codex-experimental-api-macros/src/lib.rs
fn presence_expr_for_access(
access: proc_macro2::TokenStream,
ty: &Type,
) -> proc_macro2::TokenStream {
if let Some(inner) = option_inner(ty) {
let inner_expr = presence_expr_for_ref(quote!(value), inner);
return quote! {
#access.as_ref().is_some_and(|value| #inner_expr)
};
}
if is_vec_like(ty) || is_map_like(ty) {
return quote! { !#access.is_empty() };
}
if is_bool(ty) {
return quote! { #access };
}
quote! { true }
}
// app-server-protocol/src/experimental_api.rs
impl<T: ExperimentalApi> ExperimentalApi for Option<T> {
fn experimental_reason(&self) -> Option<&'static str> {
self.as_ref()
.and_then(ExperimentalApi::experimental_reason)
}
}Reason strings follow a reverse-DNS-ish scheme (thread/start.dynamicTools, askForApproval.granular) that maps 1:1 to the wire method and field name. The dispatcher calls experimental_reason() after parsing; if the client did not negotiate experimentalApi: true during initialize and a reason is returned, the method is rejected.
Reference: codex-rs/codex-experimental-api-macros/src/lib.rs:260.
Split internal error enums from wire error enums
A single pub enum ProtocolError that doubles as both internal type and wire type freezes refactoring — every internal change risks breaking year-old clients. Codex keeps them separate: CodexErr is the internal thiserror enum with 30+ variants, From conversions from io::Error and serde_json::Error, and .downcast_ref() helpers. CodexErrorInfo is the wire type — ~15 variants, every "connection failed" shape carries http_status_code: Option<u16>. A translator to_codex_protocol_error() maps one to the other and picks up the HTTP status from whichever variant carries it.
Incorrect (single enum doubles as internal + wire):
#[derive(Serialize, Deserialize, thiserror::Error)]
pub enum ProtocolError {
// Refactoring internal shape breaks wire clients.
Io(#[from] std::io::Error), // serde panics on this boundary anyway
Stream(String),
}Correct (internal enum + wire enum + translator):
// protocol/src/error.rs — internal, rich
impl CodexErr {
pub fn to_codex_protocol_error(&self) -> CodexErrorInfo {
match self {
CodexErr::ContextWindowExceeded => {
CodexErrorInfo::ContextWindowExceeded
}
CodexErr::UsageLimitReached(_)
| CodexErr::QuotaExceeded
| CodexErr::UsageNotIncluded => {
CodexErrorInfo::UsageLimitExceeded
}
CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
CodexErr::RetryLimit(_) => {
CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: self.http_status_code_value(),
}
}
CodexErr::ConnectionFailed(_) => {
CodexErrorInfo::HttpConnectionFailed {
http_status_code: self.http_status_code_value(),
}
}
/* ... */
}
}
}
// app-server-protocol/src/protocol/v2/shared.rs — wire, frozen shape
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
HttpConnectionFailed {
#[serde(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
/* ~15 variants, every one additive */
}You can refactor CodexErr freely (add fields, reshape tuples, swap underlying libraries) and only the translator cares — the wire protocol stays frozen. The two types never share a derive chain; there is no From conversion between them, only the explicit to_codex_protocol_error() method.
Reference: codex-rs/protocol/src/error.rs:220, codex-rs/app-server-protocol/src/protocol/v2/shared.rs:71.
Dispatch JSON-RPC via an internally tagged enum with a macro
Hand-writing a dispatcher that matches request["method"] and then runs serde_json::from_value::<FooParams>(request["params"]) drifts silently every time a method is added — the match, the param type, and the response type each live in a different file. Codex defines a macro (client_request_definitions!) that generates a #[serde(tag = "method", rename_all = "camelCase")] enum where each variant carries request_id and params as struct fields. Internally tagging lines up with JSON-RPC's wire format — one serde_json::from_value call validates method and parses typed params in a single pass.
Incorrect (hand-rolled dispatch drifts from typed params):
let method = request["method"].as_str().unwrap();
match method {
"initialize" => {
let params: InitializeParams =
serde_json::from_value(request["params"].clone())?;
handler.initialize(params).await
}
"threadStart" => {
let params: ThreadStartParams =
serde_json::from_value(request["params"].clone())?;
handler.thread_start(params).await
}
// Missed any? Silent ignore. Added a method? Edit three places.
}Correct (internally tagged enum, one from_value call):
// app-server-protocol/src/protocol/common.rs — macro expansion
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientRequest {
Initialize {
#[serde(rename = "id")]
request_id: RequestId,
params: InitializeParams,
},
ThreadStart {
#[serde(rename = "id")]
request_id: RequestId,
params: ThreadStartParams,
},
/* 30+ more — each generated by the macro */
}
impl TryFrom<JSONRPCRequest> for ServerRequest {
type Error = serde_json::Error;
fn try_from(value: JSONRPCRequest) -> Result<Self, Self::Error> {
serde_json::from_value(serde_json::to_value(value)?)
}
}The macro invocation pairs each variant with both a params and response type, so you cannot add a method without declaring both sides of the conversation. The TryFrom<JSONRPCRequest> becomes a one-liner because all the work is in the serde tag attribute.
Reference: codex-rs/app-server-protocol/src/protocol/common.rs:161.
Keep removed feature flags as parseable no-op tombstones
The instinct when retiring a feature is to delete its enum variant and config key. But now an older config file that still sets the key fails to parse on the new binary, and a config written by the new binary may surprise an older one — the flag becomes a hard compatibility break in both directions. Codex models a flag's whole lifecycle in a Stage enum and keeps removed flags as inert, still-parseable entries; the value is ignored but the key never errors.
Incorrect (deleting the variant breaks existing configs):
pub enum Feature {
ShellTool,
// WebSearch removed — now {"web_search": true} in any saved config fails to parse
}Correct (Stage::Removed tombstone, ignored not rejected):
// features/src/lib.rs
pub enum Stage {
UnderDevelopment,
Experimental { name: &'static str, menu_description: &'static str, announcement: &'static str },
Stable,
Deprecated,
/// The feature flag is useless but kept for backward compatibility.
Removed,
}
// apply_map: a Removed key is consumed and skipped, never an error;
// a genuinely unknown key is warn!-logged, not fatal.A Removed flag is excluded from the experimental menu and from metrics, but it still parses, so configs survive across versions in both directions. The same registry distinguishes Removed (kept for compat) from Deprecated (still works, discouraged) — two different promises to existing users. This is the config-evolution dual of [[types-unknown-variant-forward-compat]].
Reference: codex-rs/features/src/lib.rs:44, codex-rs/features/src/lib.rs:413.
Pair rename and alias to migrate wire names without breaking clients
Renaming a variant or field on the wire normally means a flag day — ship the new name and every old client breaks. Codex renames wire strings in place but keeps the old string alive as a read-only alias. #[serde(rename)] controls what goes out; #[serde(alias)] controls what can come in. New code writes task_started; an old client that still sends turn_started parses fine. Combined with #[non_exhaustive] on the enum, external crates also cannot write exhaustive matches that would block the migration.
Incorrect (rename only — every old client breaks):
#[serde(rename = "task_started")]
TurnStarted(TurnStartedEvent),
// v1 client sending "turn_started" -> serde error, ignored or crashes.Correct (rename + alias, documented with a v1/v2 note):
// protocol/src/protocol.rs
/// Agent has started a turn.
/// v1 wire format uses `task_started`; accept `turn_started` for v2 interop.
#[serde(rename = "task_started", alias = "turn_started")]
TurnStarted(TurnStartedEvent),
/// Agent has completed all actions.
/// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.
#[serde(rename = "task_complete", alias = "turn_complete")]
TurnComplete(TurnCompleteEvent),The Rust identifier (TurnStarted) is decoupled from both wire names — renaming internally is free. A doc comment records which name is v1 and which is v2, so future grep-and-refactor passes can find the migration sites. Other files use #[serde(default, alias = "agent_type")] when field names (not variants) migrate the same way.
Reference: codex-rs/protocol/src/protocol.rs:1174.
Treat SSE streams as idle-timeout with a required terminator
A while let Some(event) = stream.next().await loop with a wall-clock deadline either kills legitimate long turns or never fires at all. Codex's process_sse loop re-arms the timeout on every stream.next() call — activity resets it, so long-running turns never hit a total deadline. And a clean Ok(None) return (stream closed) is treated as an error unless a response.completed event was observed: "stream closed before response.completed".
Incorrect (wall-clock deadline kills legit turns):
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
match stream.next().await {
Some(Ok(event)) => process(event),
Some(Err(_)) | None => break,
}
}
// A 90-second turn dies at 60s; a half-closed stream silently succeeds.Correct (per-poll idle timeout, terminator required):
// codex-api/src/sse/responses.rs
loop {
let response = timeout(idle_timeout, stream.next()).await;
let sse = match response {
Ok(Some(Ok(sse))) => sse,
Ok(Some(Err(transport_err))) => {
let _ = tx_event.send(Err(transport_err.into())).await;
return;
}
Ok(None) => {
let error = response_error.unwrap_or(ApiError::Stream(
"stream closed before response.completed".into(),
));
let _ = tx_event.send(Err(error)).await;
return;
}
Err(_) => {
let _ = tx_event
.send(Err(ApiError::Stream(
"idle timeout waiting for SSE".into(),
)))
.await;
return;
}
};
/* dispatch sse event */
}The missing-terminator error maps to CodexErr::Stream, which is_retryable() reports as true — so the session loop auto-retries half-closes instead of surfacing a mystery. The stream is bridged to the consumer via a bounded mpsc::channel(1600) rather than exposed as a raw futures::Stream, giving proper backpressure and an explicit close signal.
Reference: codex-rs/codex-api/src/sse/responses.rs:399, codex-rs/protocol/src/error.rs:78.
Multiplex helper binaries via argv[0] and symlinks
Shipping multiple binaries (codex, codex-linux-sandbox, apply_patch) is a packaging headache — and finding codex-linux-sandbox via which opens a TOCTOU between lookup and exec. Codex ships one binary. On startup it inspects argv[0]'s basename and dispatches into the relevant sub-entry-point, otherwise falls through to main. At startup the CLI creates a locked per-session temp dir under ~/.codex/tmp/arg0/, drops symlinks for each alias pointing at current_exe(), and prepends that dir to PATH.
Incorrect (multiple binaries, TOCTOU on lookup):
let helper = which::which("codex-linux-sandbox")?; // race window
Command::new(helper).args(...).spawn()?;Correct (single binary, argv[0] dispatch, locked temp symlinks):
// arg0/src/lib.rs
pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
let mut args = std::env::args_os();
let argv0 = args.next().unwrap_or_default();
let exe_name = Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
codex_linux_sandbox::run_main();
} else if exe_name == APPLY_PATCH_ARG0 {
codex_apply_patch::main();
}
/* return guard for symlink temp dir */
}
// linux-sandbox/src/linux_run_main.rs — bwrap preserves argv0
if supports_argv0 {
argv.splice(
command_separator_index..command_separator_index,
["--argv0".to_string(), CODEX_LINUX_SANDBOX_ARG0.to_string()],
);
}The temp dir is chmod 0700 and locked via fs2::try_lock so a janitor thread can clean stale siblings without racing live sessions. Windows, which lacks good symlinks, falls back to generated .bat stubs that exec the main binary.
Reference: codex-rs/arg0/src/lib.rs:54, codex-rs/linux-sandbox/src/linux_run_main.rs:422.
Mount /dev/null over the first missing path component
A naive read-only allowlist that says ".codex/ is read-only inside the writable workspace" has a gap: if .codex/ does not exist at sandbox setup time, there is nothing to bind-mount over, and a child process can mkdir .codex and write whatever it wants. Codex walks the protected path, finds the first non-existent component, and bind-mounts /dev/null onto it. That turns the would-be directory into an unwritable character device, so mkdir fails with ENOTDIR.
Incorrect (only mounts existing paths — gap on non-existent ones):
if subpath.exists() {
args.push("--ro-bind".to_string());
args.push(path_to_string(subpath));
args.push(path_to_string(subpath));
}
// Else: child can mkdir the protected name and write freely.Correct (mount /dev/null over the first missing component):
// linux-sandbox/src/bwrap.rs
if !subpath.exists() {
if let Some(first_missing_component) =
find_first_non_existent_component(subpath)
&& is_within_allowed_write_paths(
&first_missing_component,
allowed_write_paths,
)
{
args.push("--ro-bind".to_string());
args.push("/dev/null".to_string());
args.push(path_to_string(&first_missing_component));
}
return;
}
// The file-fd-mount variant for unreadable carveouts:
if preserved_files.is_empty() {
preserved_files.push(File::open("/dev/null")?);
}
let null_fd = preserved_files[0].as_raw_fd().to_string();
args.push("--perms".to_string());
args.push("000".to_string());
args.push("--ro-bind-data".to_string());
args.push(null_fd);
args.push(path_to_string(unreadable_root));The file-fd side uses preserved_files: Vec<File> to keep the /dev/null handle alive across the spawn. The equivalent Seatbelt policy blocks the same hole via (require-not (literal ...)) alongside (require-not (subpath ...)) because Seatbelt's (subpath) predicate does not cover first-time creation of the directory itself.
Reference: codex-rs/linux-sandbox/src/bwrap.rs:1058, codex-rs/linux-sandbox/src/bwrap.rs:1076.
Clear the env and tether children via pre_exec before every spawn
Inherited environments leak LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, and ambient shell secrets into every child — and if the agent is kill -9'd, its children keep running compute forever. Codex's spawn path calls cmd.env_clear() before re-adding a whitelisted env map, and in the pre_exec closure does three orthogonal things: detach_from_tty, PR_SET_PDEATHSIG(SIGTERM), and outside the closure kill_on_drop(true).
Incorrect (inherits env and leaks grandchildren):
let mut cmd = Command::new(program);
cmd.args(args); // inherits LD_PRELOAD, LD_LIBRARY_PATH, secrets
let handle = cmd.spawn()?; // no pdeathsig — kill -9 orphans computeCorrect (clear env + tether via pre_exec):
// core/src/spawn.rs
let mut cmd = Command::new(&program);
#[cfg(unix)]
cmd.arg0(
arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from),
);
cmd.args(args);
cmd.current_dir(cwd);
cmd.env_clear();
cmd.envs(allowed_env);
#[cfg(unix)]
unsafe {
let detach_from_tty = matches!(
stdio_policy,
StdioPolicy::RedirectForShellTool,
);
#[cfg(target_os = "linux")]
let parent_pid = libc::getpid(); // captured BEFORE the closure
cmd.pre_exec(move || {
if detach_from_tty {
codex_utils_pty::process_group::detach_from_tty()?;
}
#[cfg(target_os = "linux")]
codex_utils_pty::process_group::set_parent_death_signal(parent_pid)?;
Ok(())
});
}
cmd.stdin(Stdio::null()); // ripgrep hangs on open empty pipe otherwise
cmd.kill_on_drop(true);parent_pid is captured before the closure because inside pre_exec the child is already a new process — getpid() there would return the child's own pid. stdin = Stdio::null() is specifically because ripgrep has a heuristic that reads stdin when it's an open pipe, causing it to hang on an empty one.
Reference: codex-rs/core/src/spawn.rs:75, codex-rs/core/src/spawn.rs:91.
Resolve hostnames and reject private IPs before allowing egress
A network allowlist enforced by string-matching the hostname is trivially bypassed: an attacker registers evil.example.com, points its A record at 127.0.0.1 (or a metadata endpoint like 169.254.169.254), and the literal-string check happily allows it. Codex's egress proxy treats string checks as insufficient — when local binding is disabled it does a best-effort DNS lookup with a timeout and blocks the request if any resolved IP is non-public, even when the host is on the allowlist.
Incorrect (string allowlist, rebinding walks right through):
// "localhost"/"127.0.0.1" literals blocked, but evil.example.com -> 127.0.0.1 is allowed
if is_allowlisted(host_str) && host_str != "localhost" {
return Decision::Allowed;
}Correct (classify the literal, then resolve and classify the IPs):
// network-proxy/src/runtime.rs — when local binding is off
let local_literal = if is_loopback_host(&host) {
true
} else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
is_non_public_ip(ip) // 127/8, 10/8, 169.254/16, ::1, link-local, ...
} else {
false
};
if local_literal {
if !is_explicit_local_allowlisted(&allowed_domains, &host) {
return Ok(Blocked(NotAllowedLocal));
}
} else if host_resolves_to_non_public_ip(host_str, port, DNS_LOOKUP_TIMEOUT, resolve).await {
return Ok(Blocked(NotAllowedLocal)); // rebinding caught here, allowlist or not
}The two-step check matters: an IP literal is classified directly, but a hostname must be resolved first, because the danger lives in what it resolves to, not how it is spelled. is_non_public_ip leans on stdlib classifiers (is_loopback, is_private, is_link_local) plus CIDR fallbacks for ranges stdlib doesn't cover yet (CGNAT, TEST-NET).
Reference: codex-rs/network-proxy/src/runtime.rs:385, codex-rs/network-proxy/src/policy.rs:51.
Related skills
FAQ
What does openai-codex-rust-patterns do?
openai-codex-rust-patterns: A skill for development. This provides functionality for development workflows.
When should I use openai-codex-rust-patterns?
When you need to use openai-codex-rust-patterns for development tasks, or when openai-codex-rust-patterns: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
openai-codex-rust-patterns.