|
145 | 145 | * names a tree that does not exist, so a hint on one can never match anything. |
146 | 146 | */ |
147 | 147 |
|
| 148 | +import { spawnSync } from 'node:child_process'; |
148 | 149 | import { existsSync, readFileSync } from 'node:fs'; |
149 | 150 | import { join } from 'node:path'; |
150 | 151 | import process from 'node:process'; |
| 152 | +import { fileURLToPath } from 'node:url'; |
151 | 153 | import { isEntrypoint } from './invoked-as.mjs'; |
| 154 | +import { maskCommentsAndLiterals } from './js-comment-mask.mjs'; |
| 155 | +// ⛔ Not copied. The proxy-rearm PLAN is ONE source for every instrument in this |
| 156 | +// tree, so this gate and the sweeps can never disagree about whether this |
| 157 | +// container's fetch reaches GitHub at all. Only the guard variable below is |
| 158 | +// this file's own, and the block above `rearmThroughProxy` says why. |
| 159 | +import { PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './pm/check-half-states.mjs'; |
152 | 160 |
|
153 | 161 | // ── The self-test's own battery roster and floor (#13489) ────────────────── |
154 | 162 | // |
@@ -182,11 +190,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ |
182 | 190 | 'UNDETERMINED is its own answer. It must never read as clean, and it': 5, |
183 | 191 | 'Wiring absent: never clean, never an accusation.': 16, |
184 | 192 | 'The short-circuit. This is the property that makes the gate affordable,': 22, |
| 193 | + 'The route to GitHub. A 401 from a bypassed proxy reads as a dead': 9, |
185 | 194 | }); |
186 | 195 |
|
187 | 196 | // DELETING an entry silences that battery's floor exactly as effectively as |
188 | 197 | // zeroing it, so the roster's own size is pinned too. |
189 | | -const SELF_TEST_BATTERY_FLOOR = 7; |
| 198 | +const SELF_TEST_BATTERY_FLOOR = 8; |
190 | 199 |
|
191 | 200 | // The key an assertion is filed under when no battery is open. It is not a |
192 | 201 | // declared battery, so it reds by the same set difference rather than silently |
@@ -262,6 +271,9 @@ function batteryFloorFailures() { |
262 | 271 |
|
263 | 272 | const ROOT = new URL('..', import.meta.url).pathname; |
264 | 273 |
|
| 274 | +/** This file, resolved for the proxy re-exec and for the self-test's own structural reads. */ |
| 275 | +const SELF_PATH = fileURLToPath(import.meta.url); |
| 276 | + |
265 | 277 | /** The wiring that gives this gate a PR to judge. */ |
266 | 278 | const WIRING_WORKFLOW = '.github/workflows/single-claim-path-guard.yml'; |
267 | 279 |
|
@@ -532,6 +544,84 @@ const githubApi = (token) => async (path) => { |
532 | 544 | return response.json(); |
533 | 545 | }; |
534 | 546 |
|
| 547 | +// --------------------------------------------------------------------------- |
| 548 | +// The route to GitHub (#18314) — why this gate re-execs itself. |
| 549 | +// |
| 550 | +// Every real run reads a PR's file list over the network, and in an agent |
| 551 | +// container that network is reachable only through the session proxy. Node's |
| 552 | +// `fetch` does not read the proxy variables, so an unrouted run left WITHOUT |
| 553 | +// its credential and GitHub answered 401. That 401 has three readings available |
| 554 | +// to whoever sees it — the token is wrong, the PR does not exist, the repo is |
| 555 | +// wrong — and none of them is the true one, so a seat that tries to pre-run the |
| 556 | +// gate its own PR will be judged by records NOT MEASURED where a real reading |
| 557 | +// was one flag away. |
| 558 | +// |
| 559 | +// The switch is read at process START, which is the whole reason this is a |
| 560 | +// re-exec rather than an assignment. |
| 561 | +// |
| 562 | +// The DECISION is imported, not restated: one spelling of "go through the |
| 563 | +// proxy" for the whole tree. What is local is the guard VARIABLE, deliberately. |
| 564 | +// Sharing a sibling's guard would let that sibling's re-exec suppress this |
| 565 | +// one's — a grandchild is spawned without the flag in its argv, so it needs the |
| 566 | +// re-exec even though the guard the parent set says one already happened. |
| 567 | +// |
| 568 | +// ⭐ On a GitHub Actions runner there is no proxy in the environment, so the |
| 569 | +// plan never re-arms and this gate behaves exactly as it did before. The fix is |
| 570 | +// inert in CI, which is all this card ever claimed about CI. |
| 571 | +// --------------------------------------------------------------------------- |
| 572 | + |
| 573 | +/** This file's OWN re-exec guard — deliberately not any sibling's. */ |
| 574 | +export const OWN_PROXY_REARM_GUARD = 'OS_SINGLE_CLAIM_PATHS_PROXY_REARMED'; |
| 575 | + |
| 576 | +/** |
| 577 | + * The environment the imported plan is asked about: this file's own guard, |
| 578 | + * presented under the name the plan reads. Pure, so the self-test drives every |
| 579 | + * branch offline — and the branch worth pinning is a SIBLING's guard being set, |
| 580 | + * which must NOT stop this run from re-arming. |
| 581 | + */ |
| 582 | +export function proxyPlanEnv(env) { |
| 583 | + return { ...env, [PROXY_REARM_GUARD]: env[OWN_PROXY_REARM_GUARD] }; |
| 584 | +} |
| 585 | + |
| 586 | +/** |
| 587 | + * Hand this run off through the proxy, or `null` to carry on in-process. |
| 588 | + * |
| 589 | + * A returned number is the child's exit status, forwarded VERBATIM: the three |
| 590 | + * exit codes above are this gate's contract with CI, so the hand-off has to be |
| 591 | + * invisible in them. |
| 592 | + * |
| 593 | + * Everything printed here goes to STDERR. The clean verdict is this script's |
| 594 | + * only stdout, and a status line there would land inside whatever reads it. |
| 595 | + * |
| 596 | + * @param {string[]} args this run's own argv tail, forwarded unchanged |
| 597 | + */ |
| 598 | +function rearmThroughProxy(args) { |
| 599 | + const plan = proxyRearmPlan({ |
| 600 | + env: proxyPlanEnv(process.env), |
| 601 | + execArgv: process.execArgv, |
| 602 | + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), |
| 603 | + }); |
| 604 | + if (plan.hint) { |
| 605 | + console.error(`ℹ️ ${plan.reason}. A refusal below may be about the route, not this container.`); |
| 606 | + return null; |
| 607 | + } |
| 608 | + if (!plan.rearm) return null; |
| 609 | + console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`); |
| 610 | + // The env proxy agent is experimental and says so once per run. Nobody can |
| 611 | + // act on that notice, so silence it where the node in use can. |
| 612 | + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; |
| 613 | + const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], { |
| 614 | + stdio: 'inherit', |
| 615 | + env: { ...process.env, [OWN_PROXY_REARM_GUARD]: '1' }, |
| 616 | + }); |
| 617 | + if (typeof child.status === 'number') return child.status; |
| 618 | + console.error( |
| 619 | + `⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); ` + |
| 620 | + 'continuing in-process — every request will bypass the proxy.', |
| 621 | + ); |
| 622 | + return null; |
| 623 | +} |
| 624 | + |
535 | 625 | // --------------------------------------------------------------------------- |
536 | 626 | // Self-test — the verdict layer, the exit-code contract, the declared list's |
537 | 627 | // own invariants, the short-circuit that makes this affordable, and the wiring. |
@@ -676,6 +766,30 @@ function selfTest() { |
676 | 766 | { number: '16326', repo: 'o/r', token: 't' }, |
677 | 767 | ); |
678 | 768 |
|
| 769 | + // --- The route to GitHub. A 401 from a bypassed proxy reads as a dead |
| 770 | + // credential, so both legs are pinned rather than one: the CI leg must stay |
| 771 | + // untouched (no proxy in the environment, no re-exec, no behaviour change at |
| 772 | + // all), and a container must re-arm EXACTLY once. The fixture proxy names no |
| 773 | + // tree in any repo, per this file's header rule on quoted literals. |
| 774 | + battery('The route to GitHub. A 401 from a bypassed proxy reads as a dead'); |
| 775 | + const proxied = { HTTPS_PROXY: 'http://127.0.0.1:41733' }; |
| 776 | + t('the Actions-runner leg: no proxy in the env, so no re-exec and nothing changes in CI', proxyRearmPlan({ env: proxyPlanEnv({}) }).rearm, false); |
| 777 | + t('an agent container re-arms, which is the whole of the fix', proxyRearmPlan({ env: proxyPlanEnv(proxied), flagSupported: true }).rearm, true); |
| 778 | + t("...exactly once — this run's OWN guard is what stops the loop", proxyRearmPlan({ env: proxyPlanEnv({ ...proxied, [OWN_PROXY_REARM_GUARD]: '1' }), flagSupported: true }).rearm, false); |
| 779 | + t("...and a SIBLING instrument's guard does NOT suppress it", proxyRearmPlan({ env: proxyPlanEnv({ ...proxied, [PROXY_REARM_GUARD]: '1' }), flagSupported: true }).rearm, true); |
| 780 | + t("this file's guard is not the imported one, which is what makes that hold", OWN_PROXY_REARM_GUARD === PROXY_REARM_GUARD, false); |
| 781 | + t('a run already routed through the proxy does not re-arm again', proxyRearmPlan({ env: proxyPlanEnv(proxied), execArgv: [PROXY_FLAG], flagSupported: true }).rearm, false); |
| 782 | + const unsupported = proxyRearmPlan({ env: proxyPlanEnv(proxied), flagSupported: false }); |
| 783 | + t('a node that cannot take the flag SAYS so rather than bypassing silently', [unsupported.rearm, unsupported.hint], [false, true]); |
| 784 | + const ownSource = maskCommentsAndLiterals(readFileSync(SELF_PATH, 'utf8')); |
| 785 | + t('structural: the plan is imported, not restated here', /\bproxyRearmPlan\b/.test(ownSource) && !/function\s+proxyRearmPlan\b/.test(ownSource), true); |
| 786 | + // Both halves, because the ablation that planned this case found the |
| 787 | + // ordering alone vacuous: with the CALL deleted, the last occurrence is the |
| 788 | + // DECLARATION, which sits above the collection and satisfied the comparison |
| 789 | + // with no hand-off left in the file at all. |
| 790 | + const rearmSites = ownSource.split('rearmThroughProxy(').length - 1; |
| 791 | + t('structural: the hand-off is CALLED exactly once, and decided BEFORE the first network read', [rearmSites, ownSource.lastIndexOf('rearmThroughProxy(') < ownSource.lastIndexOf('await collect(')], [2, true]); |
| 792 | + |
679 | 793 | // --- The short-circuit. This is the property that makes the gate affordable, |
680 | 794 | // and it is invisible in the verdict layer, so it is pinned here against a |
681 | 795 | // recording fake API. Fixture paths name a tree that exists in no repo. |
@@ -777,6 +891,13 @@ if (isMain) { |
777 | 891 | } |
778 | 892 | } else { |
779 | 893 | const ctx = readPrContext(process.env); |
| 894 | + // Only a run with a usable context reaches the network, so only that run |
| 895 | + // needs the route. Re-execing a NOT WIRED run would spend a process to |
| 896 | + // reprint the identical wiring verdict. |
| 897 | + if (ctx !== null && ctx.wired !== false) { |
| 898 | + const handed = rearmThroughProxy(process.argv.slice(2)); |
| 899 | + if (handed !== null) process.exit(handed); |
| 900 | + } |
780 | 901 | const resolved = ctx === null || ctx.wired === false ? ctx : await collect(ctx, githubApi(ctx.token)); |
781 | 902 | const result = judge(resolved); |
782 | 903 | const emit = result.exit === EXIT_CLEAN ? console.log : console.error; |
|
0 commit comments