Skip to content

Codex live projector: an unusable in-band cwd is a miss, not a path (#471) - #474

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-471
Jul 30, 2026
Merged

Codex live projector: an unusable in-band cwd is a miss, not a path (#471)#474
philcunliffe merged 4 commits into
masterfrom
fix/issue-471

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Root cause

hypaware-core/plugins-workspace/codex/src/exchange-projector.js:120 fed the
in-band cwd (codexContext.cwd, else reqBody.cwd / metadata.cwd /
metadata.user_id.cwd, the LLP 0083
fast path) straight into resolver.resolve(cwd). firstString only asks "is
this a non-empty string", and the usage-policy matcher's first act is
path.resolve(cwd) (src/core/usage-policy/matcher.js:109). So a relative
value was silently measured against the daemon's process cwd, and the gate
returned a confident .hypignore verdict for a directory the session never ran
in. The same value is also handed to resolveRecordedContext, so it was stamped
on the row as that session's container.

Accepting it was wrong in both directions at once: it can drop a session no
.hypignore covers, and (the failure the control exists to prevent) it can
record a session whose real directory is ignored, because the verdict was
computed elsewhere entirely. A whitespace-only cwd had the same shape.

Direction chosen, and why

(a) refuse: an unusable in-band cwd counts as absent. usableInBandCwd
requires non-blank and path.isAbsolute before the value reaches the gate.

This is explicitly not a conversion to fail-closed. Refusing makes cwd
falsy, the caller's if (cwd) skips the check, the rollout fallback still gets
its turn, and when that states nothing either the row is recorded with
cwd = NULL (verified at exchange-projector.js:122 and backfill.js:241; the
already-modeled state of LLP 0049 R1 as extended by LLP 0085). What changes is
only that an unconfirmable cwd produces an honest NULL instead of a verdict for
the wrong directory plus a bogus stamped container. Refuse-and-NULL is better than accept-and-guess in
every case but one: it removes the false-drop mode and the poisoned folder/git
attribution, and for a should-be-ignored session whose real directory cannot be
confirmed the exposure is the same fail-open either way. The exception, found in
review and recorded in the helper docstring: when the daemon own process cwd
happens to sit under an ignoring .hypignore, the old guess reached the right
verdict by accident and this change records instead. No installer renders a
working directory, so that cwd is $HOME for a systemd --user unit or the
shell directory for a foreground start. That is a real, narrow loss of coverage
rather than a wrong verdict, and it is the concrete evidence for the fail-closed
decision this PR defers. Whether an unusable cwd should instead fail closed is
a separate, larger decision
and is not taken here: the existing cwd = NULL
precedent says recorded.

One deliberate consequence: a refused in-band cwd now falls through to the
rollout lookup, which is the better-trusted source for the same field. LLP 0083's
"in-band stays the fast path" bullet is amended to say so.

Duplicate predicate: stated locally, on purpose

sessionMetaCwd (the same non-blank-and-absolute rule,
LLP 0143 #usable-cwd) lives on
PR #466's unmerged branch, so this cannot import it without depending on a held
PR. The two checks (cwd.trim().length > 0 && isAbsolute(cwd)) are inlined in a
local usableInBandCwd helper whose docstring names sessionMetaCwd and says to
unify them once #466 lands. Duplicating two boolean checks was preferred over
either blocking this fix on #466 or moving a shared predicate into core in the
same change that #466 is already moving code around in.

Observability

An unusable cwd does not vanish silently. usableInBandCwd emits
plugin.codex.usage_policy_cwd_unusable at warn with
component/operation/status: 'refused', an error_kind of cwd_blank or
cwd_not_absolute (the error_kind idiom from backfill.js), and
cwd_sha256 - the same 16-hex digest the adjacent usage_policy_drop log uses,
so the two correlate and the raw path is never logged.

Reproducing tests

All three in test/plugins/codex-exchange-projector.test.js, against the real
shared matcher via the file's existing ignoringResolver helper:

  • project() computes no .hypignore verdict from a RELATIVE in-band cwd - the
    only governing .hypignore sits at path.resolve('sub'), i.e. exactly the
    mistaken base, and the exchange sends cwd: 'sub'. Asserts no
    USAGE_POLICY_DROP and no stamped cwd. Before: not ok
    (operator: 'notStrictEqual', both sides usagePolicyDrop: true - the wrong
    verdict really did fire). After: ok.
  • project() computes no .hypignore verdict from a BLANK in-band cwd -
    cwd: ' '. Asserts no drop, no blank path stamped on the row, and
    error_kind: 'cwd_blank'. Before: not ok. After: ok.
  • project() logs an unusable in-band cwd rather than skipping the gate silently - cwd: '../elsewhere'; asserts the warn's fields and that the raw
    path is hashed, never logged. Before: not ok. After: ok.

Mutation-checked: dropping isAbsolute reddens tests 1 and 3; dropping the blank
test (and its error_kind split) reddens test 2. Neither branch is decoration.

Verification

  • npm test: 2882 tests, 2873 pass, 8 fail, 1 skipped. The 8 are exactly
    test/core/leave-command.test.js, confirmed identical on a pristine
    origin/master worktree with the same node_modules. No new failures,
    +3 tests.
  • npm run typecheck and npm run build:types: clean.
  • Hermetic smokes gateway_codex_capture and session_optout_capture_drop: ok.
  • CLAUDE.md: no semicolons, no em dashes on any added line (checked over the
    added-lines diff), no @typedef, no inline import() types, JSDoc only. The
    single @ref (LLP 0083#decision [implements]) carries a relation and a gloss
    and points at a section that exists on master.

Overlap with held PRs

exchange-projector.js is also modified by held #467 (neutral:approved)
and #462 (neutral:stuck), so this will conflict there. The diff is
deliberately minimal for that reason: one changed line at the cwd seam
(wrapping the existing firstString(...) in usableInBandCwd(..., ctx)), one
new import line, and a new self-contained helper appended next to
readRecordedCwd. No refactor, no reformat, no rename, no import reordering.
Resolving against either held PR should be a one-line reapplication at line 120.

The LLP edit is a 7-line bullet inserted into LLP 0083's Decision list; #466
edits no existing LLP text, so there is no doc conflict with it.

Fixes #471

neutral-reconciler and others added 2 commits July 30, 2026 02:07
…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>
… 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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 1 of 2

Verdict: findings. The fix is correct and the direction is right. Four findings,
two fixed (both comment-only, in the new helper), two reported for a human. Nothing
here blocks merge on correctness grounds; the open items are a disclosure claim in the
PR body that I am not permitted to edit, and one pre-existing gap this PR reveals but
does not own.

Reviewed head 5faf456d. Round-1 fixes pushed to fix/issue-471 as 7132b87f
(comment-only, +14/-1 in the new helper's docstring, zero new conflict surface against
held #467 / #462 because none of those lines exist on their branches).

Verified by execution

npm test at 5faf456d: 2882 tests, 2873 pass, 8 fail, 1 skipped. The 8 are exactly
test/core/leave-command.test.js and are byte-identical on a pristine origin/master
worktree (c551d6e) with the same node_modules: 2879 tests, 2870 pass, same 8 names.
No new failures, +3 tests. npm run typecheck clean before and after my push.

(a) A row whose in-band cwd is refused and whose rollout fallback is empty is
RECORDED, and its cwd is genuinely NULL, not a wrong path.
Driven through the real
projector and the real shared matcher:

in-band cwd result cwd key on the row log
"sub" RECORDED, 2 messages key absent (stripUndefined) so NULL warn .../cwd_not_absolute
" " RECORDED, 2 messages absent warn .../cwd_blank
"../elsewhere" RECORDED, 2 messages absent warn .../cwd_not_absolute
"/work/ignored/sub" USAGE_POLICY_DROP n/a info usage_policy_drop
"/work/clean" RECORDED "/work/clean" none

So the PR's central disclosure ("recorded with cwd = NULL") is accurate, and no
misleading cwd is stamped. Fallback ordering did not change for rows that previously
resolved fine:
an absolute in-band cwd still short-circuits ahead of a wired
rolloutCwd (verified: in-band /work/clean wins over a rollout that would have said
/work/ignored/from-rollout), and an absolute ignored cwd still drops, including with a
trailing slash. A refused in-band cwd correctly falls through to the rollout and drops
when the rollout says an ignored directory.

(b) No real Codex cwd shape is wrongly refused. This is the failure mode I weighted
hardest, and the evidence clears it. For every non-absolute shape, the pre-fix code did
not honour .hypignore for that shape's own directory either; it computed a verdict for
a path under the daemon's cwd. path.resolve output on a POSIX daemon:

  • C:\Users\me\proj to <daemonCwd>/C:\Users\me\proj
  • ~/proj to <daemonCwd>/~/proj
  • \\server\share\proj to <daemonCwd>/\\server\share\proj
  • file:///work/proj to <daemonCwd>/file:/work/proj
  • "/work/proj" (quoted) to <daemonCwd>/"/work/proj"
  • ./proj, proj to <daemonCwd>/proj

None of those is the real directory, so refusing them removes a wrong verdict rather
than a right one. Windows is moot in any case: isSupportedPlatform is
darwin || linux only (src/core/daemon/platform.js:107), so a win32-flavoured
isAbsolute is not a topology this daemon runs in. Shapes that stay accepted and
unchanged: /work/proj, trailing slash /work/proj/, trailing space /work/proj ,
and /. And on the Codex route proper, the in-band value is not the raw body string at
all (see finding 3), so real codex-tui traffic is untouched.

(c) The duplicated predicate agrees with #466's sessionMetaCwd, today. Compared
against origin/fix/issue-465:

// #466: src/core/codex/rollout_session_meta.js
function metaField(value) { return typeof value === 'string' && value.trim().length > 0 ? value : undefined }
export function sessionMetaCwd(value) { const cwd = metaField(value); return cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined }
// this PR: exchange-projector.js:930
if (cwd.trim().length > 0 && isAbsolute(cwd)) return cwd

Same String.prototype.trim emptiness test, same path.isAbsolute, both return the
value byte-identical on success, both return undefined otherwise. The only differences
are the input type (unknown vs an already-narrowed string | undefined, which
firstString guarantees) and that this one logs. No divergence; unification once #466
lands is mechanical.
Worth noting for whoever unifies them: the trim is load-bearing
in sessionMetaCwd because metaField is shared with threadId/sessionId, but is
not load-bearing as a gate here (finding 2).

Findings

1. exchange-projector.js:905-908 (docstring) and the PR body: MEDIUM, the
"strictly dominates" claim is wrong. Fixed in code, still wrong in the PR body.

The PR body argues "Refuse-and-NULL strictly dominates accept-and-guess: identical
fail-open exposure for the should-be-ignored case, minus the false-drop mode."
The
exposure is not identical. There is a configuration where guessing reached the correct
verdict and this change now records where it previously dropped: when the daemon's own
process cwd sits under an ignoring .hypignore. Verified by execution: with the
governing .hypignore at the daemon's cwd and an in-band cwd: 'sub', pre-fix
path.resolve('sub') walks up into the ignored ancestor and drops; post-fix the row is
recorded with cwd NULL.

That configuration is not exotic. Neither daemon installer renders a working directory
(src/core/daemon/macos.js:105-128 emits no plist working-directory key,
src/core/daemon/linux.js:115-119 emits no WorkingDirectory=), so the daemon's cwd is
$HOME for a systemd --user unit and whatever shell started a foreground daemon,
which is very often a project directory. A user with a blanket $HOME/.hypignore loses
a drop they were getting.

Refusing is still the right call, and I did not change the behaviour: the same guessed
base also produced false drops for every session that ran elsewhere, so what is lost is
an accident, not a guarantee. But it is a narrowing of coverage, not a strict
improvement, and it belongs next to the code. Fixed at 7132b87f by stating it in
usableInBandCwd's docstring, including the pointer that a drop which only holds while
the daemon runs from the right directory is precisely what a fail-closed design would
have to replace.

Decision left to a human: the PR body still contains the incorrect domination claim
and I was instructed not to edit it. Either soften that sentence to match the docstring,
or accept the body as round-1 reasoning superseded by the code comment. Separately, the
PR explicitly defers "should an unconfirmable cwd fail closed" as a larger decision;
this finding is evidence for taking that decision rather than a reason to block, and the
existing cwd = NULL precedent (LLP 0049 R1 / LLP 0085) supports the choice made here.

2. exchange-projector.js:928-930: LOW, one predicate branch is dead as a gate and
the PR's mutation claim is wrong. Fixed.

The PR states "dropping the blank test (and its error_kind split) reddens test 2.
Neither branch is decoration."
Mutation-checked each branch independently against
test/plugins/codex-exchange-projector.test.js:

mutation result
drop isAbsolute not ok 5, not ok 7, 38/40
drop cwd.trim().length > 0 40/40 green
drop the error_kind split only not ok 6, 39/40
remove the warn entirely not ok 6, not ok 7, 38/40
revert the helper to returning cwd not ok 5, not ok 6, not ok 7, 37/40

So the blank conjunct is not load-bearing as a gate, and not because the test is
vacuous: it is provably redundant. A whitespace-only string can never be absolute on
either platform (absoluteness requires a leading /, drive letter, or separator, none
of which trim removes), so isAbsolute already refuses every blank value. isAbsolute
alone would be a behaviourally identical gate. The conjunct is load-bearing only for the
error_kind split at line 939, which is the sole thing distinguishing cwd_blank from
cwd_not_absolute. Fixed at 7132b87f: the comment now says the trim gates nothing
on its own and exists for the error_kind split, so the next person to simplify the
predicate does not silently delete the split. No behaviour change and no test change,
because the redundancy is not testable as a gate.

Everything else in the test file is non-vacuous. All three new tests redden on a full
revert of the helper, and the raw-path assertion in test 3 is load-bearing: replacing
cwd_sha256: sha256Hex(cwd).slice(0, 16) with the raw value reddens it (39/40), and so
does adding a raw cwd field alongside the digest (39/40).

3. exchange-projector.js:653 and :773: LOW to MEDIUM, pre-existing, undisclosed,
NOT fixed.
On the Codex route the new predicate mostly does not get to see the raw
body value, because codexContext.cwd is not that value: it is
selectCodexWorkspace(...)?.path (line 714), a key of the workspaces turn-metadata
map
, and selectCodexWorkspace falls back to workspacePaths[0] when no key matches
the cwd (line 773). Verified by execution: with workspaces: { '/work/ignored/proj': {} }
and body cwd: 'sub', the exchange drops and emits no
usage_policy_cwd_unusable warn at all, because the gate ran against the absolute
workspace key rather than against sub. Same for cwd: ' '. Only when the metadata
carries no workspaces map does the raw body value reach usableInBandCwd.

Two consequences. First, this is good news for finding (b): real codex-tui traffic
sends absolute workspace roots, so the predicate does not disturb it. Second, the
?? workspacePaths[0] substitution is the same defect class as #471, a confident
.hypignore verdict plus a stamped cwd for a directory the session may not have run in,
reached by workspace-key substitution instead of path.resolve. In practice workspaces
usually has one entry which genuinely is the session root, so the hazard needs multiple
workspaces with a non-matching cwd, which is why I am not calling it high severity.

I did not fix this: it lives at lines 653 and 773, outside the new helper, in exactly
the neighbourhood held #467 and #462 rewrite, and whether workspacePaths[0] is a
legitimate cwd substitute is a design question, not a review nit. Decision for a human:
open a follow-up issue for the workspacePaths[0] fallback, or record it as accepted. It
should also be mentioned somewhere, because both the PR body and LLP 0083's new bullet
read as though the in-band cwd is now predicated in general, whereas on the Codex route
it is workspace-mediated first.

4. exchange-projector.js:122: LOW, a diagnostics-only gap, NOT fixed. A cwd that
is exactly "" is refused silently, with no warn. firstString
(response-items.js:269-271) tests value.length > 0, so an empty string never reaches
usableInBandCwd and never reaches the cwd_blank branch; " " does. The PR's
"an unusable cwd does not vanish silently" therefore has this one hole. Verified by
execution: cwd: '' yields RECORDED with no log line at all. Not fixed deliberately:
the user-visible outcome is identical (absent cwd, NULL column), "" was already
treated as absent before this PR so it is not a regression, and closing it means either
changing shared firstString semantics or restructuring the call site, both of which
enlarge a diff that is deliberately surgical. Reporting only.

Checked and clean

  • No leak (priority 3). The raw path is never logged. The helper's warn carries only
    component, operation, status, error_kind and cwd_sha256. The digest
    expression is byte-identical to the adjacent drop log's
    (sha256Hex(cwd).slice(0, 16) at line 135 and line 940), so an operator hashing a
    candidate path once matches either log, which is what the comment claims. error_kind
    derives only a two-way classification from the value. Nothing else in the new path
    records payload content, and both leak mutations above redden test 3.
  • LLP 0083's new bullet (priority 6) is accurate and does not overclaim. It states
    the daemon-cwd hazard, that the rollout fallback still gets its turn, that the row
    records cwd = NULL as before, and explicitly that refusing does not make the path
    fail closed. All four match observed behaviour. Both @refs
    (exchange-projector.js docstring and the test file) are LLP 0083#decision; LLP 0083
    exists, is Status: Accepted, and has a ## Decision heading, so the anchor resolves.
    The LLP 0143 #usable-cwd mention is prose, not an @ref, which is correct since that
    doc is not on master yet and an @ref to it would be a broken reference.
  • CLAUDE.md (priority 7) clean on every added line, mine included: no semicolons, no
    U+2014, no @typedef, no inline import() types, JSDoc only, one new import
    (node:path). Note two pre-existing em dashes in this file at lines 120 and 854
    that predate the PR; not fixed, since touching them would push the diff into the lines
    Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 and Codex rollout cwd keys on the thread, not the session container (#459) #462 rewrite for no functional gain.
  • Diff discipline held. The projector change is one wrapped expression at line 122
    plus one import plus a self-contained appended helper. My round-1 additions are
    comments inside that new helper only, so the reapplication cost against the held PRs is
    unchanged: still a one-line reapplication at line 122.

How the fixes were verified

Both fixes are documentation-only, so there is no test that reddens without them; I am
stating that explicitly rather than implying test-backed verification. What was verified:
git diff 5faf456d..7132b87f shows exactly the two named comment changes and nothing
else (+14/-1, one file); git ls-remote origin fix/issue-471 is 7132b87f;
npm run typecheck clean; the projector suite 40/40. The claims the comments now make
are each backed by the execution evidence in findings 1 and 2 above.

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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral review, round 2 of 2 (final round)

Verdict: findings. Reviewed head 7132b87f (confirmed still the head at start:
git ls-remote origin fix/issue-471 was 7132b87f, master c551d6e8). The fix itself is
correct, minimal and well tested, and I found no correctness or leak defect in it. Round 1's
two open items are both now resolved: the primary one turned out to be a real adjacent
hazard of the same class
, established by execution, and it is now filed as #476 with
the evidence and three costed options; the secondary one is not a one-or-two-line change, and
the claim that overstated it is now corrected in the code.

Round-2 fixes pushed to fix/issue-471 as 50919416 (documentation only, +21/-2, two
files, all inside lines this PR itself added, so the reapplication cost against held #467 /
#462 is unchanged: still a one-line reapplication at line 122).

Round 1's finding 3, answered: yes, workspacePaths[0] reaches the policy gate

This was the question I was asked to settle, so here is the evidence rather than a summary.
resolveCodexContext (exchange-projector.js:653) calls
selectCodexWorkspace(metadata, <in-band cwd>) and stamps the result as codexContext.cwd
(:722), which is the first argument to the gate expression at :122.
selectCodexWorkspace (:773) is
workspacePaths.find((key) => pathsEqual(key, cwd)) ?? workspacePaths[0]. Driven through the
real projector and the real shared matcher, one governing .hypignore of class ignore at
/work/ignored:

workspaces map request cwd result row cwd log
{'/work/ignored/proj':{}} /work/ignored/proj DROP (correct) n/a info usage_policy_drop
{'/work/ignored/proj':{}} /work/clean/real DROP n/a info usage_policy_drop
{'/work/clean/proj':{}} /work/ignored/real RECORDED /work/clean/proj none
{'/work/ignored/proj':{}} sub DROP n/a no cwd_unusable warn at all

Rows 2 and 3 are the two halves of #471 reached through a different door. Row 2 is a false
drop for a session no .hypignore covers. Row 3 is the failure the control exists to prevent:
a session whose real directory is ignored gets recorded, with a cwd from an unrelated tree
stamped on the row (LLP 0083's "One resolved cwd, used twice" means the substitution is both
gate input and row content). Row 4 shows the substitution runs ahead of this PR's
predicate, so the raw value never reaches usableInBandCwd and nothing is logged. Because
workspacePaths[0] is normally absolute, usableInBandCwd accepts it: this PR's predicate
cannot see the problem.

So it is the same hazard class as #471, and it survives this PR intact. Severity LOW to
MEDIUM on exposure, not on class: in the single-workspace common case the one key is the
session root, an ancestor of the real cwd, so the substitution is ancestor-conservative
(a deeper .hypignore under the cwd is missed, which is fail-open) rather than wrong-tree.
The wrong-tree verdicts need a multi-root session whose cwd sits under a root that is not
workspacePaths[0], or a normalization disagreement (pathsEqual only trims trailing
slashes, so symlinked spellings miss).

Not fixed here, deliberately, and this is the one decision left for a human. The fallback
is intentional and covered by an existing test ("Codex workspace selection prefers recorded
cwd over first metadata key"), and it is load-bearing for coverage: on the
ChatGPT-subscription route the request often carries no cwd, and workspacePaths[0] is then
the only in-band source of one, so deleting the substitution removes real .hypignore
coverage. Fixing it means deciding what codexContext.cwd means when the workspace map and
the request cwd disagree, which changes row content (cwd,
attributes.codex.workspace), not only the drop decision. That is the same reason #466
deferred #471. Filed as #476 (neutral:fix) with this evidence and three costed options.
It should land after #467 and #462 are resolved, since it is in the neighbourhood they rewrite.

Round 1's finding 4, answered: not a one-or-two-line change

Closing the cwd: '' diagnostics gap requires loosening a shared helper, not editing the new
one. Three layers strip '' before usableInBandCwd is reached: readStringKey
(:1057-1059, value.length > 0), the firstString inside readRecordedCwd (:884), and
the firstString at the call site (:122). readStringKey has 16 call sites in this
file. So making '' observable means changing shared-helper semantics plus the call site plus
a test, on the exact line held #467 / #462 must reapply, for a value no real client sends and
whose user-visible outcome is already identical (absent cwd, NULL column, and it was absent
before this PR too, so not a regression). Fixed the claim instead, which was the actual
defect: the "Never silently" comment in usableInBandCwd now records the '' gap and why it
stays. Disposition: accepted, documented in place.

Fix 2: LLP 0083's new bullet overclaimed (MEDIUM, fixed)

llp/0083-codex-live-cwd-from-rollout.decision.md, added bullet, last sentence: "The
rollout-stated cwd is held to the same rule."
That is not true on this branch.
hypaware-core/plugins-workspace/codex/src/rollout-cwd.js:95 returns
stringValue(payload?.cwd) as written, and the ?? fallback at exchange-projector.js:123
sits outside the usableInBandCwd call, so nothing predicates it. Verified by execution:
with a rollout resolver returning sub and the governing .hypignore at
path.resolve('sub'), the exchange drops on a verdict computed against the daemon's cwd.
The rule holds only on #466's unmerged branch, which the helper docstring says correctly and
the LLP bullet did not.

This matters more than a wording nit because the two claims compose badly: the bullet read as
though the whole cwd seam is now predicated, while in reality this PR's refusal hands off to
a source that is not yet predicated
, and (see above) the value it predicates is usually
workspace-mediated. Not a regression either way (a relative rollout cwd was already reachable
pre-PR whenever the in-band cwd was absent), but a reader would have been misled about the
current fail-open surface.

Fixed at 50919416: the bullet now names both limits, and usableInBandCwd's docstring
gained a matching paragraph so the code says what the doc says.

Verified by execution (not by reading)

  • npm test at 50919416: 2882 tests, 2873 pass, 8 fail, 1 skipped. The 8 are exactly
    test/core/leave-command.test.js (not ok 841-843, 845-849), the known pre-existing set,
    identical to round 1's baseline on origin/master. No new failures. npm run typecheck
    clean before and after my push. Projector suite 40/40.

  • Both predicate branches, mutated independently (round 1's result reproduces exactly):

    mutation result
    drop isAbsolute not ok 5, not ok 7, 38/40
    drop cwd.trim().length > 0 40/40 green
    drop the error_kind split only not ok 6, 39/40
    remove the warn entirely not ok 6, not ok 7, 38/40
    revert the call-site wrap at :122 (pre-PR) not ok 5, not ok 6, not ok 7, 37/40

    So isAbsolute is the whole gate and the trim gates nothing: a whitespace-only string is
    never absolute, so isAbsolute alone would be behaviourally identical. The trim is
    load-bearing only for the error_kind split, which is exactly what the round-1 comment at
    :938-942 now says, so the code is honest about it. The blank-cwd test is honest too: it
    is named for the verdict ("computes no .hypignore verdict from a BLANK in-band cwd"), which
    is true and is supplied by isAbsolute, and its one trim-dependent assertion is the
    error_kind equality. Nothing in the test file claims the trim is a gate. The residual
    inaccuracy is the PR body's mutation sentence ("dropping the blank test ... Neither
    branch is decoration"), which round 1 already flagged and which I am also not permitted to
    edit.

  • No leak (priority 3). The raw path is never logged. The warn carries only component,
    operation, status, error_kind, cwd_sha256. The digest expression is byte-identical to
    the adjacent drop log's: sha256Hex(cwd).slice(0, 16) at :135 and at :953, so the two
    correlate as the comment claims. Both leak mutations redden test 7: replacing the digest with
    the raw value gives 39/40, and adding a raw cwd field alongside the digest also gives
    39/40. error_kind derives only a two-way classification. Nothing on the new path records
    payload content, prompts or reasoning. Clean.

  • Refusal does not change fallback ordering or lose the rollout's turn. An absolute clean
    in-band cwd still short-circuits ahead of a wired rolloutCwd (row recorded, cwd
    /work/clean, rollout never consulted). A refused in-band cwd with a session id present
    falls through and the rollout's ignored directory does drop (warn cwd_not_absolute then
    info usage_policy_drop). A refused in-band cwd with no rollout answer records the row with
    no cwd key at all (stripUndefined), confirming round 1's central check: no misleading
    path is stamped.

  • @ref anchors (priority 5). The PR's two new refs are both LLP 0083#decision; LLP 0083
    exists, is Status: Accepted, and has a ## Decision heading, so both resolve. Checked with
    a slugifier that also honours this repo's explicit {#anchor} heading attributes: the PR
    head and origin/master produce the same repo-wide broken count over 1029/1031 refs, so
    this PR adds zero broken anchors. My round-2 edits add no new @ref.

  • CLAUDE.md (priority 6) clean on every added line, mine included: no semicolons, no U+2014
    anywhere in the added diff, no @typedef, no inline import() types, JSDoc only, one import
    (node:path). The two pre-existing em dashes at :120 and :854 that round 1 noted are
    still pre-existing and still untouched, correctly, since fixing them would push the diff into
    lines Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 / Codex rollout cwd keys on the thread, not the session container (#459) #462 rewrite.

  • Diff discipline held. Total against master: 3 files, +158/-1. One wrapped expression at
    :122, one import, one appended self-contained helper, one LLP bullet, three tests. My
    additions are comment lines inside that new helper and prose inside that new bullet, so they
    add no conflict surface at all: those lines do not exist on either held branch, and One reader for the Codex session_meta header, in core (#465) #466
    edits no existing LLP text.

Findings summary

# file:line severity disposition
1 exchange-projector.js:653, :773 LOW to MEDIUM Filed as #476. Same hazard class as #471, confirmed by execution, survives this PR. Not fixable surgically: the fix changes row content and sits where #467 / #462 rewrite.
2 llp/0083-...decision.md, added bullet MEDIUM Fixed at 50919416. The "rollout-stated cwd is held to the same rule" claim was false on this branch; both limits now named, and the helper docstring matches.
3 exchange-projector.js:122 with response-items.js:269-271 LOW Accepted, documented in place at 50919416. Not a one-or-two-line change: three layers strip '', and the innermost has 16 other callers. Outcome already identical and not a regression.
4 PR body: "strictly dominates" and the mutation claim LOW Not fixed, cannot be. Both were corrected in code by round 1; the body still carries the round-1 wording and reviewers are instructed not to edit PR bodies.
5 rollout-cwd.js:95, unpredicated rollout cwd LOW Not this PR's, already owned by #466. Documented now rather than left implicit. Not a regression: reachable pre-PR whenever the in-band cwd was absent.

The decisions a human must make

  1. Codex live projector: the workspace-key substitution can feed the .hypignore gate a directory the session never ran in #476, the only substantive one. Pick option 1 (separate the workspace-enrichment role
    from the gate/stamp role, which requires resolveCodexContext to return them separately),
    option 2 (prefer the request's own cwd on disagreement, one expression at :722, but it
    changes the recorded cwd for the cwd-is-a-subdir case and needs an LLP 0083 amendment plus
    a test update), or option 3 (accept, and let the LLP note added here be the final answer).
    Sequence it after Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 and Codex rollout cwd keys on the thread, not the session container (#459) #462.
  2. The PR body's two superseded sentences. Either soften "strictly dominates" and the
    "neither branch is decoration" mutation claim to match the code comments, or accept the body
    as round-1 reasoning that the merged code corrects. No code consequence either way.
  3. Whether an unconfirmable cwd should fail closed, which this PR explicitly defers. The
    evidence for taking that decision is now in the helper docstring (the case where the
    daemon's own cwd sits under an ignoring .hypignore and the old guess was accidentally
    right). The existing cwd = NULL precedent (LLP 0049 R1 / LLP 0085) supports what this PR
    chose, so this is not a reason to hold the PR.

On merge readiness: nothing above blocks this PR on correctness. Findings 1 and 5 are
adjacent exposures this PR does not own and now no longer hides, findings 2 and 3 are fixed,
and finding 4 is cosmetic in a field I may not touch. This was round 2 of 2; the PR now
routes to triage, and #476 is the vehicle for the one open piece of work.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage rung, assessing head 50919416.

Round 2's fix commit was never independently reviewed. 50919416 is the
commit round 2 pushed to address its own findings; the 2-round budget was spent
producing it, and no reviewer has looked at it since. This triage read the
whole head, not a delta, and treats 50919416 as unreviewed code until now.

Verdict: no true blocker. Safe to merge on review content. Everything below
is either already tracked, already accepted/documented in the PR, or a new
non-behavioral finding filed separately.

Two things the human should see explicitly before merging, because they are
deliberate, narrow coverage tradeoffs rather than incidental gaps:

  • Fail-open by design. A row whose in-band cwd is refused and whose
    rollout fallback is also absent is recorded with no cwd key at all, so the
    .hypignore gate cannot evaluate it. This is the PR's stated design (LLP
    0049 R1 as extended by LLP 0085), verified by execution, not a bug.
  • A narrow loss of coverage, not a strict improvement. When the daemon's
    own process cwd sits under an ignoring .hypignore (no installer renders a
    WorkingDirectory=, so that is $HOME for a systemd --user unit, or the
    shell's cwd for a foreground start), the old unpredicated guess sometimes
    reached the right "ignore" verdict by accident; this change now records
    instead of guessing. isAbsolute is the whole gate here (confirmed by
    mutation testing); no real Codex cwd shape is wrongly refused, and Windows is
    moot since isSupportedPlatform is darwin/linux only. I judge this an
    accepted, documented tradeoff rather than a blocker: it closes the more
    severe converse hole (recording sessions that were ignored, because the
    verdict was computed for the wrong directory), and a blanket $HOME-level
    .hypignore will usually still cover a user's real project directories
    through the rollout fallback. But it is a real, deliberate reduction in
    opt-out coverage for that one configuration, correctly disclosed in the PR
    body and the usableInBandCwd docstring, and the human merging this should
    do so with that tradeoff in view rather than assuming strict domination
    (round 1's original "strictly dominates" claim was wrong and round 2
    corrected it).

Residuals, all non-blocking:

  • exchange-projector.js:653, :773 (workspacePaths[0] substitution): real,
    already filed as Codex live projector: the workspace-key substitution can feed the .hypignore gate a directory the session never ran in #476, already has a fix in Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate #477 (open). Composes correctly
    with this PR (refused substitution -> usableInBandCwd refuses -> rollout
    fallback). Not re-filing.
  • exchange-projector.js:122 + response-items.js:269-271: the cwd: ''
    diagnostics-only gap is accepted and documented in place, not silent.
  • The PR body's "Neither branch is decoration" sentence describing the
    mutation-testing claim is still slightly imprecise (already flagged by round
    1, fixed in the code comment but not in the body, since a reviewer cannot
    edit PR bodies). Reporting for the human to tidy; not editing it myself.
  • usableInBandCwd's docstring comment "Keep both conjuncts or that split
    dies" mildly overclaims: dropping only the trim conjunct from the if
    leaves the suite green, because error_kind's classification does its own
    independent .trim() call at the log site. Confirmed by mutation testing.
    Cosmetic; the two real branches (isAbsolute, and the error_kind ternary)
    are each independently load-bearing as claimed.

New, filed separately (not a blocker for this PR): master has moved by six
PRs since round 2, not four, since #466 and #467 merged during this triage on
top of the four already named. #466 landed src/core/codex/rollout_session_meta.js
and LLP 0150, exporting sessionMetaCwd, the exact predicate this PR's
usableInBandCwd inlines locally. This PR's own body already says the plan was
to unify "once #466 lands" (it was blocked on #466 being unmerged); #466 is now
merged. LLP 0150 does not mandate this unification for the in-band path (it
explicitly scopes that out as "its own decision"), so this is not a violated
invariant and changes no behavior today, but it is real, now-actionable debt in
a codebase where two independent copies of this exact rule have already drifted
and shipped wrong twice (#453, #459). Filed as #478.

Mergeability has also drifted underneath this PR during this triage: GitHub
now reports CONFLICTING / DIRTY (it was MERGEABLE at dispatch), because of
the same #466/#467 merges, not because 50919416 changed. CI is still green at
50919416. This is orthogonal to the review-content verdict above and belongs
to a separate rung, but whoever resolves the conflict should know #478 exists
and that rollout-cwd.js on master no longer resembles this branch's copy
(it now delegates to readRolloutSessionMeta).

Ref hygiene (test/core/llp-ref-hygiene.test.js, added by #468): fails
standalone on this branch (2/9 subtests), but both failures are pre-existing
corpus debt in .claude/skills/ref-check and .claude/skills/ref-story docs
already present on origin/master before this branch diverged, unrelated to this
PR's diff. A local merge of this head with current master passes 9/9. This PR
introduces zero new ref-hygiene violations.

Relevant issues/PRs: #476, #477, #478.

…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>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…ssues (#476 review round 2)

Round 2 of the #477 review filed each residual as its own issue so it does not
live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd,
pre-existing), #481 (a newly-recorded row carries an ignored workspace's
identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling
of an ignored directory escapes its .hypignore).

Also corrects the third limit. It was written as a trade this amendment makes;
execution against a real on-disk symlink shows it is a property of the shared
matcher that predates the amendment, that the amendment swaps which of two
symmetric spellings trips it rather than opening a new leak, and that the widest
case (a declared symlinked key with no in-band cwd at all) is identical before
and after. Notes that the matcher fix must canonicalize the local-only list
entries too, since canonicalizing only the incoming cwd un-governs an entry a
user marked by its symlink spelling.

Doc only: exchange-projector.js stays byte-identical, so the conflict surface
against #462 and #474 is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage for head ce76b56c.

The prior triage covered head 50919416. One commit landed since then, ce76b56 (merge of origin/master, i.e. #466/#467), and it was never reviewed: the 2-round review budget was already spent. This comment is that review, done as triage rather than skipped.

The merge resolved one textual conflict (llp/0083's Decision bullet list, both amendments kept), but the resolving worker also corrected two claims that had gone stale and edited the usableInBandCwd docstring in exchange-projector.js to match. None of that was mechanical conflict resolution; all of it is assessed below.

1. The "one limit" correction: TRUE. Before #466, LLP 0083 said the rollout-stated cwd was "not yet" held to the non-blank-and-absolute rule. #466 has since merged: rollout-cwd.js reads through readRolloutSessionMeta -> parseRolloutSessionMeta -> sessionMetaCwd (src/core/codex/rollout_session_meta.js:176), which applies the identical non-blank && path.isAbsolute predicate. I traced that chain directly. The rollout side is genuinely predicated now, so "two limits" correctly became "one limit" (the remaining one is the workspace-key substitution, tracked as #476, confirmed still open and accurate).

2. The dangling @ref fix: TRUE, and complete. LLP 0143 on master is now the unrelated OpenClaw attach-probe doc (#475 took that number); this repo's cwd predicate doc was renumbered to LLP 0150. I grepped the three files this PR actually touches (exchange-projector.js, llp/0083-*.md, test/plugins/codex-exchange-projector.test.js) for LLP 0143 and LLP 0144: no hits. The new LLP 0150 #usable-cwd anchor resolves (llp/0150-one-reader-for-codex-session-meta.decision.md:115).

3. The "not a consequence of LLP 0150" claim: TRUE. LLP 0150's own Consequences section says it explicitly: "Still outstanding: the live projector's in-band cwd ... reaches the same resolver.resolve with no such predicate. It is a different source with a different trust story ... tightening it is its own decision rather than a consequence of this one." The merged LLP 0083 bullet and the usableInBandCwd docstring both say the same thing in the other direction (in-band restates the rule locally rather than borrowing it, and the restatement is not implied coverage). The two docs are consistent with each other, and consistent with why #478 exists (dedup the predicate) without contradicting why it's still open (LLP 0150 doesn't mandate that dedup).

4. The #476 limit and the cwd = NULL fail-open path: TRUE, spot-checked in code. selectCodexWorkspace (exchange-projector.js:947) still falls back to workspacePaths[0] exactly as described, and usableInBandCwd refusing still falls through to the rollout resolver and then to an unconditional cwd = NULL stamp when both are absent (exchange-projector.js:132-134, resolveRecordedContext).

Residuals #476 and #478: both re-read against the current tree and still accurate; not duplicating them here. Confirmed they're mutually consistent: #478 (dedup usableInBandCwd into sessionMetaCwd) exists precisely because LLP 0150 scopes the in-band path out of its own mandate (item 3 above), so it's correctly filed as a follow-up rather than a violated invariant.

ref-hygiene: test/core/llp-ref-hygiene.test.js passes against this branch merged with current master (the worktree at ce76b56c already is that tree): 8 pass, 1 skip (a pre-existing, documented skip for issue #463 item 1), 0 fail.

npm test: 8 failures, all in test/core/leave-command.test.js, confirmed identical by name on a pristine origin/master worktree with the same node_modules symlink. Not this PR's.

No further semantic drift found beyond the disclosed llp/0083 conflict and its accompanying docstring correction in exchange-projector.js; the rest of the master merge (#466/#467, LLP 0142-0151, etc.) is untouched by this branch's own 3-file diff.

The standing trade, re-judged independently, not carried over from the last triage: this PR still leaves a fail-open path. An unusable in-band cwd is refused, falls through to the rollout fallback, and if that is also absent the row is recorded with cwd = NULL rather than dropped. There is a narrow, disclosed coverage exception: when the daemon's own process cwd happens to sit under an ignoring .hypignore, the pre-#474 code's guess (resolving a relative cwd against the daemon's own process cwd) reached the right verdict by accident; this PR removes that guess, so it now records instead of dropping. No installer in this repo renders WorkingDirectory= for the systemd unit (checked src/core/daemon/linux.js, no such directive is emitted), so that daemon cwd is $HOME for a --user unit, or the shell's cwd for a foreground start.

I checked whether PR #482 or PR #477 change this. Neither does. #482 canonicalizes symlink spellings in the shared matcher, but its own body states it leaves exchange-projector.js byte-identical and explicitly defers "the provenance question" to #477; it doesn't touch the daemon-cwd-as-base mechanism at all (that mechanism no longer exists post-#474; the exception is a historical comparison against the pre-#474 behavior, not a live code path #482 could narrow). #477 changes which value reaches the gate (explicit in-band cwd over a substituted workspace key) but explicitly does not duplicate or alter usableInBandCwd, and its own body works through how its case (c) composes with #474: "substitution refused, sub refused as unusable, rollout fallback consulted, row recorded with cwd = NULL" - the same fail-open this PR already documents, unchanged.

My independent judgement: this remains a preference, not a blocker. It is disclosed at three levels (code docstring, LLP 0083, PR body), it is narrow (only fires when the daemon's own deployment cwd is itself under an ignoring rule and no rollout cwd is available), and the fail-closed alternative is explicitly named and deliberately deferred as a separate decision rather than silently dropped. I note, per the task brief, that an earlier round of this same PR asserted the fix "strictly dominates" the old behaviour, and review already disproved that; the current PR body no longer makes that claim and states the trade honestly. Flagging it here so it's in view at merge time, not to reopen it.

Verdict: no true blocker in the unreviewed merge commit. Marking this PR's head as triaged.

@philcunliffe
philcunliffe marked this pull request as ready for review July 30, 2026 05:36
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 30, 2026
@philcunliffe
philcunliffe merged commit 05581df into master Jul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-471 branch July 30, 2026 17:11
philcunliffe added a commit that referenced this pull request Jul 30, 2026
…e key at the .hypignore gate (#477)

* Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate (#476)

`selectCodexWorkspace` falls back to the first `workspaces` turn-metadata key
when none matches the request's `cwd`, and `resolveCodexContext` stamped that
result as the one resolved `cwd` (LLP 0083) that feeds the `.hypignore` gate. So
when the request's real cwd matched no declared workspace, the privacy verdict
was computed for an unrelated directory: an opted-out session could be recorded
(the leak), and a session no `.hypignore` covered could be dropped.

An explicit in-band `cwd` now outranks the workspace key for the gate and the
stamp. The key keeps its enrichment role and still supplies the `cwd` on the
subscription route, where the request states none and the key is the only
in-band source there is. A refused substitution is reported as
`plugin.codex.usage_policy_workspace_cwd_refused` with hashed paths.

`selectCodexWorkspace` itself is untouched to keep the conflict surface with
PRs #462, #467 and #474 as small as possible.

Co-Authored-By: Claude <noreply@anthropic.com>

* LLP 0083: record the limits of the workspace-key refusal (#476 review)

Review of PR #477 verified by execution that the fix closes #476 case (a) and
several unreported variants of it, and found three consequences the amendment
did not state:

- the workspace key still outranks the rollout fallback, so a subscription-route
  session that declares a `workspaces` map never consults `session_meta.cwd`
  and a first-key guess can still decide its verdict (true on `master` too);
- because the key keeps enriching, a row recorded where it used to drop (clean
  in-band cwd, ignored declared workspace) carries that ignored workspace's
  identity;
- the gate does not canonicalize, so a symlinked in-band spelling of an ignored
  directory is now recorded where the key's canonical spelling used to drop it.

Doc only: no code change, so the projector's contended lines are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>

* LLP 0083: the stated limits of the workspace-key refusal cite their issues (#476 review round 2)

Round 2 of the #477 review filed each residual as its own issue so it does not
live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd,
pre-existing), #481 (a newly-recorded row carries an ignored workspace's
identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling
of an ignored directory escapes its .hypignore).

Also corrects the third limit. It was written as a trade this amendment makes;
execution against a real on-disk symlink shows it is a property of the shared
matcher that predates the amendment, that the amendment swaps which of two
symmetric spellings trips it rather than opening a new leak, and that the widest
case (a declared symlinked key with no in-band cwd at all) is identical before
and after. Notes that the matcher fix must canonicalize the local-only list
entries too, since canonicalizing only the incoming cwd un-governs an entry a
user marked by its symlink spelling.

Doc only: exchange-projector.js stays byte-identical, so the conflict surface
against #462 and #474 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>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
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>
philcunliffe pushed a commit that referenced this pull request Jul 31, 2026
…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>
philcunliffe added a commit that referenced this pull request Jul 31, 2026
)

* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: deferred review finding from PR #466 (Codex live projector's in-band cwd reaches the .hypignore gate unpredicated)

1 participant