CRW-122 Phase 1 · 유휴 감독 전달: 스레드 승인 정책을 가정하지 말고 보존한다 - #65
Merged
Merged
Conversation
A parent's recovery or merge-turn return to an idle supervisor whose approvalPolicy is on-request was refused with unsupported_approval_policy before turn/start, so the report could never arrive. Reproduced against an isolated app-server on codex-cli 0.154.0: expected "never", returned "on-request", attemptedEffects ["thread/resume"], zero turn/start. The policy was a constructor default no caller could set. The resume transmitted it, which asked to SET the policy of a thread this bridge did not create, and findings() judged the answer against it, which refused every thread that was not already never. Now the caller DECLARES the policy it believes the thread is on and the resume carries none at all. A parameter never sent cannot move a policy, whichever host is on the other end; measured in both directions, omitting it reports the thread's own policy and does not inherit the CODEX_HOME config default. The guard is intact and its predicate is honest: it refuses when the observed policy is not the declared one, so a supervisor whose state moved is caught rather than written to. Declaring nothing declares never, so every existing caller keeps exactly its behaviour. Delivery is not approval servicing. This bridge grants no approval, and its refusals are now recorded rather than asserted. It has no route to the thread's own approver and says so instead of implying one. delivery is derived in reconcile from what actually went out, so a death between the turn/start frame and its response can never leave a receipt claiming the message is safe to resend.
Only that component's subdirectoryTree, packageTree and sourceDigest move. The codex-session-relay entry is left exactly as it is; it belongs to CRW-120 and PR #58 under the standing peer agreement.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Three findings from PR review. An explicit approval_policy of null became "never" instead of failing, because the value was tested rather than the key's presence. A key that was written must never read as a key that was not; that is the failure the unknown-key refusal beside it already exists to stop. Keyed on presence now, and refused locally before any RPC. One test line carried trailing whitespace. The earlier "git diff --check passes" reading was taken against a clean working tree after committing, so it proved nothing; checked against the base now. The package README still said the approval policy is always never and that every resumed interactive policy is refused, and the operational bridge reference omitted approval_policy from expected_settings. A caller following either would have kept hitting the original failure.
The README lives inside the package subtree, so its correction moves subdirectoryTree as well. The codex-session-relay entry is still untouched.
APPROVAL_LIMITS said categorically that an approval refused here is not shown to the thread's approver, while get_capabilities said in the same breath that host-side forwarding is unverified. Two contradictory claims in one object, and the categorical one is the unmeasured half. What is actually established is narrower and is all that is claimed now: this bridge has no forwarding route, and nothing it does turns such a request into an approval. Whether the host independently surfaces the same request to the owning client is left unverified in every place that mentions it -- the receipt, get_capabilities, the tool docstring, the package README and the bridge reference.
The codex-session-relay entry stays untouched.
Owner
Author
|
@codex security review |
🛡️ Codex Security ReviewSecurity review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Brings in #58 (CRW-115), whose files are the relay package and disjoint from this branch's bridge surface. components.json is touched by both sides, but in different component entries: dev re-derived codex-session-relay's three digests and this branch holds codex-thread-bridge's. Both are kept as their owners produced them.
Brings in CRW-120's landed relation contract, which lifts the Phase 2 gate. components.json is touched by both sides in different component entries; each owner's values are kept.
enqueue took its recipient off the relationship row, which fixes its parent at registration. A completion arriving after the project changed hands was therefore addressed to the parent that had stepped down, and it passed assert_assignment_delivery because that check compares against the same frozen row. The stale answer was also the self-consistent one. resolve_recipient asks the landed linkage who owns the scope now. It verifies rather than overrides: a recipient that would disagree with the authorized direction is not substituted, the disagreement is the finding and it is refused, so a late report is never filed as the new owner's result. The reader's three answers stay three answers. An unreadable store is refused as unreadable and never falls back to the frozen row, because a store that could not be read has said nothing about the owner. Nothing found is reported as nothing found. Two live owners are refused without choosing between them. Each gets its own reason, since collapsing two is indistinguishable from failing to detect one. The linkage is an optional constructor argument, so every caller written before it keeps its exact behaviour, and it is only ever read.
Phase 2 touches the relay, so its three digests move. The codex-thread-bridge entry is untouched.
A parent's completion is supposed to be computed from the approved scope's real child criteria, verification and integration. Nothing computed it: outstanding() was consumed only by handover, and no code asked whether a project was done. project_state lifts this class's own per-assignment derivation one level, using the landed linkage to enumerate the project's live assignments. It returns a reading rather than a verdict, and the strongest thing it says is complete_candidate, because integration and verification are the parent's judgment and are not visible here. None of the non-answers becomes completion. An unreadable store says so and does not report an empty outstanding set. A project with nothing attached is unregistered, which is not every assignment being finished. Two live owners is ambiguous and is not resolved by picking. Partial Done cannot close a parent because the outstanding set has to be empty rather than small, work parked on another parent still counts because the set is the project's and not one parent's, and a child's own goal status is never consulted at all.
Bridge entry untouched.
…ding up Three review findings, all real. up() turns its state ambiguous only for competing owners, so every other inconsistency it reports arrived with a resolved state and was delivered through. owner_drift is the one that matters: the linkage doc says it is reported at both ends and that the handover sequence passes through it on purpose, because each assignment moves to the incoming parent before the scope does. In that window the project binding can still name the outgoing parent, so the owner equality check agreed with the frozen row and delivered to the parent stepping down - the exact case this path exists to stop. A resolved state carrying contention is no longer treated as a resolved owner. project_state answered unreadable for every production caller, because the Services property built AssignmentView without a linkage. The reading existed only in its own tests. It is wired now. owners() sat outside the error boundary, so a store failure there raised a database error at a caller asking about completion instead of returning the documented unreadable shape. It reads the same store as the two queries above it and is required to classify the answer, so it belongs inside.
My previous fix refused on any contention up() reported. That list also carries every retained linkage_conflicts row for the scope, and nothing ever deletes those: they exist to remember a refused write. So one historical rejected mutation would have blocked this scope's deliveries permanently. The two are distinguishable in the record rather than by guesswork. A walk finding carries a "contention" key - owner_drift, competing_owners, competing_parents, scope_cycle, instruction_conflict, ambiguous_scope - and an audit row carries "reason" and no "contention". Only the live findings refuse. The owner_drift protection is unchanged, including when a drift arrives alongside an audit row.
project_state expanded outstanding() into per-assignment states outside its error boundary. outstanding() returns ids; naming each one's state reads the same registry and store again, so a failure there raised at a caller asking about completion instead of returning the promised unreadable reading. Same escape as the owners() one, one line further down.
project_state shipped with no caller but its own tests, so the reading existed without anyone able to ask for it. linkage-completion sits beside the other linkage query commands, takes --project, and is listed in OFFLINE_COMMANDS because like the rest of that surface it reads the store and never calls a host. It prints the four answers distinctly rather than reducing them to a yes: unreadable, unregistered, ambiguous, incomplete with the unfinished rows named, and complete_candidate. It never prints "complete" - integration and verification are the parent's judgment and are not visible to this reader.
Moving the unfinished expansion inside the first error boundary also moved it before the ambiguity branch, so a project with two live owners plus a failing state() read answered unreadable instead of ambiguous - replacing the specific fact with the vaguer one. Two live owners is establishable from owners() alone, and an unattached project from attached() alone. Both classifications now run first, and the expansion keeps its own boundary afterwards so a state() failure is still classified rather than raised at a caller asking about completion.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
A parent sending a recovery or merge-turn return to an idle supervisor whose
approvalPolicyis
on-requestwas refused withunsupported_approval_policybeforeturn/start, so thereport could never be delivered. Steering an active supervisor worked, which is why documents
and manual recovery survived while idle-supervisor report-then-resume did not.
Reproduced against a real isolated app-server (codex-cli 0.154.0, its own
CODEX_HOME, nocredentials — nothing live was touched):
Cause
The approval policy was a constructor default that no caller could set
(
settings.pyapproval_policy="never"). Two separate faults followed:resume_params()transmittedapprovalPolicy: "never"on every resume that requestedanything. That is asking to set the policy of a thread this bridge did not create.
findings()judged the host's answer against that same assumednever, so any thread that wasnot already
neverwas refused before a turn could start.The change
The caller now declares the policy it believes the thread is on, and the resume carries no
approvalPolicyat all.policy, whichever host is on the other end.
ThreadResumeParams.approvalPolicyis optional;ThreadResumeResponserequiresapprovalPolicyandapprovalsReviewer, so the truth isalways observable without asking for anything.
never" to"observed is not the declared one", which is how a supervisor whose state moved under the
caller gets caught rather than written to. Omitting the key declares
never, so every existingcaller keeps exactly its previous behaviour, including its previous refusal.
with a JSON-RPC error, which declines to decide; answering in the approval vocabulary
(
approved/decline) would write a verdict in the approver's own type and make the bridge theapprover. Those refusals are now recorded, keyed by
threadId/turnId, so "this bridgegranted nothing" is evidence rather than an assurance.
deliveryis derived inreconcilefromattemptedEffects, never pre-written. A deathbetween the
turn/startframe and its response can therefore never leave a receipt that saysoutcome_unknownbesidenot_delivered— which would tell the next caller it is safe to resend.Nothing is relaxed: no policy is forced to
never, no guard is deleted, no sandbox or approvalsetting is widened, and
create_thread/create_worktree_threadstill setneveron the threadsthey create.
Measured, not assumed
The whole design rests on "omitting the parameter preserves the policy". That was originally
inferred from schema optionality; the architect review flagged it, so it was measured on
codex-cli 0.154.0 in both directions with isolated
CODEX_HOMEs:config.approval_policyon-requestneveron-requestneveron-requestneverRead twice each; nothing persisted. Omission preserves and does not inherit the config
default. Separately measured: a resume transmitting
neveragainst anon-requestthread comesback
on-requestand does not persist, so on this host the old transmission was an ill-shapedrequest rather than a live relaxation. Both facts are stated separately and neither is overclaimed.
Tests
12 new tests, red-green verified against the original source: declared delivery to an
on-requestidle thread; the guard still refusing when nothing is declared; a policy that changedunder the caller; a granular policy that can be observed but never declared; an explicit null
declaration refused locally; the resume never carrying
approvalPolicy; an approval requestrefused and never decided; a refusal preserved as
not_deliveredwith zeroturn/start; andrecovery processing one logical message exactly once across replayed refusals, a corrected
declaration and repeated ACKs.
conftest.pygains a fake that acts on the resume parameter. Against it, the old resume wouldhave moved an
on-requestsupervisor toneverand then reported success, because the value itfound was the value it had just written. The default fake models the measured host, which ignores
the parameter — a fake that could only do that would have assumed the conclusion.
Validation
The one check the
Protect devruleset requires isdev-gate, and it issuccesson the currenthead. That ruleset also requires every review thread to be resolved (all five are) and requires the
branch to be current with
devat merge time — it is 4 commits behind with zero file overlap, whichis an integration step for whoever merges, not a defect here.
The external GitHub Codex reviewer is not a required gate under Jun's current policy, which
disabled it. It is not requested or re-requested on new heads, and an absent, pending or skipped
Codex review is recorded as not required by user policy rather than as a passing check. Devin
review and all repository-required CI are unaffected.
CI is green on the current head
4e3c7c8, base763ce15: run to be recorded below, attempt 1, eventpull_request—validate,tests (3.10),tests (3.13),packages (3.11),packages (3.13),secretsanddev-gateallsuccess;release-gateskipped.Locally:
scripts/ci/validate.py,scripts/ci/contracts.py,runtime_install.py verify-definitionand
git diff origin/dev --checkpass; bridge suite 198 passed, relay suite 1239 passed / 1 skipped.Two local results are sandbox artifacts rather than code, and CI covers both:
scripts/ci/testsreports one error —test_a_tilde_settings_path_is_absolute_once_the_hook_opens_itwrites to
$HOME, which is read-only in this sandbox. This PR touches no CI test file.scripts/ci/packages.pycannot run locally:/var/tmpis read-only and/tmpcontains a stray.git, exactly the condition its owntemporary_root()docstring warns about. The 38test_worktree.pyfailures it causes reproduce identically on the original source. Bothpackageslegs pass in CI.scripts/crw_runtime/components.json: only thecodex-thread-bridgeentry's three digests arere-derived. The
codex-session-relayentry is untouched — it belongs to CRW-120 / #58.Review round
Five findings, all real, all fixed and each answered in thread with evidence before its thread was
resolved. Two came from the external Codex reviewer, which Jun has since disabled; that changes
nothing about them. A reported defect is an obligation regardless of who reported it, so none of
these was auto-resolved or discarded when the reviewer went away, and no unrelated improvement was
absorbed alongside them.
never(Devin, marked resolved). The selection tested the value insteadof the key's presence, so an explicit null silently became
never— the same "a discarded keylooks like a setting never requested" failure the unknown-key refusal beside it exists to stop.
Keyed on presence now, with a regression test.
it: the original
git diff --checkran after committing against a clean tree, so it comparednothing and this description repeated that empty result as a pass. Re-checked against the base.
neverand interactive threads are always refused, and the bridge reference omitted the key entirely —
so a caller following either would have kept hitting the original failure. Both updated.
APPROVAL_LIMITSasserted categorically that arefused approval is never shown to the thread's approver, while
get_capabilitiessaidhost-side forwarding is unverified — two opposing claims in one object, and the categorical one
was the unmeasured half. The claim is now limited to what is established: this bridge has no
forwarding route and nothing it does turns such a request into an approval.
Scope
This PR now covers both phases of CRW-122. Phase 2 was gated on CRW-120 while that PR was in
flight; CRW-120 has since landed on
devand the gate was released, so the relay half isimplemented here rather than deferred.
Phase 2, in
packages/codex-session-relay, built against the landedlinkage.py:delivery.resolve_recipientreadslinkage.up(relationship_id=...)instead of taking therecipient off the relationship row, which freezes its parent at registration. It verifies
rather than overrides: a linkage owner that disagrees with the row is refused as
relation_owner_drift, so a completion arriving after the project changed hands is never filedas the new owner's result. An unreadable store is refused as
relation_unreadableand neverfalls back to the frozen row; nothing found is
unregistered_scope; two live owners isduplicate_scope_ownerand is not resolved by picking. A resolvedstatethat still carriescontentionis not treated as a resolved owner either — that is the handover window where theproject binding can still name the outgoing parent while the issue edge names the incoming one.
AssignmentView.project_statereads a project's completion from its children vialinkage.attached()andoutstanding(). It returns a reading, not a verdict: the strongeststate is
complete_candidate, because integration and verification are the parent's judgment.Partial Done cannot close it (the outstanding set must be empty, not small), a shared project
cannot be closed by one parent (the set is the project's), and a child's own goal status is
never consulted.
linkage.pyitself is untouched; both consumers only read it, and both take the linkage as anoptional argument so every pre-existing caller keeps its behaviour.
22 targeted tests across
test_delivery_relation.pyandtest_project_completion.py, red-greenverified: against
dev's relay source the files cannot even import.Known limits, recorded rather than closed
forwarding route — the protocol offers no method by which a second client hands an approval
request to the client that owns the thread. Whether the host independently surfaces it is not
established: measuring it needs a model actually requesting approval, and the isolated
CODEX_HOMEhas no credentials (measured 401). Left unverified everywhere it is mentioned.codex-session-relaystill hardcodesnever(settings.py:190,:222;schema/delivery-attempt.json:63). It belongs to CRW-120 / CRW-115: move a manual install to the plugin install, and own update, failure, disable and removal #58, so the claim here is scoped todirect bridge callers, and the relay change is returned to the coordinator as a follow-up.
through
ExecutionPolicyis a deliberate Phase 1 non-goal: the declared value is nevertransmitted, so the most a caller can do is correctly name the policy of the thread it addresses.