Uh oh!
There was an error while loading. Please reload this page.
hyp session: take Codex's stated CODEX_THREAD_ID over the rollout mtime guess (#442 A, D) - #450
Conversation
…me guess Issue #442 item A. `resolveSessionIdForCli` fell back to a Codex rollout whose `payload.cwd` matched the invocation cwd, treating an mtime inside a 30-minute bound as evidence the session was live. mtime is a proxy, so a session that ended inside the window still resolved, and `hyp session ignore` would opt out the finished session, print "the gateway will drop this session", and leave the session the user is actually in recording. PR #439 deferred this on the ground that Codex exposes no liveness signal. That is no longer true: openai/codex#10096 (merged 2026-02-03, closing openai/codex#8923) injects `CODEX_THREAD_ID` into the environment of every shell/exec tool subprocess, exempt from `shell_environment_policy` filtering. Its presence is proof rather than proxy - a session that has ended cannot have spawned this process - and its value is `session.conversation_id`, the same identifier the rollout's `session_meta.payload.id` carries and its filename embeds. So this is the id the disk scan already produced, from a source that states it instead of inferring it. The disk scan stays as the fallback for a Codex predating the variable and for a hand invocation no client spawned, keeping its staleness bound and its `INFERRED from <rollout>` provenance line. A stated id carries neither, because neither applies to it. Two clients each stating an id (environments nest: Codex runs `claude`, or the reverse) is ambiguity and refuses naming both, rather than preferring one and being wrong half the time. Strictly narrowing: it can only turn a confident answer into a refusal. Also settles issue #442 item D in LLP 0066: `--resume` / `--continue` reuse the session id and `--fork-session` mints a new one, per Claude Code 2.1.215's own flag reference, so an opt-out survives a resume and is dropped by a fork. And records in LLP 0067 why item B (a local listener that echoes the token) has no cheap fix: a shared secret is readable by any same-uid process, and peer-pid verification has no portable form. Items B and C remain deferred; see the PR body.
…var proof of provenance not liveness Review of #450. The CODEX_THREAD_ID premise holds: verified against the merged openai/codex#10096 diff (the insert is step 6 of core/src/exec_env.rs, AFTER the include_only retain, so the exemption is structural) and against codex-rs/protocol/src/protocol.rs, where `SessionMeta { session_id: SessionId, id: ThreadId }` confirms the rollout's `payload.id` is the same ThreadId the variable carries. Two claims around it were tighter than the evidence supports. 1. "Presence is proof of liveness" is proof of PROVENANCE. It is liveness for a `hyp` run inside a tool call the client is blocked on, which is the path that matters, but a process that outlives its spawn (a server or tmux pane started from a tool call) inherits the variable and keeps it after the session ends. Strictly narrower than the mtime bound it replaces, but not zero. 2. The identity caveat understated its own consequence. The drop keys on the session container (LLP 0066 §scope); CODEX_THREAD_ID is the thread. Codex derives the session id from the thread id for a ROOT thread (same uuid), but a SUBAGENT thread keeps the root's session id and mints its own, so an opt-out taken inside a subagent tool call states an id the drop never matches and the verb reports success for a suppression that suppresses nothing. Pre-existing and shared with the disk scan, so not introduced here, but now on a live path and now closable: current Codex writes `session_meta.session_id` beside `session_meta.id`, so the drop key itself is on disk. Also corrects §cli-response-check's "neither on Windows", which reads as "no mechanism on Windows"; Windows has GetExtendedTcpTable, it is just a third implementation and a native dependency. The argument is unchanged. Docs and comments only. No behaviour change: 2850 pass / 8 known pre-existing leave-command failures, typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 29, 2026
Verdict: the |
| Claim | Verified |
|---|---|
| openai/codex#10096 merged 2026-02-03 | Yes, 66b196a, merged 2026-02-03T19:31:13Z |
Injects CODEX_THREAD_ID | Yes, exec_env.rs: env_map.insert(CODEX_THREAD_ID_ENV_VAR, thread_id.to_string()) |
Exempt from include_only | Yes, and structurally: the insert is step 6, afterenv_map.retain(|k, _| matches_any(k, &policy.include_only)). Stronger than the PR says. Added to the LLP. |
Value is session.conversation_id | Yes, shell.rs / user_shell.rs both pass Some(session.conversation_id) |
| Closes openai/codex#8923 | Issue closed 2026-02-04, title "expose current Codex session ID programmatically". Consistent |
| openai/codex#19630 merged 2026-04-28 | Yes, title matches exactly |
One nuance not worth a change: create_env takes Option<ThreadId> and app-server's exec path passes None, so "every shell/exec subprocess" holds for the tool-call paths the PR names but not literally every codex-spawned process. The resolver handles absence correctly (falls through to the scan).
4. Fallback ordering and the refusal path - all confirmed
- 30-minute bound survives on the disk path.
session_command.js:520-531,MAX_ROLLOUT_AGE_MSunchanged, refusal message unchanged. INFERREDprovenance survives.:347-349still keys onidSource === 'codex_rollout'only. Test 29 assertscodex_envproduces noINFERREDline andsession_id_evidence: null.- Two stated ids refuse, naming both.
:475-481, error interpolates${s.env}=${s.id}for every candidate. It cannot silently pick one: thestated.length > 1refusal precedes thestated.length === 1return, so there is no path where a second candidate is dropped. CLAUDE_CODE_SESSION_IDvsCODEX_THREAD_IDprecedence is deliberate: there is none. The array order only fixes the order of names in the refusal string. I confirmed by reversingSTATED_SESSION_ID_VARS- 38/38 still pass, i.e. no test depends on the order, which is correct given both-set is an error. Documented in the JSDoc and in LLP 0067 §cli-session-id.- Explicit argument still wins over any stated id (
:142,:226short-circuit beforeresolveSessionIdForCli), and there is a test for it. - Blank / whitespace-only values fall through rather than resolving to nothing, and a blank one alongside a real one is not treated as ambiguity. Tested.
5. Test teeth - verified, they have real teeth
Failing-before / passing-after, reproduced exactly as claimed.git checkout origin/master -- session_command.js, tests untouched:
not ok 24 - CODEX_THREAD_ID beats the disk scan: a stated id is not an inference
not ok 25 - the 30-minute stale-rollout window cannot hand out a DEAD id when Codex states the live one
not ok 26 - two clients each stating a session id is ambiguity, and ambiguity refuses
not ok 27 - an empty CODEX_THREAD_ID is not a stated id: it falls through rather than resolving to nothing
not ok 29 - a Codex-stated id is reported as stated, not as INFERRED from disk
# pass 33 / # fail 5
Restored: # pass 38 / # fail 0. Matches the PR body.
Test 25 does exercise the fail-open directly, not a happy path: it asserts the disk path still resolves the dead id for a session that ended 60s ago (inside the bound), then asserts the live id wins once CODEX_THREAD_ID is set. That is the residual pinned in both directions.
Mutation testing (mine, beyond the PR's claim):
| Mutation | Result |
|---|---|
if (stated.length > 1) -> if (false) (refusal becomes pick-the-first) | not ok 26 only. Precisely targeted. |
Delete the stated.length === 1 short-circuit (stated id demoted below the disk scan) | 12 failures incl. 23, 24, 25, 27, 29 |
Reverse STATED_SESSION_ID_VARS order | 38/38 pass - correct, see above |
The suite pins the behaviour, not the implementation.
Note: the diff adds 6 tests, not 5. The sixth (an explicit session id argument still beats a Codex-stated one) passes on master too, since the argument path short-circuits above the resolver. The "5 failing-before" count is right.
6. LLP accuracy
Item D (--resume / --fork-session) - accurate, independently confirmed.claude --help at 2.1.215 on this machine returns the quoted text verbatim: --fork-session When resuming, create a new session ID instead of reusing the original (use with --resume or --continue). The Codex half also checks out: codex-rs/cli/src/main.rs:180,192 has both Resume(ResumeCommand) and Fork(ForkCommand) as separate verbs, and session.rs:554-558 confirms the semantics - Resumed reuses conversation_id, Forked takes ThreadId::default(). SessionMeta even carries forked_from_id. Nothing overstated. No change needed.
Item B (echo-rogue dead end) - reasoning sound, one inaccurate clause. The core argument is correct: a gateway-written token is readable by any process that could bind the port, because both run as the same uid, so it authenticates nothing. Peer-process identity is the only real separator, and status.json already carries the pid, so the sketch is right too.
Finding 3 - LOW (fixed). "(/proc/net/tcp plus /proc/<pid>/fd on Linux, lsof on macOS, neither on Windows)" reads as "no mechanism exists on Windows", which is false - GetExtendedTcpTable gives the owning pid. Rewritten to say it is a third implementation plus a native dependency. The conclusion (separate design decision, not a hardening tweak) is unchanged and if anything better supported.
Item C (moot) - confirmed, nothing to check.
7. Refs and house style
- No new broken
@refanchors. Every anchor this PR touches resolves:0066#readable,0066#scope,0067#cli,0067#cli-session-id,0067#cli-provenance,0067#cli-response-check,0067#tests. The only break insession_command.jsis the pre-existing@ref LLP 0086#endpoint-discovery/#manual-attach-reads-the-live-portagainst a doc with no{#...}anchors at all. Untouched here, tracked separately, correctly called out in the PR body. - No em dashes added by this PR, and none by me. (
llp/0067carries 28 pre-existing ones onmaster- corpus-wide, out of scope.) - No semicolons, JSDoc types only, no inline
import('...')types.types.d.tsunion widened correctly and threaded throughSessionStatusReport. npm test: 2850 pass / 8 fail, the knowntest/core/leave-command.test.jsbaseline.npm run typecheck: clean. Unchanged after my commit.
What I fixed
9414dd9 on fix/issue-442, docs and comments only, no behaviour change:
llp/0067-session-opt-out.design.md§cli-session-id - "proof of liveness" -> "proof of provenance", with the outlives-its-spawn residual named; the identity caveat rewritten to name the subagent divergence, the silent no-op it causes, the loss of "latent", and the on-disksession_meta.session_idthat makes it closable; theinclude_onlyexemption noted as structural (step 6, after the retain).llp/0067§cli-response-check - the Windows clause corrected.hypaware-core/plugins-workspace/ai-gateway/src/session_command.js-STATED_SESSION_ID_VARSblock comment brought in line with both corrections.
Re-verified after the commit: 2850 pass / 8 known fail, typecheck clean, no new long lines, no em dashes.
What remains (not blocking)
- Own issue, MEDIUM:"
hyp session ignorefrom a Codex subagent thread opts out an id the drop never matches" - carry §cli-session-id's analysis: readsession_meta.session_idfrom the rollout, movecodex/src/backfill.js:564-566off "the rollout carries no distinct session id" at the same time, and decide whether a statedCODEX_THREAD_IDshould be resolved through the rollout to the container id before it is sent. Pre-existing, not a regression from this PR. - The three follow-ups the PR body already asks for (item B's peer-pid issue, the Codex
hypaware-privacyskill body still picking newest-by-mtime, LLP 0086's missing anchors) all stand and are correctly scoped out. - PR body says 5 new tests; the diff adds 6. Cosmetic, and I did not edit the body.
Still a draft, and I did not mark it ready or merge it.
…retracted claim Round 2 review of #450. Round 1 downgraded "CODEX_THREAD_ID is proof of liveness" to "proof of provenance" in LLP 0067 §cli-session-id and in the STATED_SESSION_ID_VARS comment, naming the residual (a process that outlives its spawn inherits the variable and keeps it after the session ends). It missed two other places that state the retracted version, one of them normative. 1. LLP 0066 R11 justified "a stated id carries no staleness bound" with "only a running session can have spawned the process it is set in" - the liveness claim itself, in a MUST-shaped requirement. The requirement is right (an env var has no timestamp to bound), only its rationale overclaimed. Restated as provenance, with the outlives-its-spawn residual and a pointer to LLP 0067 §cli-session-id. 2. `provenanceNotes` justified qualifying only the disk inference with "because only it can name a session that has already ended", which LLP 0067 now says is false: a hyp run from a detached descendant of a tool call can name a finished session too. Replaced with the reason LLP 0067 §cli-provenance actually gives - the stated id's residual is far narrower, and qualifying both would train the reader to skip the caveat on the one path where it is load-bearing. Docs and comments only, no behaviour change. Round 1's premise re-verified against openai/codex main: SessionMeta { session_id: SessionId, id: ThreadId } with neither field carrying a serde attribute, root threads take SessionId::from(thread_id) (a straight uuid copy) while a non-root agent takes agent_control.session_id() with an independently minted ThreadId, and PR 10096's insert is post-retain, post-exclude and post-set in exec_env.rs. Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe
commented
Jul 29, 2026
Round 2 (final, cap 2) - reviewed |
philcunliffe
commented
Jul 29, 2026
Triage verdict: SHIPBoth review rounds approved after unusually thorough verification (upstream
Step 3 - does a stated |
| Item | Status |
|---|---|
| #453 subagent thread-vs-session divergence | pre-existing, not widened, filed, closable path named |
| #451 echo-rogue (item B) | pre-existing, analyzed dead end, filed |
| #452 privacy-skill mtime + broken LLP 0086 anchors | pre-existing, filed |
#435 skill collapse onto hyp session | pre-existing enhancement, filed |
| #455 (new) fork-drop caveat not in CLI text | doc/UX gap, filed, non-blocking |
Bar per LLP 0017: a TRUE BLOCKER needs a production defect this PR causes or
worsens - wrong behavior, data loss, a security/privacy hole, a crash, or a
perf regression. Nothing here rises to that; every item is either genuinely
pre-existing and unwidened, or a documentation/UX completeness gap sitting on
a sound underlying mechanism. Weighed against what this PR actually closes -
a demonstrated stale-session false-assurance hole, reproduced end-to-end
above - the residuals do not outweigh shipping it.
Correction made to the PR body: the diff adds 6 new tests, not 5;
5 of them fail on master and pass here (the fail-open regression teeth),
the 6th ("an explicit session id argument still beats a Codex-stated one")
passes unchanged on both, pinning existing precedence rather than exercising
new behavior. Corrected in place, rest of the body preserved verbatim, with a
note added pointing at #455.
philcunliffe
commented
Jul 29, 2026
Merge this BEFORE #458. Do not merge #458 first and then this one.Neutral is flagging a cross-PR hazard it found while reviewing #458 ( Why. This PR resolves a Codex session id and returns it directly as the drop key:
#458 replaces So the two orders are not symmetric:
Why not just close this PR and keep #458?#458 was branched from Nothing to do on this PR. It is approved on its own merits. This note exists only so the order is visible at merge time. Once this lands, #458 rebases on top of it and its |
Uh oh!
There was an error while loading. Please reload this page.
Resolves the one conflict in ai-gateway/src/types.d.ts on SessionIdResolution's JSDoc: master's #450 prose (which adds the codex_env source alongside argument and claude_env) is kept, together with this PR's gloss on the @ref LLP 0067#cli-session-id annotation, which #452(b) added because a glossless ref is what CLAUDE.md forbids. Co-Authored-By: Claude <noreply@anthropic.com>
#450 landed on master and this branch is its follow-up, so where the two disagree about session-id resolution the conflict resolves as a SUPERSEDE rather than a merge, per this PR's stated ordering. Kept from #450: the liveness benefit of CODEX_THREAD_ID (Codex sets it on the process it spawns, so a finished thread cannot have set it), the two-stated-clients refusal, and the blank-variable fallthrough. Kept from this branch: the correct grain. CODEX_THREAD_ID is a selector, not an answer - it names the live thread, the rollout is looked up by payload.id, and the session container the gateway drops on is read out of it. `codex_env_rollout` replaces `codex_env`. Reconciled beyond the marked conflicts, where master's #450 text auto-merged but its claims no longer hold: - session_command.js `provenanceNotes` doc: CODEX_THREAD_ID no longer "states" the session id, so its path is qualified rather than presented as stated. Master's residual (a process outliving its spawn keeps the variable) is kept, scoped to CLAUDE_CODE_SESSION_ID. - LLP 0066 R10: the removed age bound is about liveness alone; the resolved container stays an inference, so R12 still applies to it. - LLP 0067 test plan: the stated-thread path reports `codex_env_rollout` with the rollout named in `session_id_evidence`, not `codex_env` with none. - Dropped #450's six resolver tests, which assert the superseded behaviour (source `codex_env`, the thread id as the answer, no evidence). This branch's tests cover the same intent at the corrected grain. Its one assertion with no equivalent here - a blank CLAUDE_CODE_SESSION_ID beside a real Codex thread is not ambiguity - is carried over, adapted. Local run: npm test 2875 pass / 8 fail (all 8 are the pre-existing test/core/leave-command.test.js baseline, identical on pristine origin/master); the 96 tests in ai-gateway-session-status, codex-backfill and codex-exchange-projector all pass; npm run typecheck clean; smokes session_optout_capture_drop and backfill_codex_fixture ok. Co-Authored-By: Claude <noreply@anthropic.com>
Works the deferred findings from PR #439, one verdict per item. Two of the four moved; two did not, and the reasons are recorded rather than hand-waved.
A - the 30-minute stale-rollout window: FIXED
The deferral rested on a factual claim: "closing it fully needs a liveness signal Codex does not currently expose". That claim is out of date.
CODEX_THREAD_IDinto the environment of every shell/exec tool subprocess, and deliberately exempts it fromshell_environment_policy.include_onlyfiltering. Verified against the merged diff:create_env(policy, Some(session.conversation_id))intools/handlers/shell.rs,tasks/user_shell.rsandunified_exec/process_manager.rs.session.conversation_id, the thread id, which is the same identifier the rollout'ssession_meta.payload.idcarries and its filename embeds. So this is the same id the disk scan already produced, obtained from a source that states it rather than infers it. No new identity is introduced, only better provenance.resolveSessionIdForClinow takes a stated id (CLAUDE_CODE_SESSION_IDorCODEX_THREAD_ID, sourcecodex_env) ahead of the disk scan. The scan survives untouched as the fallback for a Codex predating the variable and for a hand invocation from a terminal, keeping its 30-minute bound and itsINFERRED from <rollout>provenance line. A stated id carries neither, because neither applies to it: attaching the caveat to a stated id would train the reader to skip it on the one path where it is load-bearing.Two clients each stating an id (environments nest: Codex runs
claude, or the reverse, and the child inherits the parent's variable while setting its own) is ambiguity, and refuses naming both. Preferring either is wrong half the time, and being wrong means opting out a session the user is not in while reporting success. Strictly narrowing, per the same argument round 2 used for the staleness bound: it can only turn a confident answer into a refusal.Regression test, failure path exercised. 6 new tests in
test/plugins/ai-gateway-session-status.test.js(corrected: an earlier draft of this description said 5). 5 of the 6 fail onmasterand pass here (verified by revertingsession_command.jsalone:# pass 33 / # fail 5, then restoring:# pass 38 / # fail 0); the sixth, "an explicit session id argument still beats a Codex-stated one", passes unchanged againstmastertoo; it pins a property the change does not disturb rather than one it introduces, so it has no teeth of its own but is still worth keeping as a guard against a future regression in argument precedence. The load-bearing one is "the 30-minute stale-rollout window cannot hand out a DEAD id when Codex states the live one": it builds a rollout for a session that ended 60s ago (inside the bound), asserts the disk path still resolves that dead id, then asserts that withCODEX_THREAD_IDset the live id wins. That is the fail-open itself, pinned, not a happy path.Not claimed: an identity caveat the switch does not change.
CODEX_THREAD_IDis the thread; when a live Codex exchange carriesmetadata.session_idthe adapter stamps that assession_idand the thread asconversation_id(LLP 0030). The disk scan has exactly the same property, so this is pre-existing and unchanged, not introduced here. Recorded in LLP 0067 §cli-session-id.B - residual echo-rogue: DEFERRED (nothing cheap and correct exists)
Judged, not waved through. The obvious fix, an authenticity token the gateway writes beside its bound port and the client requires, does not defend against the stated attacker: any process able to bind that port runs as the same user, and is therefore able to read the same file. A shared secret between two same-uid processes authenticates nothing.
The only signal that would actually separate them is peer-process identity.
status.jsonalready carries the daemon pid and endpoint resolution is already liveness-gated on it, so the client could in principle verify that the process listening on the resolved port is that pid. That needs platform-specific machinery with no portable form (/proc/net/tcpplus/proc/<pid>/fdon Linux,lsofon macOS, neither on Windows), which makes it a design decision, not a hardening tweak. Recorded in LLP 0067 §cli-response-check so the next person does not re-derive the dead end. Residual stays mitigated by theendpoint_sourcedisclosure.C - scope: MOOT, no code action
A packaging preference recorded against a PR that has since merged (
3aff41f). Confirmed: nothing to do. The guidance it carried (land route + tests, then the CLI surface, then session-id resolution, as three reviewable changes) stands for future PRs of that shape and needs no artefact of its own.D - does
claude --resume/--fork-sessionmint a new session id: SETTLED, recorded in LLP 0066A resume reuses the id; a fork mints a new one. Claude Code's own flag reference is explicit, from
claude --helpat 2.1.215:The flag exists precisely because
--resume/--continuereuse the id by default. Codex splits the same two behaviours across separate verbs (codex resume <id>/codex fork <id>).So an opt-out survives a resume and is silently dropped by a fork - and the fork is exactly the case LLP 0066 §readable item 2 exists to surface: re-running
hyp session statusinside the fork resolves the new id (stated by the client) and reportsnot_ignored, which is the true answer for that session. PR #439's decision to make this detectable rather than decide it turns out to have been the right call; this just fills in the fact. Doc-only, no code consequence.Local transcript evidence was checked first and was inconclusive by construction: all 51 transcripts under
~/.claude/projectscarry exactly onesessionIdeach (subagent files carry their parent's), i.e. none of them was resumed or forked, so the CLI's own contract is the evidence. Read-only inspection; nothing under$HOMEwas modified.Closing this PR closes#442, so the leftovers need their own issues
Item C is genuinely finished and item D is now recorded. Item B is not fixed and will be silently closed along with #442 unless it is filed separately. Please open:
hyp sessioncannot prove the control responder is the gateway" - carrying the §cli-response-check analysis above: shared-secret ruled out (same-uid), peer-pid verification named as the only real option, and the trigger to act on it (an unprivileged-user multi-tenant host where port squatting is realistic).Two smaller follow-ups worth their own issues, both found while working this and both out of scope here:
hypaware-privacyskill body still picks the newest rollout by mtime (codex/skills/hypaware-privacy/SKILL.md) and states "Codex, unlike Claude, exposes noCLAUDE_CODE_SESSION_ID". Both are now wrong: it should short-circuit onCODEX_THREAD_ID. Not changed here because a skill body has no regression test and this PR's gate is a proven one.{#...}anchors, so the two@ref LLP 0086#endpoint-discovery/#manual-attach-reads-the-live-portannotations insession_command.jsare broken onmaster. Pre-existing, untouched.(Added by neutral triage after CI went green: filed #455, the CLI's own ephemerality caveat names a gateway restart but never a resume/fork session-id change, even though item D above settles that a fork silently drops the opt-out. Nothing lies;
hyp session statusre-run always reports the current session's true state. But the user has no CLI-visible signal to know they should re-check after a fork. Doc-only fix, not blocking.)Checks
npm test:# pass 2850 / # fail 8- the 8 known pre-existing failures intest/core/leave-command.test.js, unrelated to this change. No regressions.npm run typecheck: clean (exit 0).Fixes#442