apply_patch sandbox retry can request duplicate approval and hang app-server turns

Open 💬 1 comment Opened May 5, 2026 by mzhaom

Summary

apply_patch can request a second approval during the sandbox retry path even after the patch was already config/auto-approved. In app-server or wrapper contexts where that internal retry approval is not answered, the turn can wait indefinitely without producing a tool result or turn/completed.

I have a tested fix on this branch/commit:

I could not open a PR because this account/token is blocked from CreatePullRequest against openai/codex, even though mzhaom/codex is a real fork and the branch is comparable.

Observed behavior

A run got stuck after Codex core accepted and internally dispatched an apply_patch freeform custom tool call. The model response completed and Codex logged the tool as config-approved, but there was no corresponding custom_tool_call_output, no final assistant message, and no turn/completed event.

The target worktree remained clean and the expected patched file was not created, so this was not just client-side rendering or wrapper output handling.

Root cause

Tracing the core tool path showed:

  1. The Responses API emitted a freeform custom_tool_call for apply_patch.
  2. Codex core accepted it and routed it through ToolRouter::dispatch_tool_call_with_code_mode_result into ApplyPatchHandler.
  3. apply_patch::apply_patch assessed the patch as safe and returned ExecApprovalRequirement::Skip.
  4. ToolOrchestrator logged the tool decision as config-approved.
  5. The first sandboxed attempt can fail when the active workspace-write policy does not allow the target write.
  6. ApplyPatchRuntime allows a no-sandbox retry under AskForApproval::OnRequest.
  7. The orchestrator did not remember the earlier Skip decision as already_approved, so the retry path requested a second approval.
  8. In app-server/wrapper contexts that do not answer that retry approval, the tool call never produces a result and the turn never completes.

The bug is the lost approval state between the initial config-approved Skip and the later sandbox retry decision.

Fix on branch

The branch adds a small explicit hook to the Approvable trait:

fn skip_requirement_counts_as_approval(&self, _req: &Req) -> bool {
    false
}

The default is false, preserving current behavior for other tools. ApplyPatchRuntime opts in because its upstream safety assessment has already approved the patch operation before execution is delegated to the runtime.

ToolOrchestrator now sets already_approved from that hook in the non-guardian ExecApprovalRequirement::Skip path. That lets apply_patch retry outside the sandbox without emitting a duplicate approval request.

Changed files:

  • codex-rs/core/src/tools/sandboxing.rs
  • codex-rs/core/src/tools/orchestrator.rs
  • codex-rs/core/src/tools/runtimes/apply_patch.rs
  • codex-rs/core/src/session/tests.rs

Repro test

The branch adds config_approved_tool_retry_does_not_request_second_approval, which uses a fake runtime that:

  • is config-approved via ExecApprovalRequirement::Skip
  • fails the first sandboxed attempt with SandboxErr::Denied
  • allows a no-sandbox retry
  • counts approval requests

With only the orchestrator propagation line removed, the test fails exactly as expected:

assertion failed: `(left == right)`
left: 1
right: 0

That proves the original behavior requested one unexpected second approval. With the fix restored, the same test passes.

Validation run

Local validation:

cargo fmt --package codex-core
cargo test -p codex-core config_approved_tool_retry_does_not_request_second_approval
cargo test -p codex-core apply_patch
cargo test -p codex-app-server turn_start_does_not_stream_apply_patch_change_updates_without_feature_v2
git diff --check

Results:

  • focused regression test passed with the fix
  • pre-fix reproduction failed with one unexpected retry approval
  • cargo test -p codex-core apply_patch passed: 293 tests
  • app-server apply_patch turn completion test passed
  • git diff --check passed

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗