One reader for the Codex session_meta header, in core (#465) - #466
Conversation
Two plugins read a Codex rollout's first line to gate a privacy control: `@hypaware/codex`'s live cwd resolver wants `payload.cwd` for the `.hypignore` match (LLP 0083), and `hyp session` wants an id for the session opt-out (LLP 0066/0067). Each held its own copy of the rules, and the copies drifted twice, both times as a silent wrong answer: #453 named a thread id the drop never matched, #459 resolved the root's cwd for a subagent turn. The drift was live at HEAD. `readRolloutMeta` in session_command.js had no `type === 'session_meta'` guard, so a rollout whose first line is any other record carrying `payload.id`/`payload.cwd` (a `turn_context` does) resolved a confident id belonging to no session. The codex side had the guard but accepted a whitespace-only `cwd` as a path and handed it to the policy matcher. `src/core/codex/rollout_session_meta.js` is now the only reader. It states the three rules once (parse the raw JSONL line, require the session_meta envelope, treat absent/non-string/blank as unresolvable and never back-fill `sessionId` from `threadId`) and owns the bounded first-line read both callers were duplicating. Core, not either plugin: the gateway cannot reach into codex's private modules, and single-caller ownership is what makes drift cheap. LLP 0143 records the decision and the placement. `sessionId` has no consumer yet by design: rule 1 exists for it, so leaving it unread is what let the property go unstated. Which id each caller uses is unchanged here. Co-Authored-By: Claude <noreply@anthropic.com>
PR #466 (fix/issue-465) independently minted llp/0143-one-reader-for-codex-session-meta on its own branch at the same time this branch minted llp/0143-codex-lineage-from-body-client-metadata. Two documents cannot share a number, and duplicate LLP numbers are exactly the corpus defect issue #463 tracks, so this branch takes 0144 (deterministic tie-break: the lower PR number keeps the original). Purely a renumber: every @ref anchor, cross-link, and Related entry follows the move, and the document's own content is unchanged. Verified no residual "0143" reference remains on this branch and all six referenced anchors still resolve in the renamed document. Co-Authored-By: Claude <noreply@anthropic.com>
…ot Config `Systems` is the discovery key: CLAUDE.md tells an engineer to read the LLP tagged with a subsystem's `Systems` value before changing it. The reader lives in `src/core/codex/` and exists to keep the `.hypignore` usage-policy control from being evaluated against the wrong directory, so `Core` and `Usage-Policy` are the tags a future change to either would search on. `Config` is not implicated: nothing here parses or validates config. Review round 1, minor: metadata only, no text or code change. Co-Authored-By: Claude <noreply@anthropic.com>
Verdict: approve with findings (3 findings, 1 fixed and pushed, 2 deliberately left)The PR does what #465 asked. The invariant is single-sourced for the header Reviewed at 1. Is the invariant actually single-sourced?Yes, for the
I swept for a third copy and found none:
Also worth recording: the reader is imported by plugins, never the reverse, so 2. Privacy: do the guards hold?I checked each case you asked about, by test and by direct execution.
Two good properties I want to call out because they are easy to lose later: 3. Is the regression gate real?Yes. I mutated the guards in a worktree at
Separately, I confirmed the two reproducing tests are genuine: with only the 4. PlacementCorrect, and it does not invent a new coupling.
5. Conventions and toolingAll clean:
Findings1. LLP 0143:5 - Low -
|
…#465 review round 2) Two review findings from round 1, both on the `cwd` that gates `.hypignore`: 1. `src/core/codex/rollout_session_meta.js` accepted any non-blank string as a `cwd`, so a relative one reached the matcher, whose first act is `path.resolve(cwd)`. That silently supplies the *daemon's* process cwd as the base, so a header saying `../elsewhere` produced a confident `.hypignore` verdict governed by a file under wherever the daemon was started. Same defect shape as #459, reached through a different field. 2. The codex backfill still used the looser `stringValue` for `session_meta.cwd` and its `turn_context.cwd` fallback, and feeds them to the same gate, so LLP 0143's "blank means blank-after-trim" held at two of three sites. A whitespace-only cwd reached `path.resolve` and was stamped on the row. Rule 3 (unconfirmable is unresolvable) now covers both for `cwd`: `sessionMetaCwd` requires a non-blank *absolute* path, byte-identical, else `undefined`. It is exported because the backfill reads whole rollout files and folds `turn_context`, so it cannot delegate to the first-line reader, but it can and now does share the one predicate. Only `cwd` is path-tested; the ids stay opaque provider tokens (LLP 0066 R5). Refusing is not a symmetric trade, which is why this is a rule and not a judgement call. Refusing costs the ordinary "no cwd" fail-open (`cwd = NULL`), a state the system already models (LLP 0049 R1 as extended by LLP 0085) and already the documented answer for an absent rollout (LLP 0083). Accepting is wrong in both directions at once: it can drop a session nothing covers, and it can record a session whose real directory IS ignored, because the verdict was computed for another directory. The repo already applies this rule to the sibling case in LLP 0045 (a relative `settings_file` is refused, not returned for later re-resolution against `process.cwd()`). The rollout's own location cannot supply the missing base either: rollouts live under `<CODEX_HOME>/sessions/YYYY/MM/DD/`, unrelated to where the session ran. No legitimate rollout loses its cwd: Codex writes it from the process working directory, always absolute. The `hyp session` caller is unaffected; it compares against an absolute invocation cwd, so a relative value never matched. LLP 0143 gains the rule as `#usable-cwd`, and its consequence bullet now says "every site that feeds the gate" instead of "both callers". The projector's in-band cwd (LLP 0083's fast path) is called out there as still outstanding: a different source, and its value is also stamped for workspace/git enrichment rather than only consulted for the gate. Tests, each verified to redden on the matching mutation: - reverting `sessionMetaCwd` to `metaField` reddens 4 named tests - reverting the backfill to `stringValue`/`firstTurnString` reddens `a blank or relative rollout cwd never reaches the usage-policy gate` Co-Authored-By: Claude <noreply@anthropic.com>
The interface is the contract consumers read; the absoluteness rule belongs in it, not only in the reader that enforces it. Co-Authored-By: Claude <noreply@anthropic.com>
Verdict: approve with findings. Round 2 of 2 (final review round).Reviewed at
One finding is left, and it is a new one I turned up while settling finding 1. Round 1's two open findings1.
|
| option | verdict |
|---|---|
| accept the relative value | wrong in both directions at once. It can drop a session no .hypignore covers (data lost), and - the failure the control exists to prevent - it can record a session whose real directory is ignored, because the verdict was computed for a different directory. It also stamps a non-null bogus cwd on the row, so the value looks like a real answer to everything downstream that keys on cwd. |
| resolve against the rollout file's own directory | eliminated, not deferred. Rollouts live under <CODEX_HOME>/sessions/YYYY/MM/DD/, which has no relationship to where the session ran, so this is a second wrong base rather than the missing right one. |
| refuse (chosen) | costs the ordinary "no cwd" fail-open: cwd = NULL, the projector's if (cwd) skips the check. That is a state the system already models and compensates for (LLP 0049 R1 as extended by LLP 0085) and is already the documented answer for an absent or unreadable rollout (LLP 0083). |
Two further things make this a rule rather than a judgement call:
- It is rule 3, not a fourth rule. Rule 3 is "unconfirmable is
unresolvable... A caller that needs it must refuse; none of them may guess."
Nothing on the line says what a relativecwdis relative to, so honoring
it means guessing a base, andpath.resolveguesses the daemon's process
cwd. The rule already forbade this; the reader just was not enforcing it for
cwd. - The repo already decided the sibling case. LLP 0045 refuses a relative
clientsettings_filerather than returning it, in as many words: handing it
back "would return something re-resolved againstprocess.cwd()at read time
instead of the value validated at call time".claude/src/hook_command.js:128
is the same shape in code (path.isAbsoluteor the value is dropped).
And nothing legitimate loses coverage: Codex writes session_meta.cwd from its
own process working directory, which is always absolute, and the hyp session
caller compares meta.cwd against an absolute invocation cwd
(session_command.js:524), so a relative value never matched there anyway -
refusing only makes that refusal explicit and earlier.
The fix. sessionMetaCwd (rollout_session_meta.js:176) is now the one
cwd predicate: non-blank and absolute, byte-identical, else undefined.
Only cwd is path-tested; threadId/sessionId stay opaque provider tokens
(LLP 0066 R5) and a uuid is not an absolute path, so path-testing them would
refuse every real header. LLP 0143 #usable-cwd records the rule and the
options table above.
Verified, by execution both ways. Before, with a .hypignore planted where
the relative path lands from the process cwd:
reader cwd : "../elsewhere"
rollout-cwd resolver: "../elsewhere"
policy verdict : {"class":"ignore","governedBy":"/tmp/daemonhome-XXXXXX/elsewhere/.hypignore",...}
After: undefined / undefined, and the projector's if (cwd) takes the
documented cwd = NULL path.
New tests, and each fails without the fix (reverting sessionMetaCwd to the
old metaField reddens 4 named tests, checked by mutation, not assumed):
test/core/codex-rollout-session-meta.test.js::a relative session_meta.cwd is no cwd, not a path resolved against the daemon(../elsewhere,./repo,
repo,repo/sub,' /repo', and the id on the same line unaffected)...::an absolute cwd still resolves, and the ids are never path-tested...::sessionMetaCwd is the one cwd predicate, usable by a caller that cannot delegatetest/plugins/codex-rollout-cwd.test.js::a relative session_meta.cwd is no cwd: the matcher would resolve it against the daemon- the seam test, which
also asserts the wrong-directory verdict is real rather than hypothetical, so
the test cannot pass vacuously.
2. hypaware-core/plugins-workspace/codex/src/backfill.js:541 - the third reader's cwd predicate. FIXED. Low.
Round 1 asked whether the looser predicate can actually admit a bad cwd into
the gate. It can. Demonstrated, not inferred - a rollout whose
session_meta.cwd is ' ', run through the real provider with a spying
resolver:
cwds handed to the usage-policy resolver: [" "]
row cwd stamped: [" "]
path.resolve(' ') is <process.cwd()>/ , so the ancestor walk starts one
level below the invoking directory and hits any .hypignore at or above it.
Both directions again: a false ignore for a session with no real cwd, and a
false full for a session whose true directory is ignored. A relative
metaPayload.cwd does the same, resolved against wherever hyp backfill was
invoked.
Round 1 was right that backfill cannot delegate to the reader (parseRolloutFile
reads whole files and folds turn_context, with documented fallbacks). So it
keeps its own file walk and now shares the one predicate:
cwd: firstString(sessionMetaCwd(metaPayload.cwd), firstTurnCwd(turnPayloads)),Note the turn_context.cwd fallback is covered too (firstTurnCwd), which
round 1's write-up did not mention: without it the fallback would smuggle back in
exactly what the session_meta branch refuses. LLP 0143's consequence bullet now
reads "every site that feeds the gate" rather than "both callers", which is the
claim that is actually true.
Verified: reverting only this line to stringValue(...) /
firstTurnString(..., 'cwd') reddens exactly
test/plugins/codex-backfill.test.js::a blank or relative rollout cwd never reaches the usage-policy gate, which asserts the resolver was asked nothing
(asked is empty), that the session still backfills, and that the row records no
cwd rather than a bogus one.
New finding
3. hypaware-core/plugins-workspace/codex/src/exchange-projector.js:120 - the live projector's in-band cwd has the same latitude. NOT FIXED. Low. Needs a human decision.
const cwd = firstString(codexContext?.cwd, readRecordedCwd(reqBody))
?? (codexContext?.session_id ? rolloutCwd?.resolve(codexContext.session_id) : undefined)
if (cwd) { const policy = resolver.resolve(cwd) ... }The third branch is now predicated. The first two are not: a blank-ish or
relative cwd arriving in x-codex-turn-metadata or the request body
(LLP 0083's fast path) reaches the identical resolver.resolve at line 123 and
the identical path.resolve. Same defect, different source.
I did not fix it, deliberately, and this is the decision to make:
- It is a different source with a different trust story. The rollout is a
file HypAware chose to read; the in-band value is one the client volunteers
per request, which is LLP 0083's stated fast path. - The in-band value is not only consulted for the gate. The same
codexContext.cwdis stamped on the row for workspace/git enrichment
(exchange-projector.js:651), so tightening it changes row content, not just a
drop decision. That is a projection-semantics change, not a consequence of
this PR's rule. - Touching
exchange-projector.jswould add a third conflict seam to a
branch that already conflicts with hyp session ignore names the session container Codex drops on, not a thread id (#453) #458 and Codex rollout cwd keys on the thread, not the session container (#459) #462 by design.
The decision: either (a) apply sessionMetaCwd to the in-band branches too,
accepting that a client sending a relative cwd loses enrichment as well as the
gate, or (b) leave in-band as-is on the grounds that a client volunteering a bad
cwd is the client's defect and the enrichment value should be preserved verbatim.
I lean (a) for the gate specifically, because the argument in finding 1 does not
depend on where the string came from - but it is a projection change and belongs
in its own issue, not slipped into a review round. It is recorded as still
outstanding in LLP 0143's consequences so it cannot be lost.
Round 1's own fix: re-verified, no regression
llp/0143-...:5still reads**Systems:** Core, Plugins, Gateway, Sources, Usage-Policy. Round 1's438cad9is intact and my commits did not touch that
line.- No
@refbroke. All 9@ref LLP 0143sites resolve, including the 4 new
#usable-cwdones against the new{#usable-cwd}anchor. Every./NNNN-*.md
link in LLP 0143 resolves to a real file, and every explicit{#anchor}it
targets exists. I did fix one broken link I introduced mid-edit
(](#requirements)pointing at LLP 0143 itself rather than at LLP 0049);
it is correct in what was pushed. - Round 1's two reproducing tests and all 8 mutation results were re-checked as
part of running the suites above; nothing regressed.
Checks
npm test: 2900 tests, 2891 pass, 8 fail, 1 skipped. The 8 are exactly the
known-unrelatedtest/core/leave-command.test.jsset; +5 tests, +5 passing,
no new failures. CI green on both pushed commits,19078c8and the new head1f7fdac(test 22/24, typecheck 22/24, 8/8 pass).npm run typecheckclean;npm run build:typesclean.- Hermetic smokes
backfill_codex_fixture,gateway_codex_capture,
session_optout_capture_drop: all ok. - CLAUDE.md on everything I touched: no semicolons, no em dashes on any added
line (checked over the added-lines diff), no@typedef, no inlineimport()
types, JSDoc only.sessionMetaCwd's@refcarries a relation and a gloss.
Deliberately left alone (unchanged from round 1, still correct)
- Em dashes on pre-existing context lines in
rollout-cwd.jsand
codex-rollout-cwd.test.js. Still the Codex rollout cwd keys on the thread, not the session container (#459) #462 seam; cosmetic churn there still
costs more than it buys. - The hyp session ignore names the session container Codex drops on, not a thread id (#453) #458 / Codex rollout cwd keys on the thread, not the session container (#459) #462 conflicts. Out of scope, flagged in the PR body. My changes add
no new seam:rollout-cwd.jsandsession_command.jsare untouched this round. sessionIdexposed with no consumer. Justified, and it is what makes the
"never back-filled" property testable.
|
Neutral triage (rung: triage, LLP 0017) at head The 2-round review budget was exhausted before round 2's two fix commits Round 2's fix, verifiedRound 2 made
Verdict: round 2's fix is sound. Not a blocker. Residual finding, assessedLLP 0143 documents one gap on purpose: the live projector's in-band cwd This code is unmodified by PR #466 ( Verdict: non-blocking, tracked separately. OutcomeEverything found is non-blocking. Follow-up: #471. |
🤖 neutral: the "strictly dominates" argument that cleared this PR has a documented exceptionThis PR is still held with What the triage arguedThis PR's triage cleared the The exception, found while reviewing PR #474PR #474 applies the same predicate to the in-band cwd path, and its review found a case where the old accept-and-guess behaviour was not worse:
That is not hypothetical. No installer renders a working directory, so the daemon's cwd is So "strictly dominates" is wrong as stated. The accurate claim is: better in every case but one, and the exception is a narrow, accidental loss of coverage rather than a wrong verdict. Why neutral is not withdrawing the approval
Mechanically the label also still holds: What this means for youThis PR and #474 together encode a deliberate choice: an unusable cwd fails open. If that matches your intent, merge as-is; the coverage exception above is the price and it is now written down. If you would rather an unresolvable cwd fail closed, neither PR delivers that, and the PR #474's body has been corrected to state the exception accurately. This comment corrects the record here. |
Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
Two things, both forced by what landed on master while this was in review. ## The conflict: #458 rewrote the file this PR collapses #458 ("hyp session ignore names the session container Codex drops on") and this PR both touch `readRolloutMeta` in `ai-gateway/src/session_command.js`, from opposite directions. #458 grew it: a `type === 'session_meta'` envelope guard, a third field (`payload.session_id`), a blank-after-trim test on that field, and a new resolution path (`resolveFromStatedThread`) built on it. This PR deletes it: issue #465's whole value is that no second copy of the `session_meta` rules survives, so the function becomes a delegation to `src/core/codex/rollout_session_meta.js`. Resolved by keeping both. Every behaviour #458 added is intact - the container is still the answer, `CODEX_THREAD_ID` is still a selector rather than an answer, a legacy or blank `session_id` still refuses rather than falling back to the thread id - and none of the predicates behind it live here any more. The three rules (raw line, envelope type, blank-is-absent) are stated once, in the core reader, which is the point of #465. ## Third-copy check #458's own resolution logic does NOT read `session_meta` fields directly. `resolveFromStatedThread` matches on `meta.threadId` and reads `meta.sessionId`, both from `readRolloutMeta`, so it is a second *resolution path*, not a second *reader*, and routing it through the shared reader needed no change to it. What had become a full second copy is `readRolloutMeta` itself: #458 gave it its own envelope guard and its own blank test on the new field, which is precisely the duplicate this PR removes. `statedEnv` is a blankness test on an environment variable, not on the header, and stays. The only other site that reads `session_meta` fields itself is `codex/src/backfill.js`, which walks whole rollout files (folding `turn_context`) and so cannot call a first-line reader. It shares the one `cwd` predicate (`sessionMetaCwd`) and deliberately does not share rule 3's id refusal: a backfilled row must land in some partition, where the CLI can refuse. Now stated in the LLP rather than only in the code comment. ## One behaviour change the merge required `readRolloutMeta` no longer discards a rollout whose `cwd` is unusable; it passes `cwd: undefined` through. The shared reader refuses a blank or relative `cwd` (LLP 0150 #usable-cwd), and on the cwd-matching path that is what we want. On #458's stated-thread path `cwd` is never consulted, so requiring it would have turned a field-level predicate into a file-level one and refused a session whose container is plainly on disk - a regression of #458 introduced by tightening a field it does not use. Pinned by a new test, mutation-checked: restoring the cwd requirement reddens it. ## Reconciled beyond the conflict markers Four claims that auto-merged cleanly but stopped being true once #458 landed: - LLP 0150 said `sessionId` "has no consumer yet on purpose" and that moving the verb onto the container "is #453's job". #453 is closed and the verb is moved; the section now records that, and that #458 added a resolution path but no second reader. - LLP 0150 said the `hyp session` caller "compares `meta.cwd` against an absolute invocation cwd, so a relative value never matched". True of one of its two paths now. Rewritten to say why the other path must not refuse on `cwd` at all. - LLP 0150's rule 3 read as a blanket "callers refuse", which the backfill does not. Scoped to the reader, with the backfill's different answer explained. - The `readRolloutMeta` doc comment said `meta.sessionId` "is deliberately not consulted here". It is consulted now. Also: LLP 0150's Context bullet no longer implies `CODEX_THREAD_ID` makes the rollout unnecessary, and `resolveSessionIdForCli`'s legacy-rollout note points at `legacyRolloutError`, which is where that refusal now lives. ## The renumber: collision avoidance, NOT a ruling on #469 #475 landed `llp/0143-openclaw-registers-no-attach-probe.decision.md` while this PR held `llp/0143-one-reader-for-codex-session-meta.decision.md`. Different filenames, so git flags no conflict, but merging as-is would put two documents at 0143 on master, a fifth duplicate after 0098, 0099, 0111 and 0142. This document moves to 0150 (0149 is the highest on master) and all 12 references follow: `@ref` annotations in `session_command.js`, `backfill.js`, `rollout-cwd.js`, `rollout_session_meta.js`, `types.d.ts` and four test files, plus the heading and one self-reference in the document. **This is mechanical collision avoidance and sets no precedent.** Issue #469 asks whether the later claimant renumbers or whether citations become filename-qualified, and that question is still open and unowned. Renumbering here is only what avoids adding a sixth duplicate today; whichever way #469 is decided, nothing about this commit should be read as having decided it. The human's 0143 is untouched, as are LLP 0142's two references to it. Verified: `npm test` 2915 tests, 2905 pass, 8 fail (the pre-existing `test/core/leave-command.test.js` set, identical on a pristine `origin/master` worktree), +12 tests and no new failures. `npm run typecheck` clean. Smokes `gateway_codex_capture`, `session_optout_capture_drop`, `backfill_codex_fixture` all ok. All `@ref LLP 0150` targets and anchors resolve. Co-Authored-By: Claude <noreply@anthropic.com>
|
Triage rung (LLP 0017), head This PR was approved at an earlier head (
Residual: #471 (in-band cwd at the live projector's Tests: Verdict: no true blockers. The unreviewed merge commit's claims about |
…s Codex never sends (#464) (#467) * Codex lineage reads the durable body client_metadata (#464) The Codex live projector derived a turn's thread, session and parent thread from request headers, three of whose names Codex has never emitted, while the authoritative ids sat unread in the request body. Read against Codex's source, Codex projects one `CodexResponsesMetadata` snapshot onto three surfaces per HTTP request. The flat body `client_metadata` map (a top-level field of `ResponsesApiRequest`) is built unconditionally and always carries `session_id` and `thread_id`; the `x-codex-turn-metadata` blob only rides along for request kinds that carry turn metadata; and `compatibility_headers` emits exactly four names. `thread-id`, `session-id` and `parent-thread-id`, the three bare names the projector read, are not among them, so they could never supply a right value and could supply a wrong one: any hop setting `thread-id` dictated `conversation_id`, the scope of the row's fallback `message_id`. - Lineage now resolves body-map-first, turn-metadata-blob-second, with the real `x-codex-parent-thread-id` header (previously misspelled) last. - A Codex-owned `client_metadata` map is itself a sufficient signal that an exchange is Codex, so the API-key route's generic `/v1/responses` no longer needs a Codex header to be recognized. - The three fictional header names are gone; the real ones are named constants. - `attributes.codex.lineage_source` records which surface stated the identity, so a future Codex version dropping one is queryable rather than a silent `conversation_id` drift. Nothing re-keys: the blob's `thread_id` and the body map's `thread_id` are the same field of the same snapshot, and the removed header names never matched real traffic, so `conversation_id` (and the `message_id` / `part_id` scoped on it, LLP 0030) is unchanged for every shape already recorded. Already-recorded rows are left alone; LLP 0143 states why no backfill. LLP 0143 is the decision doc. LLP 0083 and LLP 0141 carried the disproved premise that `x-codex-turn-metadata` is Codex Desktop behavior and that the subscription route states its session in a `session-id` header; both are corrected, and the two `codex-rollout-cwd` fixtures that rested on that header now use the shape Codex really sends. Co-Authored-By: Claude <noreply@anthropic.com> * Renumber LLP 0143 to 0144 to clear a duplicate number with PR #466 PR #466 (fix/issue-465) independently minted llp/0143-one-reader-for-codex-session-meta on its own branch at the same time this branch minted llp/0143-codex-lineage-from-body-client-metadata. Two documents cannot share a number, and duplicate LLP numbers are exactly the corpus defect issue #463 tracks, so this branch takes 0144 (deterministic tie-break: the lower PR number keeps the original). Purely a renumber: every @ref anchor, cross-link, and Related entry follows the move, and the document's own content is unchanged. Verified no residual "0143" reference remains on this branch and all six referenced anchors still resolve in the renamed document. Co-Authored-By: Claude <noreply@anthropic.com> * Codex lineage: record a body/blob disagreement instead of silently preferring one The body-map-first precedence rests on Codex projecting one metadata snapshot onto both the flat `client_metadata` map and the `x-codex-turn-metadata` blob, so that the two are equal whenever both are present. That is a claim about another program's internals which HypAware cannot verify, and the body-wins tie-break discarded the counter-evidence without trace: a row whose surfaces disagreed was indistinguishable from a row whose surfaces agreed. - `attributes.codex.lineage_conflict` now names the lineage fields the two surfaces state differently (`thread_id`, `session_id`, `turn_id`, `parent_thread_id`), absent when they agree or only one spoke. The row still keys on the body, so this adds a signal and moves no identity. - `lineage_source` now resolves in the same order as the values it describes (`thread_id` before `session_id`, body before blob). It previously answered "did the body state anything at all", which mislabelled a turn whose `thread_id` came from the blob while only its `session_id` came from the body as `body_client_metadata`, though `conversation_id` keys on `thread_id`. - LLP 0144 gains `#lineage-conflict` and states why the assumption gets a continuously checked signal rather than a one-time assertion. Co-Authored-By: Claude <noreply@anthropic.com> * Codex lineage: an ambiguous flat client_metadata pair is not evidence of Codex `readCodexClientMetadata` accepted a body `client_metadata` map on either of two signals: an `x-codex-*` prefixed key, or the flat `session_id` + `thread_id` pair. Only the first is Codex-exclusive. The second is a shape any agent framework may send, and the projector's matched path set includes the fully generic `/v1/responses` and `/v1/chat/completions`, so an unrelated client that posted that pair was stamped `client_name: 'codex'` and dictated the row's `conversation_id` and `session_id` (the partition key, LLP 0030). That is the same defect class as the fictional `thread-id` header this branch removed, reached through the body instead of a header, and in a capture product a misfiled client is a privacy question. The flat pair is now honoured only when the transport already identified the exchange as Codex independently of the body (`hasCodexTransportSignal`: the `chatgpt` upstream, the `/backend-api/codex/` namespace, an `x-codex-*` compatibility header, or a `codex`-prefixed user-agent product). Real Codex loses nothing: `client_metadata` carries `x-codex-installation-id` and `x-codex-window-id` on every request, so the strict branch alone covers all known Codex traffic, and the corroborated pair still covers a build that stopped writing them. A non-Codex client's row now comes out byte-identical to the same request with no `client_metadata` at all. `isCodexExchange` is replaced by `hasCodexTransportSignal` plus the body check at the single decision point in `resolveCodexContext`, so the corroboration flag cannot drift between the two callers. Also pins the assumption that keeps the outer `match` gate (path and turn-metadata header only) consistent with the body being a Codex signal: a test asserts every route Codex posts to passes the gate, so a body-only Codex request is never dropped before the body is read. LLP 0144#body-is-a-codex-signal and #body-is-authority are amended in the same commit. Co-Authored-By: Claude <noreply@anthropic.com> * Renumber LLP 0144 to 0151 to clear the collision with the OpenClaw block PR #475 merged llp/0144-shadow-provider-per-api-shape.decision.md to master as part of the OpenClaw 0142-0149 block, so this branch's llp/0144-codex-lineage-from-body-client-metadata.decision.md would have put two documents at 0144. Because the filenames differ, git reported no conflict and CI stayed green, so nothing on the rung ladder would have caught it before merge. 0151 verified free across master and every remote branch. 0150 is held by fix/issue-465, renumbered there from 0143 for the same reason. Purely a renumber: the document's content is unchanged, and the rename carries every reference with it (12 @ref annotations in exchange-projector.js, 1 in the gateway_codex_capture smoke, 14 in codex-exchange-projector.test.js, 1 in codex-rollout-cwd.test.js, plus the cross-links and Related entries in LLP 0083 and LLP 0141, and the heading). No residual 0144 reference remains on this branch and all six referenced anchors resolve in the renamed document. The human directed this renumber explicitly, accepting that moving the head strips neutral:approved and re-opens the review ladder. It resolves this one collision and sets no precedent for issue #469, where the general renumber-versus-qualified-citation convention is still open. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral@hyparam.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com>
…nes #466 aged `llp/0083-codex-live-cwd-from-rollout.decision.md` was the only conflict: this branch inserts an "an unusable in-band cwd is a miss" bullet into the Decision list, and master (#467) rewrote the adjacent "keyed on the codex session id" bullet to name `client_metadata.session_id` and LLP 0151. Both wanted; both kept. Two claims this branch made stopped being true when #466 landed, so they are corrected rather than carried across: - The bullet said the rollout-stated `cwd` is "not yet" held to the same rule and that PR #466 would close that half. #466 landed: `rollout-cwd.js` reads through `readRolloutSessionMeta`, which applies `sessionMetaCwd`, so a refused in-band value now falls through to an already-predicated source. Stated that way, and the "two limits" count drops to the one that remains (#476). - `usableInBandCwd`'s docstring pointed at "LLP 0143 #usable-cwd, PR #466" and said the core module "is not on `master` yet; unify them once it lands". That LLP is now 0150, and the module has landed. The duplication is kept with its real reason: LLP 0150 scopes the in-band path out of its own mandate, so borrowing its predicate here would widen 0150's stated scope, and the `error_kind` split needs the two conjuncts apart. The merged text now says explicitly that this bullet is not a consequence of LLP 0150, so nothing implies the in-band path is covered by 0150's rule. No code behavior changes in this merge: the projector, its helper, and the tests merged textually clean and are re-verified against what #466/#467 landed. Co-Authored-By: Claude <noreply@anthropic.com>
…471) (#474) * Codex live projector: an unusable in-band cwd is a miss, not a path (#471) The in-band cwd (`codexContext.cwd` / `reqBody.cwd` / `metadata.cwd` / `metadata.user_id.cwd`, the LLP 0083 fast path) reached `resolver.resolve(cwd)` unpredicated. The matcher's first act is `path.resolve(cwd)`, so a relative value was measured against the DAEMON's process cwd: a confident `.hypignore` verdict for a directory the session never ran in, plus that bogus value stamped on the row as its container. `usableInBandCwd` now requires non-blank and absolute before the value reaches the gate, and logs a `plugin.codex.usage_policy_cwd_unusable` warn with an `error_kind` and a hashed cwd so the refusal is observable rather than silent. This does NOT convert the path to fail-closed: a refused cwd falls through to the rollout fallback, and when that states nothing too the row records `cwd = NULL` and is recorded, the existing precedent (LLP 0049 R1 as extended by LLP 0085). What changes is that an unconfirmable cwd yields an honest NULL instead of a verdict computed for the wrong directory. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: state the fail-open this refusal narrows, and why the trim stays Review round 1 on #474. Two disclosure gaps in `usableInBandCwd`, both comment-only and both inside the new helper, so no extra conflict surface against the held PRs that also touch this file. - The PR reasoned that refuse-and-NULL strictly dominates accept-and-guess with "identical fail-open exposure for the should-be-ignored case". It does not. When the daemon's own process cwd sits under an ignoring `.hypignore`, the guessed base reached the correct verdict and this change now records where it previously dropped. Neither the daemon's launchd plist nor its systemd unit renders a working directory, so that cwd is `$HOME` for a `--user` unit and whatever shell started a foreground daemon. Refusing is still right (the same base produced false drops for every session that ran elsewhere), but the narrowing is real and belongs next to the code, not only in a PR body. - `cwd.trim().length > 0` gates nothing: a blank string is never absolute on either platform, so `isAbsolute` already refuses it. Dropping that conjunct leaves the whole suite green, which reads as dead code to the next person to simplify the predicate. It is load-bearing only for the `error_kind` split that tells `cwd_blank` from `cwd_not_absolute`. Say so. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: name the two limits this predicate does not reach Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com> Co-authored-by: neutral-reconciler <neutral@hyparam.dev>
A semantic merge, not a textual one: master reworked the same Codex cwd path under this branch's feet (#466 LLP 0150, #467 LLP 0151, #474, #477), so the conflicts had to be resolved on what the combined behaviour means, not on which side's hunk looked cleaner. Four files, and what each side wanted: - exchange-projector.js: master wrapped the in-band cwd in `usableInBandCwd` (#474) and added the refused-workspace warn (#477), both around the very expression this branch replaced. Kept both, with this branch's `resolveRolloutCwd` as the fallback rather than master's inline `rolloutCwd.resolve(session_id)`, which is the container key #459 is about. - rollout-cwd.js: master replaced the local first-line read with core's one `readRolloutSessionMeta` (LLP 0150); this branch added a thread-identity guard on top of that read. Composed rather than chosen: the guard now compares `meta.threadId` from the shared reader. The two fit exactly, because LLP 0150 rule 1 (raw JSONL line, never Codex's `Deserialize`) is the property the guard depends on to see an absent `payload.id` as absent. `meta.cwd` also arrives pre-predicated by `sessionMetaCwd`, so a blank or relative rollout cwd is now refused here too. Cache key stays the thread id. - LLP 0083: took master's Context correction and its unusable-in-band bullet, kept this branch's thread-keying thesis over master's superseded "keyed on the codex session id" bullet, and reconciled the prose that #467 falsified: the thread now comes from the body's `client_metadata`, not from `thread-id` / `session-id` header names Codex never emitted. The Consequences bullet promising a shared-reader follow-up was stale (that fold has landed) and now says so. - test/plugins/codex-rollout-cwd.test.js: git merged this file cleanly and the result was wrong in both directions, which is the part worth reading. Master's #257 fixtures key the fake resolver on the session id while stating a distinct thread id, so thread keying missed; rekeyed onto the thread id, which keeps master's deliberately-distinct pair. More seriously, this branch's #459 fixtures state identity through the bare `session-id` / `thread-id` / `parent-thread-id` headers, which #467 established are names no Codex version emits and removed the reads for. Left alone, four leak-direction tests failed outright and the refusal tests would have passed VACUOUSLY, for want of any id rather than because a refusal fired, silently gutting the gate. Ported the fixtures to the body `client_metadata` surface (LLP 0151), assertions unchanged. Checked, not assumed: - Regression gate still bites: master's two source files under this merged test file fail 11 of 23, including every #459 leak-direction case and all four refusal cases, so the ported fixtures are not vacuous. - `npm test`: 3039 pass / 8 fail, exactly the `leave-command` 8 that fail identically on a pristine `origin/master` worktree (73b4618), by name. - `npm run typecheck`: clean. No em dashes, no semicolons in changed lines. The LLP anchors cited (0150#usable-cwd, 0151#body-is-authority, 0083#container-fallback-gap) all resolve. Not touched, deliberately: the open `subagent_signal` finding at `resolveRolloutCwd`. The refusal is still value-blind and its shape is unchanged, but #467 narrowed its reachability, since a turn now has to carry neither a Codex-owned `client_metadata` map nor a turn-metadata blob to reach the container fallback at all. LLP 0083 records that narrowing without pretending it closes the question. Co-Authored-By: Claude <noreply@anthropic.com>
…st LLP 0083 provenance Two review findings on PR #513, both in the round's own scope. R1 (test hygiene): the new equivalence test was the only case in `codex-exchange-projector.test.js` that built a projector with no injected resolver, so the shared matcher walked the REAL ancestors of `/work/repo` looking for a `.hypignore`. A file above the checkout on the machine running the suite would turn `projection.cwd` into a `USAGE_POLICY_DROP` sentinel and redden an assertion that has nothing to do with the gate. Inject a resolver whose fs holds no list anywhere, matching how every neighbouring case in the file works, and hoist the projector out of the loop. Re-checked the mutation after the change: loosening `usableInBandCwd` back to `cwd.trim().length > 0` still reddens the test on the "repo" case, so the hermetic resolver did not make it vacuous. R2 (doc accuracy): the LLP 0083 bullet claimed the local copy was written "while 0150 was still unmerged". It was not. LLP 0150 / `sessionMetaCwd` merged in #466 on 2026-07-29; the copy landed in #474 on 2026-07-30, and that commit's own docstring cited LLP 0150 `#usable-cwd` by anchor while explicitly declining to borrow it, on the scoping argument the bullet already states. Attributing the copy to timing rewrites the recorded rationale. Say what actually changed instead: the weight given to drift, not the scoping. Co-Authored-By: Claude <noreply@anthropic.com>
) * Codex live projector reads the cwd predicate from core, not a copy (#478) `usableInBandCwd` inlined its own `trim() && isAbsolute()` copy of the rule core exports as `sessionMetaCwd`. PR #474 added it deliberately and temporarily, while LLP 0150 was unmerged; #466 landed that predicate, so the copy now has an owner to defer to. Behaviour is unchanged. Only a non-empty string reaches the in-band seam (`readStringKey` and `firstString` refuse the rest upstream), and over that whole domain the two copies already agreed byte for byte. What changes is that there is one place left for the rule to drift from, which is the whole reason LLP 0150 exists: this exact rule, stated twice, shipped the wrong answer twice (#453, #459). The wrapper survives only for the refusal diagnosis: `error_kind` needs blank told apart from relative, and the shared predicate's single `undefined` cannot carry that. It now decides nothing. Docs: LLP 0083's bullet said the checks are restated locally and that 0150's predicate is not borrowed; LLP 0150's consequences listed the in-band path as "Still outstanding ... no such predicate", which #474 already falsified. Both now describe the shared predicate, and 0150 keeps its scoping caveat: sharing the rule was LLP 0083's call, not an invariant 0150 imposed. Co-Authored-By: Claude <noreply@anthropic.com> * Review round (521cf88): hermetic resolver for the new seam test, honest LLP 0083 provenance Two review findings on PR #513, both in the round's own scope. R1 (test hygiene): the new equivalence test was the only case in `codex-exchange-projector.test.js` that built a projector with no injected resolver, so the shared matcher walked the REAL ancestors of `/work/repo` looking for a `.hypignore`. A file above the checkout on the machine running the suite would turn `projection.cwd` into a `USAGE_POLICY_DROP` sentinel and redden an assertion that has nothing to do with the gate. Inject a resolver whose fs holds no list anywhere, matching how every neighbouring case in the file works, and hoist the projector out of the loop. Re-checked the mutation after the change: loosening `usableInBandCwd` back to `cwd.trim().length > 0` still reddens the test on the "repo" case, so the hermetic resolver did not make it vacuous. R2 (doc accuracy): the LLP 0083 bullet claimed the local copy was written "while 0150 was still unmerged". It was not. LLP 0150 / `sessionMetaCwd` merged in #466 on 2026-07-29; the copy landed in #474 on 2026-07-30, and that commit's own docstring cited LLP 0150 `#usable-cwd` by anchor while explicitly declining to borrow it, on the scoping argument the bullet already states. Attributing the copy to timing rewrites the recorded rationale. Say what actually changed instead: the weight given to drift, not the scoping. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: test <test@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: test <test@test.com>
Root cause
Two modules read a Codex rollout's
session_metaline and must apply the samethree rules, because both answers gate a privacy control and a wrong one is
silent:
hypaware-core/plugins-workspace/codex/src/rollout-cwd.jswantspayload.cwdfor the
.hypignorematch (LLP 0083).hypaware-core/plugins-workspace/ai-gateway/src/session_command.jswants anid for
hyp session(LLP 0066/0067).Each held its own copy of the rules. The copies had already drifted into two
shipped defects (#453 stated a thread id the drop never matched; #459 resolved
the root's cwd for a subagent turn), and the drift was still live at HEAD:
readRolloutMeta(gateway) had notype === 'session_meta'guard, so arollout whose first line is any other record carrying
payload.idandpayload.cwd(aturn_contextdoes) resolved a confident id belonging to nosession.
hyp session ignorewould then report success for a drop that dropsnothing.
readRolloutCwd(codex) had the envelope guard but usedstringValue, whichaccepts a whitespace-only
cwdand hands it to the policy matcher as a path.Patching both copies again would restore agreement without removing the reason
they disagree, so the fix removes the second copy instead.
Fix
src/core/codex/rollout_session_meta.jsis now the only reader of that line.parseRolloutSessionMeta/readRolloutSessionMetareturn{ threadId, sessionId, cwd }and state the three rules once:hand-written
Deserializeback-fillssession_idfromid),typemust besession_meta,undefined, andsessionIdisnever derived from
threadId.The bounded first-line read (line 1 only, 64 KiB prefix, LLP 0049 R6) moves in
with the parse rather than staying duplicated at each caller. Both callers now
delegate; neither keeps a predicate of its own.
It lives in core, not in
@hypaware/codex: the gateway plugin cannot reach intoanother plugin's private
src/modules, and single-caller ownership is exactlywhat makes drift cheap. That is LLP 0003's rule (a behavior otherwise
copy-pasted into every plugin belongs in core) and follows the partition-spec
helpers' promotion to a neutral core home (LLP 0022). LLP 0143 records the
decision, the placement question the issue asked to answer, and the
consequences.
sessionIdis exposed with no consumer on purpose: rule 1 exists for it, soleaving it unread is what let the "never back-filled" property go unstated.
Which id each caller uses is unchanged here; that stays with #453/#459.
Reproducing tests
Both callers get a test that fails on the current code:
test/plugins/ai-gateway-session-status.test.js::a first line that is not a session_meta record resolves nothing, however much it looks like one-a rollout whose first line is a
turn_contextcarryingpayload.idand amatching
payload.cwd. AssertsresolveSessionIdForClirefuses and nevernames the bogus id. Before:
not ok(it resolvednot-the-session-id).After:
ok.test/plugins/codex-rollout-cwd.test.js::a blank session_meta.cwd is no cwd, not a blank path handed to the policy matcher. Before:not ok(theresolver returned
' '). After:ok.test/core/codex-rollout-session-meta.test.jsis the union suite the issue'sground-truth gate asks for, 13 tests over both callers' failure paths: legacy
rollout with no
session_id(refuse, never back-fill), subagent rollout(container and thread stay apart), four wrong envelope types plus
missing/blank/non-string
type, blank and non-string fields, no payload,byte-identical passthrough of a field that survives the blank test, first line
only, an over-long first line, and unreadable/empty/absent files. Every guard
was mutation-checked: each of the 8 mutations tried (drop the envelope guard,
back-fill
sessionIdfromid, drop the trim, drop the string check, drop theobject gate, read the whole file instead of line 1, shrink the prefix, read
threadIdoffsession_id) reddens a specific named test.Verification
npm test: 2895 tests, 2886 pass, 8 fail. The 8 failures aretest/core/leave-command.test.js, pre-existing and unrelated (identical set onunmodified
origin/master); no new failures, +16 tests.npm run typecheck(tsc -p tsconfig.json --noEmit): clean.backfill_codex_fixture,gateway_codex_capture,session_optout_capture_drop: all ok.Note for the reviewer
The issue asked for this after #458 landed, to avoid conflicting with a held PR.
#458 is still open, so this touches
session_command.js(the file #458 rewrites)and
rollout-cwd.js(which #462 also touches). The conflicts are confined tothose two seams and both are small: each caller is now a two-line delegation, and
#458's move onto the session container becomes
meta.sessionIdat its seamrather than a fourth rewrite of the reader. No existing LLP text was edited, so
there are no doc conflicts.
Fixes #465