From 649878fc4134ca78e74f0e8fd0ee355425a9417a Mon Sep 17 00:00:00 2001 From: huangyiirene Date: Tue, 11 Aug 2026 14:31:02 +0000 Subject: [PATCH] fix(scripts): probe the transport and report a prerequisite, not a bare HTTP status (#7412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-half-states.mjs`'s docblock claimed "unauthenticated works at 60 req/h". That is not a fact about this script's environment — three container classes have now been measured and no two agree, so the file stated as universal what is per-container. The docblock now says what live mode actually requires (a route to api.github.com from NODE, whose fetch ignores HTTPS_PROXY and so does not share curl/gh/MCP's path; plus either no token or one that really is a GitHub credential) and records all three measurements, including this change's own: `GITHUB_TOKEN` set to the agent proxy's 14-char `prox…` placeholder, which makes the token fallback strictly worse than sending nothing. Behaviourally, the script now PROBES before it sweeps, in the #7718 shape: pure classifiers the self-test drives with the real observations, wording kept next to the code that knows what it did not check, and a PREREQUISITE NOT MET report that names which requirement is unmet, prescribes the one command that satisfies it, and states plainly that nothing was swept. Exit 3 for a classified prerequisite failure, distinct from the pre-existing 2 for one this file cannot name; both non-zero, so no wrapper's behaviour changes. `--probe` answers "can live mode run HERE?" without sweeping. The probe cost one trap to get right, and it is pinned by the self-test: `/rate_limit` is EXEMPT from the limit it reports, so with the quota spent it still answers 200 while every other endpoint 403s. A status-only reading green-lit a sweep that could not make one request — the #4690 shape this file exists to refuse. `parseRemaining` covers the sibling bug: `Number(null)` is 0, which would have turned every 401 into a phantom exhausted quota. Self-test 24 -> 58 cases. No H-predicate is touched (#7553's spelling question is a separate pending ruling), and no transport re-architecture is attempted: which token to send, and whether these scripts should grow an MCP-backed transport, stay the maintainer's call per triage. The script does not drop or substitute the token on its own, so a container where the sweep worked before works identically after. Refs #7341, #7379, #7718 --- scripts/pm/check-half-states.mjs | 576 ++++++++++++++++++++++++++++++- 1 file changed, 560 insertions(+), 16 deletions(-) diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index d12bd37452..afcee2b542 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -6,6 +6,7 @@ * label/assignee invariants the dispatch protocol calls "过夜半状态". * * node scripts/pm/check-half-states.mjs # sweep the live repo + * node scripts/pm/check-half-states.mjs --probe # can live mode run HERE? (no sweep) * node scripts/pm/check-half-states.mjs --self-test # verify the predicates offline * * ## Why report-only, and why the exit code is ALWAYS 0 on a completed sweep @@ -69,10 +70,79 @@ * title/assignee pair is the mechanical half; the sweep prints the sticker * URL so the patrol reads the body itself. * - * Auth: uses GITHUB_TOKEN / GH_TOKEN when present (unauthenticated works at - * 60 req/h — enough for a small board, not for comment-fetching sweeps). + * ## Transport prerequisite — MEASURED per run, never assumed (#7412) + * + * Live mode talks to `api.github.com` over node's global `fetch`, and that needs + * two things this repo's agent containers do NOT uniformly provide: + * + * 1. a route to `api.github.com` from NODE. Node's `fetch` (undici) ignores + * `HTTPS_PROXY`, so it does not share the path `curl`, `gh` and the + * `mcp__github__*` tools take. A container where `curl https://api.github.com` + * answers 200 can be one where this script reaches nothing, and the reverse + * also occurs. `curl` is therefore NOT a valid pre-flight for this script; + * the probe below is. + * 2. either NO token, or a token that really is a GitHub credential. + * `GITHUB_TOKEN` / `GH_TOKEN` being SET does not make them GitHub tokens: + * in agent containers both are commonly the agent proxy's own 14-character + * `prox…` placeholder. Sending that as a Bearer earns a hard 401 — strictly + * WORSE than sending nothing, because the token fallback at `TOKEN` turns a + * container where anonymous access WOULD have worked into one where the + * sweep cannot start. + * + * The paragraph this replaces claimed "unauthenticated works at 60 req/h". That + * is not a fact about this script's environment. Three container classes have + * been measured and no two agree: + * + * PM seat session (#7412 as filed) — proxy denies the host (curl 403 with and + * without the token), node fetch 401. GitHub access is MCP-only there, so + * live mode cannot run at all. + * Triage Routine (#7412 comment, 2026-08-11) — host reachable AND the injected + * `GITHUB_TOKEN` is a real credential (`/rate_limit` 200, 15000 core + * quota). Live mode runs fully. + * Cloud dev session (this change's own measurement, 2026-08-11) — node fetch + * reaches the host, but NEITHER identity can read the board: the token is + * the `prox…` placeholder (401 Bad credentials), and anonymous is 403 + * `API rate limit exceeded for ` because the 60 req/h anonymous quota + * is counted per EGRESS IP and was already spent by other containers + * behind the same NAT. Meanwhile `curl` answered 200 BOTH ways, because it + * honours HTTPS_PROXY and the proxy substitutes a real credential — the + * misleading pre-flight point 1 warns about, measured. + * + * That last class also produced the trap worth naming: `/rate_limit` is EXEMPT + * from the limit it reports. With the quota spent it still answers 200 (carrying + * `x-ratelimit-remaining: 0`) while every other endpoint answers 403 — so a + * probe that reads only the status code cheerfully green-lights a sweep that + * cannot make one request. The first draft of the probe below did exactly that. + * `probeIsUsable` is that lesson, and the self-test pins it. + * + * So the script PROBES (`GET /rate_limit`, which costs no core quota) before it + * sweeps. A failed probe prints a classified PREREQUISITE NOT MET report naming + * which of the two requirements is unmet and the one command that satisfies it — + * never a sweep result. `--probe` runs that check alone, which is what a seat + * should use to answer "can live mode run in THIS container?". + * + * Deliberately NOT decided here: whether these scripts should grow an MCP-backed + * transport or a required-real-token doctrine. That depends on where + * `scripts/pm/**` live modes are meant to execute, which is a maintainer call + * (#7412 triage, explicitly out of scope). This change only stops the file from + * lying about the transport it has. It does not drop, substitute or re-route the + * token, so a container where the sweep worked before works identically after. + * * REST only, never GraphQL (Operational notes 3: the loop's hot path stays on * the core quota). + * + * ## Exit codes + * + * 0 the sweep completed — 0 or 40 findings alike (report-only, see above). + * 3 PREREQUISITE NOT MET — a classified transport failure. Nothing was swept, + * and the report says so instead of implying a clean board. + * 2 the sweep could not run for a reason this file cannot classify. The + * pre-existing catch-all, kept so an unfamiliar failure stays loud (#4690). + * + * 2 and 3 are both non-zero, so any wrapper reading non-zero as failure behaves + * exactly as before. The split exists so a patrol can tell "this container was + * never able to run the live sweep" (3 — expected, go run it elsewhere) from + * "something broke" (2 — investigate). */ import process from 'node:process'; @@ -150,6 +220,324 @@ export function h6SeatBodyOversized(issue, limit = SEAT_BODY_SOFT_LIMIT) { return Buffer.byteLength(issue.body ?? '', 'utf8') > limit; } +// --------------------------------------------------------------------------- +// Transport prerequisite — the classifier (pure) and the probe that feeds it. +// +// Modelled on `scripts/cli-build-prerequisite.mjs`: the knowledge lives in pure +// functions the self-test can drive with the REAL measured observations, and the +// WORDING stays here, next to the only code that knows what it did not check. +// Kept in this file rather than shared with the CLI-build prerequisites — those +// classify a subprocess's stderr, this classifies HTTP observations; a common +// module would be one name over two unrelated corpora. +// --------------------------------------------------------------------------- + +/** The exit code for a classified transport prerequisite failure (see header). */ +export const EXIT_PREREQUISITE_NOT_MET = 3; + +/** + * What the token in the environment LOOKS like — never whether it is valid; only + * GitHub can say that, and a 401 is it saying so. This exists to enrich the + * report ("…and it carries no GitHub token prefix"), never to pre-reject a token: + * pre-rejecting on shape would silently drop a credential in a format GitHub + * added after this line was written, which is the confident-wrong-diagnosis + * failure the sibling module is built to avoid. + * + * `redacted` is prefix-plus-length, the same form #7412 used to report the + * `prox…` placeholder. A real token's first four characters are its public + * prefix, so this is safe to print; the rest never is. + * + * @param {string} token + * @returns {{ present: boolean, shape: 'absent'|'github-prefix'|'legacy-40-hex'|'unrecognized', redacted: string }} + */ +export function describeToken(token) { + const t = String(token ?? ''); + if (!t) return { present: false, shape: 'absent', redacted: '' }; + const redacted = `${t.slice(0, 4)}… (len ${t.length})`; + if (/^(?:gh[pousr]_|github_pat_)/.test(t)) return { present: true, shape: 'github-prefix', redacted }; + if (/^[0-9a-f]{40}$/.test(t)) return { present: true, shape: 'legacy-40-hex', redacted }; + return { present: true, shape: 'unrecognized', redacted }; +} + +/** + * Whether a probe result means "requests will actually go through" — which is + * NOT the same as "the probe returned 200". + * + * `/rate_limit` is exempt from the rate limit it reports: with the anonymous + * quota spent it still answers 200, carrying `x-ratelimit-remaining: 0`, while + * `/repos/…/issues` answers 403 `API rate limit exceeded`. Measured on this + * change's own container, where the first draft of this probe green-lit a sweep + * that then failed on its very first page. A `null` remaining (header absent) is + * treated as usable: absence is not evidence of exhaustion, and the in-loop net + * is the backstop. + */ +export function probeIsUsable(result) { + if (!result || result.networkError) return false; + return result.status === 200 && result.rateLimitRemaining !== 0; +} + +/** + * `x-ratelimit-remaining` as a number, or null when the header is absent. + * + * The null matters and `Number()` alone will not give it: `Number(null)` is 0, + * and a 401 carries no rate-limit headers at all — so the naive read turns every + * bad-credential response into "quota exhausted" and would misprescribe the + * remedy. Absent means unknown, and `probeIsUsable` treats unknown as usable. + */ +export function parseRemaining(raw) { + if (raw === null || raw === undefined || raw === '') return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} + +/** One probe result as a readable clause, for the report's evidence lines. */ +export function describeProbe(result) { + if (!result) return 'not attempted'; + if (result.networkError) return `did not complete (${result.networkError})`; + if (result.status === 200 && result.rateLimitRemaining === 0) { + return 'HTTP 200 but with x-ratelimit-remaining: 0 — the quota endpoint is exempt from the limit it reports, so every other endpoint answers 403'; + } + const left = result.rateLimitRemaining === null || result.rateLimitRemaining === undefined ? '' : ` (${result.rateLimitRemaining} left)`; + return `HTTP ${result.status}${left}`; +} + +/** + * The exhausted-quota verdict, shared by the two observations that mean it: a + * 403 on a real endpoint, and `/rate_limit`'s exempt 200 with 0 remaining. + */ +function rateLimitedVerdict(tok, result, how) { + return { + kind: 'rate-limited', + headline: tok.present + ? 'the API rate limit for this credential is exhausted' + : 'the anonymous API rate limit (60 req/h) is exhausted for this egress IP', + detail: [ + `\`GET /rate_limit\` -> ${describeProbe(result)}.`, + ...(how ? [`In this state ${how}.`] : []), + ``, + ...(tok.present + ? [`The quota refills on the hour.`] + : [ + `The anonymous 60 req/h is counted per EGRESS IP, not per container, so in a`, + `shared-NAT agent container it is routinely already spent by neighbours — being`, + `"unauthenticated" is not a quota of one's own. It refills on the hour.`, + ]), + ], + fix: tok.present + ? ['wait for the quota window, or use a credential with a larger quota.'] + : ['export GITHUB_TOKEN= (5,000+ req/h), or wait for the window.'], + }; +} + +/** + * Turn probe OBSERVATIONS into a named prerequisite verdict. Pure — the network + * lives in `probeTransport` — so `--self-test` can pin every branch against the + * three container classes actually measured in #7412. + * + * Deliberately narrow, in the same direction as `looksLikeStaleWorkspaceDist`: + * an unrecognised status comes back as `null` (= "not a failure this classifier + * can name") and the caller keeps its pre-existing loud generic failure. A wrong + * confident diagnosis here would send a seat to fix a credential when GitHub was + * merely down. + * + * @param {{ token?: string, authed?: object|null, anon?: object|null }} obs + * `authed` / `anon` are each `{ status, rateLimitRemaining }` or + * `{ networkError }`; `anon` is only gathered when a token was used and failed. + * @returns {{ kind: string, headline: string, detail: string[], fix: string[] } | null} + */ +export function classifyTransportProbe(obs) { + const token = obs?.token ?? ''; + const tok = describeToken(token); + const authed = obs?.authed ?? null; + const anon = obs?.anon ?? null; + const primary = tok.present ? authed : anon; + if (!primary) return null; + const anonUsable = probeIsUsable(anon); + + const shapeNote = + tok.shape === 'unrecognized' + ? `The value carries no GitHub token prefix (\`ghp_\`/\`gho_\`/\`ghs_\`/\`github_pat_\`) — in` + : `The value has a GitHub token shape, so it is a credential this account no longer holds —`; + const shapeNote2 = + tok.shape === 'unrecognized' + ? `agent containers this is normally the proxy's own placeholder, not a credential.` + : `expired, revoked, or scoped to a different repo.`; + + if (primary.networkError) { + return { + kind: 'host-unreachable', + headline: '`api.github.com` is not reachable from node in this container', + detail: [ + `\`GET /rate_limit\` did not complete: ${primary.networkError}`, + ``, + `Node's fetch does not use HTTPS_PROXY, so this says nothing about \`curl\`, \`gh\``, + `or the \`mcp__github__*\` tools — those may all work here and still not be this`, + `script's transport.`, + ], + fix: [ + 'run the sweep from a container with direct egress to api.github.com (CI, or', + 'the Routine seat class); in an MCP-only seat the board read stays manual.', + ], + }; + } + + // A 200 from `/rate_limit` is NOT sufficient, and finding that out is what the + // measurement below cost: GitHub exempts `/rate_limit` from the limit it + // reports, so it keeps answering 200 with `x-ratelimit-remaining: 0` while + // every other endpoint answers 403. A probe that read only the status would + // vouch for a sweep that cannot make a single request — the exact + // "green check that checked nothing" this file exists to refuse (#4690). + if (primary.status === 200 && primary.rateLimitRemaining === 0) { + return rateLimitedVerdict(tok, primary, 'every OTHER endpoint answers 403 `API rate limit exceeded`'); + } + + if (primary.status === 200) { + return { + kind: 'reachable', + headline: tok.present + ? 'api.github.com is reachable and the token authenticates' + : 'api.github.com is reachable anonymously (no token in the environment)', + detail: [], + fix: [], + }; + } + + if (primary.status === 401 || (primary.status === 403 && anonUsable)) { + const anonWorks = anonUsable; + return { + kind: anonWorks ? 'bad-credential-anon-reachable' : 'bad-credential', + headline: anonWorks + ? 'the token in the environment is not a valid GitHub credential — and it is the ONLY thing stopping the sweep' + : 'the token in the environment is not a valid GitHub credential', + detail: [ + `\`GET /rate_limit\` with GITHUB_TOKEN/GH_TOKEN = ${tok.redacted} -> ${describeProbe(primary)}.`, + ...(anon ? [`The same request with NO token -> ${describeProbe(anon)}.`] : []), + ``, + `${shapeNote} ${shapeNote2}`, + ``, + ...(anonWorks + ? [ + `The host IS reachable from node here and anonymous access has quota left, so`, + `the credential is the only thing in the way.`, + ``, + `This script does not drop the token on its own — which token to send is the`, + `caller's decision, and silently sweeping as a different identity is not a call`, + `a report-only tool should make (#7412 triage: transport doctrine is the`, + `maintainer's).`, + ] + : [ + `Dropping the token would NOT be enough here: the anonymous path is unusable`, + `too, so this container needs a real credential rather than a re-run.`, + ]), + ], + fix: anonWorks + ? [ + 'GITHUB_TOKEN= GH_TOKEN= node scripts/pm/check-half-states.mjs', + ' ↑ anonymous is 60 req/h and that quota is per EGRESS IP, shared with every', + ' other container behind it. This sweep spends one request per label page plus', + ' one per assigned pm-tracked card, so it can exhaust mid-run — which surfaces', + ' as another PREREQUISITE NOT MET, never as a short finding list.', + ] + : ['export GITHUB_TOKEN= and re-run (see the anonymous reading above).'], + }; + } + + if (primary.status === 403) { + if (primary.rateLimitRemaining === 0) { + return rateLimitedVerdict(tok, primary, ''); + } + return { + kind: 'host-unreachable', + headline: '`api.github.com` answers 403 in this container — the host is refusing, not rate-limiting', + detail: [ + `\`GET /rate_limit\` -> HTTP 403${tok.present ? ` with GITHUB_TOKEN/GH_TOKEN = ${tok.redacted}` : ' (no token)'}.`, + ...(anon ? [`The same request with NO token -> ${anon.networkError ? anon.networkError : `HTTP ${anon.status}`}.`] : []), + ``, + `403 in both directions with quota left is the egress proxy refusing the host,`, + `not GitHub refusing the caller — the shape #7412 measured in a PM seat session.`, + `\`curl\` and the \`mcp__github__*\` tools take a different path and may still work.`, + ], + fix: [ + 'run the sweep from a container with direct egress to api.github.com (CI, or', + 'the Routine seat class); in an MCP-only seat the board read stays manual.', + ], + }; + } + + return null; +} + +/** + * The observations, gathered from the live host. `/rate_limit` is the probe + * because it is the one endpoint that costs no core quota — asking "can I read + * this board?" must not spend the budget the sweep then needs. + * + * The second, token-less probe fires ONLY when a token was sent and failed. That + * is what separates "the credential is bad" from "the host is unreachable" — + * two facts with different remedies that the card's original measurement could + * not tell apart. The healthy path stays at exactly one request. + */ +async function probeRateLimit(token) { + try { + const res = await fetch(`${API}/rate_limit`, { + headers: { + accept: 'application/vnd.github+json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + return { status: res.status, rateLimitRemaining: parseRemaining(res.headers.get('x-ratelimit-remaining')) }; + } catch (err) { + return { networkError: err?.cause?.code ?? err?.cause?.message ?? err?.message ?? 'fetch failed' }; + } +} + +async function probeTransport() { + const first = await probeRateLimit(TOKEN); + if (!TOKEN) return classifyTransportProbe({ token: '', anon: first }); + if (first.status === 200) return classifyTransportProbe({ token: TOKEN, authed: first }); + return classifyTransportProbe({ token: TOKEN, authed: first, anon: await probeRateLimit('') }); +} + +/** + * The prerequisite printer. Its load-bearing half is the closing paragraph: the + * whole point of #4690 is that "could not read the input" must never be legible + * as "the input is clean", and on a REPORT-ONLY tool that risk is sharper than + * on a gate — a silent run of this script looks exactly like a healthy board. + * + * `swept` keeps that paragraph TRUE when the failure arrives mid-run: the + * pre-sweep probe fires at 0, where "nothing was listed" is exact, while the + * in-loop net can fire after some labels were already read. Same invariant as + * `check-i18n-bundles`'s partial-round wording (#7681/#6033). + * + * @param {{ kind: string, headline: string, detail: string[], fix: string[] }} v + * @param {{ swept?: number }} [options] + */ +function reportPrerequisiteNotMet(v, options = {}) { + const { swept = 0 } = options; + const nothing = + swept === 0 + ? [ + ` Nothing was swept: no issue was listed and no predicate (H1–H6) ran, so this`, + ` result says NOTHING about whether the board carries half-states. It is not a`, + ` clean board and it is not a dirty one — it is no reading at all.`, + ] + : [ + ` Nothing was judged: the transport failed after ${swept} issue(s) had been listed,`, + ` the rest were never fetched, and no finding line was printed — H2 in particular`, + ` needs a per-card comment fetch that never happened. An empty finding list here`, + ` is not a clean board.`, + ]; + console.error( + `\ncheck-half-states: PREREQUISITE NOT MET — ${v.headline}\n\n` + + v.detail.map((l) => (l ? ` ${l}` : '')).join('\n') + + `\n\n Fix: ${v.fix[0] ?? 'unknown'}\n` + + v.fix.slice(1).map((l) => ` ${l}\n`).join('') + + `\n${nothing.join('\n')}\n` + + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` + + ` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, + ); + process.exit(EXIT_PREREQUISITE_NOT_MET); +} + // --------------------------------------------------------------------------- // Live sweep // --------------------------------------------------------------------------- @@ -161,7 +549,14 @@ async function rest(path) { ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), }, }); - if (!res.ok) throw new Error(`GET ${path} -> HTTP ${res.status}`); + if (!res.ok) { + // The status rides along so the in-loop net can re-classify rather than + // re-parse the message — the same reason the CLI prerequisites return the + // matched sentence instead of a boolean. + const err = new Error(`GET ${path} -> HTTP ${res.status}`); + err.status = res.status; + throw err; + } return res.json(); } @@ -178,8 +573,32 @@ async function listIssues(label) { } async function sweep() { + // Answered once, before any listing — so an unusable transport costs ONE + // classified verdict instead of a raw HTTP status from whichever label page + // happened to go first (`pm:dispatched`, in the failure #7412 recorded). + const pre = await probeTransport(); + if (pre && pre.kind !== 'reachable') reportPrerequisiteNotMet(pre); + const findings = []; const seen = new Map(); + try { + await sweepInto(findings, seen); + } catch (err) { + err.sweptSoFar = seen.size; + throw err; + } + + findings.sort((a, b) => a[0].number - b[0].number); + for (const [issue, code, msg] of findings) { + console.log(` ${code} #${issue.number} ${msg}\n ${issue.html_url}`); + } + console.log( + `check-half-states: swept ${seen.size} open pm-labeled issue(s) in ${OWNER_REPO} — ` + + `${findings.length} half-state(s) found. Report-only: findings are patrol input, not a gate verdict.`, + ); +} + +async function sweepInto(findings, seen) { for (const label of ['pm:dispatched', 'pm:queue', 'pm:blocked', 'pm:seat']) { for (const issue of await listIssues(label)) seen.set(issue.number, issue); } @@ -213,19 +632,10 @@ async function sweep() { } } } - - findings.sort((a, b) => a[0].number - b[0].number); - for (const [issue, code, msg] of findings) { - console.log(` ${code} #${issue.number} ${msg}\n ${issue.html_url}`); - } - console.log( - `check-half-states: swept ${seen.size} open pm-labeled issue(s) in ${OWNER_REPO} — ` + - `${findings.length} half-state(s) found. Report-only: findings are patrol input, not a gate verdict.`, - ); } // --------------------------------------------------------------------------- -// Self-test — predicates only; no network. +// Self-test — predicates and the transport classifier; no network. // --------------------------------------------------------------------------- function selfTest() { @@ -269,6 +679,117 @@ function selfTest() { t('H6: multi-byte body measured in bytes', h6SeatBodyOversized(issue(['pm:seat'], [], '账'.repeat(3_400), '[PM seat] domain:devx — ⏳ vacant')), true); t('H6: oversized body without pm:seat is out of scope', h6SeatBodyOversized(issue(['pm:queue'], [], 'x'.repeat(20_000), 'big card')), false); + // -- transport prerequisite (#7412) --------------------------------------- + // The three container classes are REAL measurements, not invented fixtures; + // each names where it was taken, so a future transport change can be checked + // against the environments that actually exist rather than against a guess. + const kind = (o) => classifyTransportProbe(o)?.kind; + + // Class 1 — PM seat session, #7412 as filed: the host refuses in both + // directions with quota left. Must NOT be reported as a credential problem: + // a real token would not have helped, and sending a seat to find one wastes + // the round. + t( + '#7412 class 1 (PM seat): 403 both ways -> host-unreachable', + kind({ token: 'prox_placeholder', authed: { status: 403, rateLimitRemaining: 59 }, anon: { status: 403, rateLimitRemaining: 59 } }), + 'host-unreachable', + ); + // Class 2 — triage Routine container: reachable with a real credential. + t( + '#7412 class 2 (Routine): authed 200 -> reachable', + kind({ token: 'ghp_' + 'x'.repeat(36), authed: { status: 200, rateLimitRemaining: 14_999 } }), + 'reachable', + ); + // Class 3a — a token GitHub rejects while anonymous still has quota. The + // distinguishing case the card could not name: the ONLY fault is the + // credential, and the remedy is a re-run, not a hunt for a token. + t( + 'token 401 but anon has quota -> bad-credential-anon-reachable', + kind({ token: 'prox_abcdefghi', authed: { status: 401, rateLimitRemaining: null }, anon: { status: 200, rateLimitRemaining: 59 } }), + 'bad-credential-anon-reachable', + ); + t( + 'that verdict prescribes the token-less re-run, not a new credential', + classifyTransportProbe({ token: 'prox_abcdefghi', authed: { status: 401 }, anon: { status: 200, rateLimitRemaining: 59 } }).fix[0].includes('GITHUB_TOKEN= GH_TOKEN='), + true, + ); + // Class 3b — the SAME container as actually measured on 2026-08-11: the token + // 401s AND the shared-IP anonymous quota is spent. Dropping the token does not + // help, so the verdict must not prescribe it — the first draft did, and the + // prescribed command then failed with 403 on its first page. + const class3 = classifyTransportProbe({ + token: 'prox_abcdefghi', + authed: { status: 401, rateLimitRemaining: null }, + anon: { status: 200, rateLimitRemaining: 0 }, + }); + t('#7412 class 3 (cloud dev, measured): 401 + exhausted anon -> bad-credential', class3?.kind, 'bad-credential'); + t('…and it does NOT prescribe the token-less re-run', class3.fix.join(' ').includes('GITHUB_TOKEN= GH_TOKEN='), false); + t('…and it names a real credential as the remedy', class3.fix[0].includes('a real GitHub token'), true); + // The trap itself: `/rate_limit` is exempt from the limit it reports, so a + // 200 with 0 remaining is an EXHAUSTED quota, never a green transport. A + // status-only reading here green-lights a sweep that cannot run (#4690). + t( + '/rate_limit 200 with remaining 0 is rate-limited, NOT reachable', + kind({ token: '', anon: { status: 200, rateLimitRemaining: 0 } }), + 'rate-limited', + ); + t('probeIsUsable: 200 with quota left', probeIsUsable({ status: 200, rateLimitRemaining: 5 }), true); + t('probeIsUsable: 200 with 0 left is NOT usable', probeIsUsable({ status: 200, rateLimitRemaining: 0 }), false); + t('probeIsUsable: absent header is not evidence of exhaustion', probeIsUsable({ status: 200, rateLimitRemaining: null }), true); + t('probeIsUsable: network error', probeIsUsable({ networkError: 'ECONNREFUSED' }), false); + // `Number(null)` is 0, so the naive header read turns every 401 (which carries + // no rate-limit headers) into a phantom exhausted quota and misprescribes the + // remedy. Absent must mean unknown. + t('parseRemaining: absent header is null, not 0', parseRemaining(null), null); + t('parseRemaining: empty header is null, not 0', parseRemaining(''), null); + t('parseRemaining: a real 0 survives', parseRemaining('0'), 0); + t('parseRemaining: a real count survives', parseRemaining('4999'), 4999); + t('parseRemaining: garbage is unknown, not 0', parseRemaining('n/a'), null); + t('probeIsUsable: nothing observed', probeIsUsable(null), false); + t( + 'describeProbe names the exemption in the evidence line', + describeProbe({ status: 200, rateLimitRemaining: 0 }).includes('exempt from the limit it reports'), + true, + ); + // No token at all, host fine — the shape the old docblock assumed universal. + t('no token + 200 -> reachable', kind({ token: '', anon: { status: 200, rateLimitRemaining: 60 } }), 'reachable'); + // A bad credential where anonymous ALSO fails must not promise that dropping + // the token is enough. + t( + '401 with anon also refused -> bad-credential (not the anon-reachable remedy)', + kind({ token: 'ghp_stale', authed: { status: 401 }, anon: { status: 403, rateLimitRemaining: 0 } }), + 'bad-credential', + ); + // 403 WITH remaining:0 is the quota, not the proxy — different remedy. + t('403 + remaining 0 -> rate-limited', kind({ token: '', anon: { status: 403, rateLimitRemaining: 0 } }), 'rate-limited'); + t( + 'exhausted anonymous quota prescribes a credential', + classifyTransportProbe({ token: '', anon: { status: 403, rateLimitRemaining: 0 } }).fix[0].includes('GITHUB_TOKEN'), + true, + ); + t('network error -> host-unreachable', kind({ token: '', anon: { networkError: 'ENOTFOUND' } }), 'host-unreachable'); + // The narrowness that keeps a wrong confident diagnosis out: anything this + // classifier cannot name stays unclassified, and the caller keeps its loud + // generic failure (exit 2) rather than blaming a credential for a GitHub + // outage or a typo'd PM_SWEEP_REPO. + t('502 is not a prerequisite failure', classifyTransportProbe({ token: '', anon: { status: 502 } }), null); + t('404 is not a prerequisite failure', classifyTransportProbe({ token: 'ghp_x', authed: { status: 404 } }), null); + t('no observation at all -> unclassified', classifyTransportProbe({ token: '' }), null); + // Token shape enriches the wording and never gates the request: an unknown + // future prefix must still be SENT, so GitHub gets to be the judge. + t('describeToken: classic prefix recognised', describeToken('ghp_abc').shape, 'github-prefix'); + t('describeToken: fine-grained prefix recognised', describeToken('github_pat_abc').shape, 'github-prefix'); + t('describeToken: legacy 40-hex recognised', describeToken('a'.repeat(40)).shape, 'legacy-40-hex'); + t('describeToken: proxy placeholder is unrecognized', describeToken('prox_abcdefghi').shape, 'unrecognized'); + t('describeToken: absent', describeToken('').present, false); + // Redaction: prefix + length only. The #7412 report form, and never the token. + t('describeToken: redacts to prefix + length', describeToken('ghp_secretsecret').redacted, 'ghp_… (len 16)'); + t( + 'a rendered verdict never contains the token body', + JSON.stringify(classifyTransportProbe({ token: 'prox_SECRETVALUE', authed: { status: 401 }, anon: { status: 200 } })).includes('SECRETVALUE'), + false, + ); + let failed = 0; for (const [name, actual, expected] of cases) { const ok = actual === expected; @@ -286,11 +807,34 @@ const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split if (isMain) { if (process.argv.includes('--self-test')) { selfTest(); + } else if (process.argv.includes('--probe')) { + // "Can live mode run HERE?" answered on its own, so a seat can find out + // without a sweep and without reading a raw HTTP status off a label page. + probeTransport().then((v) => { + if (!v) { + console.error('check-half-states: transport probe returned an unclassified result — run the sweep to see the raw failure.'); + process.exit(2); + } + if (v.kind !== 'reachable') reportPrerequisiteNotMet(v); + console.log(`✓ check-half-states: transport prerequisite met — ${v.headline}.`); + }); } else { sweep().catch((err) => { - // A sweep that could not run must not read as a clean board (#4690). - console.error(`check-half-states: sweep failed to run — ${err.message}`); - process.exit(2); + // The in-loop net. The pre-sweep probe answers the common case, but the + // transport can also fail mid-run (a quota exhausted by this very sweep, + // a credential revoked between pages), and those must report as the + // prerequisite they are rather than as an unexplained HTTP number. The + // probe is re-run rather than inferred from the status alone: a fresh + // reading is what distinguishes a real transport failure from a transient + // 5xx on one page, and it comes back `reachable` in the latter — which + // correctly falls through to the generic failure below. + const classify = err.status ? probeTransport() : Promise.resolve(null); + return classify.then((v) => { + if (v && v.kind !== 'reachable') reportPrerequisiteNotMet(v, { swept: err.sweptSoFar ?? 0 }); + // A sweep that could not run must not read as a clean board (#4690). + console.error(`check-half-states: sweep failed to run — ${err.message}`); + process.exit(2); + }); }); } }