Fork/side conversation fails when $CODEX_HOME/sessions is a symlink: rollout path 'must be in Codex home directory'
Summary
/fork and side conversations fail for every paginated thread whenever $CODEX_HOME/sessions is a symlink pointing outside the Codex home — a rollout Codex itself wrote is rejected by the fork containment check.
What breaks
When $CODEX_HOME/sessions is a symlink pointing outside the Codex home (a legitimate layout — e.g. session storage relocated to another volume or a shared store), /fork and side conversations fail for every paginated thread with:
thread/fork failed: rollout path `<home>/sessions/2026/08/18/rollout-….jsonl` must be in Codex home directory (code -32600)
The rollout was written by Codex itself, through its own configured home. Reads, resume, archive, and unarchive all work fine in this layout; only fork rejects it.
Why
scoped_rollout_path (thread-store/src/local/helpers.rs) canonicalizes both the root and the rollout path and requires starts_with:
- For the fork-lineage caller the root is
codex_homeitself.canonicalize(codex_home)resolves to the real home dir, butcanonicalize(rollout_path)resolves through thesessionssymlink to the external target — so containment fails for a path the store composed through its own home. - Archive/unarchive/delete pass
codex_home/sessions(orarchived_sessions) as the root, where canonicalizing the root also walks through the symlink — which is why only fork breaks.
Repro needs nothing but ln -s: symlink a home's sessions dir elsewhere, create a session, /fork.
Fix
Accept a rollout path that is lexically contained in the root as written (absolute, no ./.. traversal in the suffix), in addition to canonical containment. The lexical check only ever admits paths expressed through the configured root — exactly the paths the store composes — and the canonicalize-the-rollout step still runs first, so a missing file still errors and the returned path is still fully resolved for downstream reads. A genuinely external rollout path (not under the home as written) is still rejected: the existing read_thread_uses_live_writer_rollout_path_for_external_resume test still passes unchanged.
Test
fork_accepts_rollout_reached_through_symlinked_sessions_dir (unix-gated): a home whose sessions is a symlink to an external dir, a paginated session written through the home path, resumed, then prepare_fork — fails with the error above before this change, passes after.
Patch
External PRs appear closed on this repo (the last non--oai PR predates July), so filing as an issue. A ready-made fix + regression test, rebased on current main and green on cargo test -p codex-thread-store (226/226, the existing external-rollout rejection test unchanged), is here:
https://github.com/lucasygu/codex/commit/0eab6159c4 (branch fix/fork-symlinked-sessions-dir)
Happy for a maintainer to cherry-pick it wholesale or reimplement — the repro test alone (fork_accepts_rollout_reached_through_symlinked_sessions_dir) may be the useful part.
Environment
Reproduced on codex-cli 0.147.0 and 0.148.0, macOS; the check landed with the paginated-fork thread-store work (#35220) and ships in stable ≥ 0.146.1.
1 Comment
I can confirm this on Windows 11 with an NTFS directory junction.
Setup:
fsutil reparsepoint queryreports this as a Windows mount-point reparse point.For paginated-history threads,
/sidefails immediately becausethread/forkreturns:The rollout is not missing. The same file is accessible through both the logical junction path and its physical target:
fsutil file queryfileidreturns the same Windows file ID for both paths, confirming that they refer to the same physical file.I also checked the thread metadata and historical Desktop logs:
The failing paginated threads include newly created threads, so this is not limited to resumed or legacy sessions.
Version observations:
Because the successful and failing samples use different history modes, I cannot claim a clean version regression boundary. However, the behavior appears to be the same canonical-containment issue described here: the logical rollout path is under
$CODEX_HOME/sessions, but canonicalization resolves it through the junction to an external volume, causing the fork containment check to reject a rollout written by Codex itself.