|
8 | 8 | * node scripts/check-single-claim-paths.mjs # judge this PR (CI) |
9 | 9 | * node scripts/check-single-claim-paths.mjs --self-test # verify it offline |
10 | 10 | * |
| 11 | + * A live run re-execs itself once through the session proxy (transport, below). |
| 12 | + * |
11 | 13 | * ⚠️ Repo paths are named UNQUOTED in this header on purpose, and the self-test |
12 | 14 | * fixtures below use paths that exist in no repo. Both are load-bearing; the |
13 | 15 | * last section carries the measurement that forces them. |
|
145 | 147 | * names a tree that does not exist, so a hint on one can never match anything. |
146 | 148 | */ |
147 | 149 |
|
| 150 | +import { spawnSync } from 'node:child_process'; |
148 | 151 | import { existsSync, readFileSync } from 'node:fs'; |
149 | 152 | import { join } from 'node:path'; |
150 | 153 | import process from 'node:process'; |
| 154 | +import { fileURLToPath } from 'node:url'; |
151 | 155 | import { isEntrypoint } from './invoked-as.mjs'; |
| 156 | +// ⛔ Not copied. The proxy-rearm plan is ONE source in the half-states patrol, |
| 157 | +// the same import post-stamped.mjs and check-prior-rulings.mjs take, so this gate |
| 158 | +// and they cannot come to disagree about whether this container's fetch reaches |
| 159 | +// the API. Only the guard variable below is this file's. |
| 160 | +import { PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './pm/check-half-states.mjs'; |
152 | 161 |
|
153 | 162 | // ── The self-test's own battery roster and floor (#13489) ────────────────── |
154 | 163 | // |
@@ -181,12 +190,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ |
181 | 190 | 'The failure has to carry the remedy, not just the verdict.': 3, |
182 | 191 | 'UNDETERMINED is its own answer. It must never read as clean, and it': 5, |
183 | 192 | 'Wiring absent: never clean, never an accusation.': 16, |
| 193 | + 'The transport route: the decision, and the two runs that take none.': 18, |
184 | 194 | 'The short-circuit. This is the property that makes the gate affordable,': 22, |
185 | 195 | }); |
186 | 196 |
|
187 | 197 | // DELETING an entry silences that battery's floor exactly as effectively as |
188 | 198 | // zeroing it, so the roster's own size is pinned too. |
189 | | -const SELF_TEST_BATTERY_FLOOR = 7; |
| 199 | +const SELF_TEST_BATTERY_FLOOR = 8; |
190 | 200 |
|
191 | 201 | // The key an assertion is filed under when no battery is open. It is not a |
192 | 202 | // declared battery, so it reds by the same set difference rather than silently |
@@ -520,6 +530,94 @@ export async function collect(ctx, api) { |
520 | 530 | return { ...ctx, claimed, others, undetermined }; |
521 | 531 | } |
522 | 532 |
|
| 533 | +// --------------------------------------------------------------------------- |
| 534 | +// Transport — routing this process's `fetch` at the session proxy before it |
| 535 | +// asks GitHub anything. |
| 536 | +// |
| 537 | +// MEASURED in an agent container: `HTTPS_PROXY` is set, that proxy injects the |
| 538 | +// real credential, the token variables hold a placeholder, and node's `fetch` |
| 539 | +// does not read `HTTPS_PROXY` — so an unrouted read sends the placeholder and |
| 540 | +// the first call throws `GitHub API 401`. `curl` on the same container answers |
| 541 | +// 200, which is what says the failure is a ROUTE and not a credential anyone |
| 542 | +// could go and fix. Until this shim, that was the whole local story of this |
| 543 | +// gate: its verdict could be taken in CI only, and a dev running the derived |
| 544 | +// gate list in a container recorded it NOT MEASURED rather than a verdict. |
| 545 | +// |
| 546 | +// ⛔ Nothing about the verdict changes here. A transport error on a bypassed |
| 547 | +// route keeps the class it had before, no exit code is added, and the |
| 548 | +// EXIT_NOT_WIRED routing is untouched — a run with no usable PR context reads |
| 549 | +// nothing, so it re-execs nothing either. |
| 550 | + |
| 551 | +/** This file, resolved for the re-exec below. */ |
| 552 | +const SELF_PATH = fileURLToPath(import.meta.url); |
| 553 | + |
| 554 | +/** |
| 555 | + * This file's OWN re-exec guard, deliberately not the patrol's: one shared |
| 556 | + * variable would let a re-exec of another instrument suppress the re-exec of |
| 557 | + * this one, and the symptom would be the silent 401 this exists to close. |
| 558 | + */ |
| 559 | +export const OWN_PROXY_REARM_GUARD = 'OS_SINGLE_CLAIM_PATHS_PROXY_REARMED'; |
| 560 | + |
| 561 | +/** |
| 562 | + * Is a network read imminent? Only then is a route worth re-arming. Pure, so |
| 563 | + * every branch is pinned offline with no proxy present. |
| 564 | + * |
| 565 | + * Two runs read nothing and must therefore spawn nothing: `--self-test`, which |
| 566 | + * is offline by contract, and a run whose PR context is absent or incomplete, |
| 567 | + * which exits EXIT_NOT_WIRED having made no request. Re-execing either would |
| 568 | + * spawn a child to prove a route nothing is about to use, and would print an |
| 569 | + * informational line in front of the NOT WIRED text a reader is there to read. |
| 570 | + * |
| 571 | + * @param {{ argv?: string[], ctx?: null | { wired?: boolean } }} [run] |
| 572 | + */ |
| 573 | +export function transportRouteApplies({ argv = [], ctx = null } = {}) { |
| 574 | + if (argv.includes('--self-test')) return false; |
| 575 | + return ctx !== null && ctx.wired !== false; |
| 576 | +} |
| 577 | + |
| 578 | +/** |
| 579 | + * The re-exec decision for this file: the shared plan, read through this file's |
| 580 | + * own guard name. Pure — env, execArgv and flag support in, decision out. |
| 581 | + */ |
| 582 | +export function proxyRearmDecision({ env = {}, execArgv = [], flagSupported = true } = {}) { |
| 583 | + return proxyRearmPlan({ |
| 584 | + // Map this file's guard onto the name the shared plan reads, so the logic |
| 585 | + // stays single-sourced while the guards stay independent. |
| 586 | + env: { ...env, [PROXY_REARM_GUARD]: env[OWN_PROXY_REARM_GUARD] }, |
| 587 | + execArgv, |
| 588 | + flagSupported, |
| 589 | + }); |
| 590 | +} |
| 591 | + |
| 592 | +/** |
| 593 | + * Re-exec ONCE with the proxy flag — argv, env and stdio forwarded — and return |
| 594 | + * the child's exit code, or `null` when this run carries on in-process. |
| 595 | + */ |
| 596 | +function rearmThroughProxy(args) { |
| 597 | + const plan = proxyRearmDecision({ |
| 598 | + env: process.env, |
| 599 | + execArgv: process.execArgv, |
| 600 | + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), |
| 601 | + }); |
| 602 | + if (plan.hint) { |
| 603 | + console.error(`ℹ️ ${plan.reason}. A failure below may be about the route, not this container.`); |
| 604 | + return null; |
| 605 | + } |
| 606 | + if (!plan.rearm) return null; |
| 607 | + console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`); |
| 608 | + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; |
| 609 | + const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], { |
| 610 | + stdio: 'inherit', |
| 611 | + env: { ...process.env, [OWN_PROXY_REARM_GUARD]: '1' }, |
| 612 | + }); |
| 613 | + if (typeof child.status === 'number') return child.status; |
| 614 | + console.error( |
| 615 | + `⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); ` |
| 616 | + + 'continuing in-process — every request will bypass the proxy.', |
| 617 | + ); |
| 618 | + return null; |
| 619 | +} |
| 620 | + |
523 | 621 | const githubApi = (token) => async (path) => { |
524 | 622 | const response = await fetch(`https://api.github.com${path}`, { |
525 | 623 | headers: { |
@@ -676,6 +774,32 @@ function selfTest() { |
676 | 774 | { number: '16326', repo: 'o/r', token: 't' }, |
677 | 775 | ); |
678 | 776 |
|
| 777 | + // --- The transport route. The re-exec is what makes this gate readable |
| 778 | + // outside CI, and it is invisible in the verdict layer, so the DECISION is |
| 779 | + // what is pinned — offline, with no proxy present and no request made. |
| 780 | + battery('The transport route: the decision, and the two runs that take none.'); |
| 781 | + t('a configured proxy routes this run', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://127.0.0.1:45311' } }).rearm, true); |
| 782 | + t('...with the flag node only reads at process start', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x' } }).flag, PROXY_FLAG); |
| 783 | + t('...and a reason naming the variable, for a reader of the run log', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x' } }).reason.includes('HTTPS_PROXY'), true); |
| 784 | + t('the lowercase spelling counts too', proxyRearmDecision({ env: { https_proxy: 'http://x' } }).rearm, true); |
| 785 | + t('NO proxy in the environment takes no route (the CI runner leg, unchanged)', proxyRearmDecision({ env: {} }).rearm, false); |
| 786 | + t('...and it says why it took none', proxyRearmDecision({ env: {} }).reason.includes('directly'), true); |
| 787 | + t('the flag already in execArgv does not route a second time', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x' }, execArgv: [PROXY_FLAG] }).rearm, false); |
| 788 | + t('...nor the same flag in NODE_OPTIONS', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x', NODE_OPTIONS: `--enable-source-maps ${PROXY_FLAG}` } }).rearm, false); |
| 789 | + t('...nor the env spelling of that switch', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x', NODE_USE_ENV_PROXY: '1' } }).rearm, false); |
| 790 | + t('this file\'s own guard stops a re-exec loop', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x', [OWN_PROXY_REARM_GUARD]: '1' } }).rearm, false); |
| 791 | + t('...and that guard is this file\'s, not the patrol\'s', OWN_PROXY_REARM_GUARD === PROXY_REARM_GUARD, false); |
| 792 | + t('the patrol\'s own guard does not suppress this file\'s re-exec', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x', [PROXY_REARM_GUARD]: '1' } }).rearm, true); |
| 793 | + t('a node that will not take the flag hints instead of re-execing', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x' }, flagSupported: false }).hint, true); |
| 794 | + t('...and re-execs nothing', proxyRearmDecision({ env: { HTTPS_PROXY: 'http://x' }, flagSupported: false }).rearm, false); |
| 795 | + |
| 796 | + // WHERE the decision is taken: only by a run that is about to read the API. |
| 797 | + const liveCtx = readPrContext({ PR_NUMBER: '18844', GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 't' }); |
| 798 | + t('a wired live run takes the routing decision', transportRouteApplies({ argv: [], ctx: liveCtx }), true); |
| 799 | + t('the offline self-test never takes it', transportRouteApplies({ argv: ['--self-test'], ctx: liveCtx }), false); |
| 800 | + t('no PR context at all reads nothing, so it routes nothing', transportRouteApplies({ argv: [], ctx: readPrContext({}) }), false); |
| 801 | + t('an incomplete context (NOT WIRED) routes nothing either', transportRouteApplies({ argv: [], ctx: readPrContext({ PR_NUMBER: '1' }) }), false); |
| 802 | + |
679 | 803 | // --- The short-circuit. This is the property that makes the gate affordable, |
680 | 804 | // and it is invisible in the verdict layer, so it is pinned here against a |
681 | 805 | // recording fake API. Fixture paths name a tree that exists in no repo. |
@@ -777,6 +901,13 @@ if (isMain) { |
777 | 901 | } |
778 | 902 | } else { |
779 | 903 | const ctx = readPrContext(process.env); |
| 904 | + // Transport before the questions that need it: unrouted, the first read |
| 905 | + // answers 401 in a container where the proxy holds the credential. Placed |
| 906 | + // AFTER the context read so a NOT WIRED run never pays for a child. |
| 907 | + if (transportRouteApplies({ argv: process.argv, ctx })) { |
| 908 | + const rearmed = rearmThroughProxy(process.argv.slice(2)); |
| 909 | + if (rearmed !== null) process.exit(rearmed); |
| 910 | + } |
780 | 911 | const resolved = ctx === null || ctx.wired === false ? ctx : await collect(ctx, githubApi(ctx.token)); |
781 | 912 | const result = judge(resolved); |
782 | 913 | const emit = result.exit === EXIT_CLEAN ? console.log : console.error; |
|
0 commit comments