fix: validate the wrapper's inputs and stop reporting a refused resolution as unsupported #11
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/dispatch-input-validation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Four things the wrapper accepted or described wrongly. Each now fails loudly, and
dist/index.mjsis rebuilt to match.parsePrNumberranNumber(raw)past an integer check, so007,7.0,7e0,7,+7and0x7all became 7. Dispatching007builds the concurrency groupreview-007, which supersedes nothing — two passes then write the same comment surface. The raw string must now match^[1-9][0-9]*$, and the converted value must be a safe integer:Number("9007199254740993")is 9007199254740992, and enough digits giveInfinityand a request for/pulls/Infinity.REVIEW_SERVICE_URLwas only checked for emptiness before the capability token rode anAuthorizationheader to whatever it named. It must now parse as anhttps:URL whose path does not already end in/v1— the client appends that prefix itself, sohttps://review.j4k.dev/v1used to 404 every call as/v1/v1/reviews. The new optionalexpected-service-originaction input pins the origin exactly when a caller sets it; left unset, or set to the empty string an undefined vars expression expands to, only the scheme and prefix checks apply.Nothing read the pull request's state, so a
workflow_dispatchfor a closed or merged PR was reviewed like any other. The dispatch arm now refuses anything that is not open and unmerged, and refuses with a separate message when the forge returns neitherstatenormergedrather than guessing.A runtime 403 or 404 from the resolve endpoint was logged as "conversation resolution is unavailable on this instance" — false on a
-j4kserver whose version probe just confirmed the route exists (#8). The 403 now says the task token may not use the route. The 404 stays ambiguous on purpose: the probe matches the-j4ksubstring, which proves fork lineage and not that the build serves the endpoint, so the message names all three causes — an older build, a comment deleted since the sweep, or a refused token. Either way the pass degrades to replies instead of failing the run. That fixes the label, not the refusal — a real 403 still needs the Actions task token granted resolve permission server-side, so #8 stays open.A follow-up in
j4k/alignbumps the pinned sha in the review workflow template and setsexpected-service-origin: https://review.j4k.dev.Review
01M1CCXN526GR602HEQ4P50821— head63e033375a4c58f9f146473b7aa39369fb9ef542Review — j4k-oss/review-wrapper @
da2202054eScope: diff against base tree
017065182efcStatus: dispatched — coverage complete (3/3 slots terminal)
Computed under:
Findings (0)
No findings survived.
Reviewed:
Other claims
01M1CDAX3XN94FZSQSR6CCH8B2medium — reviewCredentials validates a normalized URL but returns the raw value01M1CDC6XTQVM2JBHM2VEAC401medium — workflow_dispatch does not recheck open state after the review waitCoverage
@ -64,0 +71,7 @@if (shape.status !== 403 && shape.status !== 404) {return undefined;}const endpoint = typeof shape.url === "string" ? shape.url : "an unrecorded endpoint";if (!serverSupportsResolution) {return {reason: "unsupported",low — classifyResolutionFailure's
unsupportedarm is unreachable: every production call passes probed === truelens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M1CA80TEG6VJT888NWT8NZKNof review01M1C9TJ50AP66066Y2JKBA0FTConfirmed and fixed in
30972d873b.The trace holds.
pass.probedisreadonly,pass.supportedstarts equal to it and is only ever assignedfalse, and bothresolveandunresolveare reachable only past a!pass.supportedguard (projectClaimsline 125 beforesettle,projectSupersededline 151 before its ownresolve) — soserverSupportsResolutionwas a constanttrueand theunsupportedarm was dead. Grepping confirmed the other half too:reasonwas read nowhere outsideconversation-markers.test.ts.Took the first branch you named.
classifyResolutionFailure(error, probed)is nowresolutionRefusalMessage(error)returning the message alone;ResolutionDegradationand its single-valuedreasonare gone, as is the now-unusedPass.probedfield and the comment that claimed two diagnoses ship. The unprobed instance keeps its one diagnosis, unconditionally, inreconcile's!probedbranch before any request goes out. The two tests asserting the dead branch are deleted; the rest were rewritten against the string return. Full gate set green (bundle, format:check, knip, typecheck, lint, fta, 249 tests).The review pass listed this under
## Other claims → unadjudicated, so it never became a thread. Checked it anyway, and it is accurate:reviewCredentials()skips the origin assertion when the input isundefinedor""(src/credentials.ts, andsrc/credentials.test.tspins the empty case deliberately), while both descriptions said only "Left unset". The gap matters becauseexpected-service-origin: ${{ vars.SOMETHING }}expands to the empty string when the variable is undefined — the pin disappears with no error.Documented in
30972d873b:action.ymland the README's action-inputs table now say an empty value leaves the pin off. Behaviour is unchanged.@ -64,0 +65,5 @@if (shape.status !== 403 && shape.status !== 404) {return undefined;}const endpoint = typeof shape.url === "string" ? shape.url : "an unrecorded endpoint";return `review-wrapper: the forge refused conversation resolution with HTTP ${shape.status} from ${endpoint}; the version probe reports a -j4k instance, so the route exists and the task token is not allowed to use it — projecting replies only for the rest of this pass`;low — Resolution 404s are misdiagnosed as token permission failures
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M1CB65VHAYSQ2MD691DSW763of review01M1CAJG8RVF2X9P9R0N20S9Y6Agreed and fixed in
885f00f11e.The probe is exactly as narrow as you describe —
probeResolutioninsrc/forge/client.tsreads/versionand returnsmeta.version.includes("-j4k"), which proves fork lineage and nothing about the route. An older-j4kbuild predating the resolution endpoint passes it and 404s on the first resolve, and the anchor can also disappear between the sweep and the write. So the 403 diagnosis was being applied to a status that does not carry it.Split by status rather than softening both: 403 keeps
so the task token may not use the route, and 404 now readsthe version probe reports a -j4k instance but not that this build serves the route, so the cause is that build, a comment deleted since the sweep, or a refused token. Status and endpoint stay verbatim in both, and both still degrade the pass to replies.resolutionRefusalMessageis renamedresolutionDegradationMessageto match. Full gate set green (252 tests).@ -43,6 +48,6 @@function parsePrNumber(raw: string | undefined): number {const prNumber = Number(raw);if (raw === undefined || !Number.isInteger(prNumber) || prNumber <= 0) {if (raw === undefined || !PR_NUMBER.test(raw)) {throw new Error(`workflow_dispatch input pr_number is not a positive integer: ${String(raw)}`);}return prNumber;return Number(raw);}medium — Large dispatch PR numbers are silently rounded to another pull
lens
general-bug· armdefault· tally 1 valid / 0 invalid / 0 uncertainclaim
01M1CAVBM028NK5JRDP5WBNTQRof review01M1CAJG8RVF2X9P9R0N20S9Y6Reproduced and fixed in
885f00f11e.Confirmed against the regex directly:
Number("9007199254740993")is9007199254740992,Number("99999999999999999999999")is1e+23, and 400 digits giveInfinity— all three pass^[1-9][0-9]*$.parsePrNumbernow requiresNumber.isSafeIntegerafter the conversion and throwsworkflow_dispatch input pr_number is larger than any pull request number: <raw>otherwise, so nothing reachesgetPull. New cases inevent-context.test.tspin both rejections and accept9007199254740991at the boundary.I did not chase a forge contract for the identifier range — the fix does not need one, since a value the runtime cannot represent exactly is refused rather than assumed impossible. Full gate set green (252 tests).
Round 3's pass listed this under
## Other claims → unadjudicatedand never opened a thread for it, so here is the disposition. It reproduces:createCapabilityClientbuilds paths like/v1/reviews, andbuildUrlin the transport resolves them withnew URL(path.replace(/^\//, ""), base.endsWith("/") ? base : base + "/"). WithREVIEW_SERVICE_URL=https://review.j4k.dev/v1that giveshttps://review.j4k.dev/v1/v1/reviews— every call 404s and nothing names the cause.The confusion was already in this repo: four
credentials.test.tscases usedhttps://review.j4k.dev/v1as a valid service URL.Fixed in
63e033375a:serviceOriginnow refuses a base whose path ends in/v1, beside the existinghttps:check, and the README's environment-contract row says so. A base mounted under any other path (https://review.j4k.dev/review) still resolves correctly and is covered by a test. The four fixtures move to the origin.Also recording the round-3 pass's coverage: its
test-trimmingslot came backabandonedand the run exited 1, so that lens did not review head885f00f. This push starts a fresh pass over the same diff.Adjudicating round 4's two unadjudicated claims. Neither opened a thread, so the dispositions are here. This is review round 4, where the rule is to fix only clear, severe, demonstrably real bugs and to defer the rest — every push costs another full pass over the whole diff.
The security reading is refuted, and I checked it rather than deferring it, because the value decides where the capability token goes.
serviceOrigincallsnew URL(serviceUrl); the transport callsnew URL(path, raw.endsWith("/") ? raw : raw + "/"). Same WHATWG parser, same raw string, so the origin cannot diverge. Ran the interesting inputs — leading whitespace, an explicit:443, embedded userinfo (https://user:pw@review.j4k.dev), and the backslash-authority trick (https://review.j4k.dev\@evil.example) — and the validated origin equals the request origin in every one.What is left is hygiene, not a defect: returning
parsed.hrefinstead of the raw string would make the validated value the used value by construction. Deferred to a follow-up PR — the exact change isreturn { serviceUrl: parsed.href, … }inreviewCredentials(src/credentials.ts), which needsserviceOriginto hand back the parsed URL rather than its origin.Accurate as described.
assertDispatchableruns once insidereadWrapperInputs, before the orchestrator opens the session, and a pass takes minutes — so a PR merged or closed during the wait still gets its comment surface reconciled.Acknowledged, not fixed, for two reasons. The round gate: writing comments onto a PR that closed mid-review is untidy, not corruption, and nothing is lost. And it is a design question rather than a defect — the
pull_request_targetarm has no state check at all and never did, so rechecking on only the dispatch arm would make the two paths disagree. If it should be fixed, the change belongs in the orchestrator after the wait returns, not inassertDispatchable, and it should cover both arms.