Skip to content

Commit 0f42d36

Browse files
fix(scripts): check-issue-citations re-execs through the proxy instead of prescribing it (#19254)
Fixes #18949 ## What was wrong `scripts/check-issue-citations.mjs` imported `proxyRearmPlan` and used it only to compose a hint string. In a container whose only route out is `HTTPS_PROXY` — which node's `fetch` does not read — every board read answered HTTP 401, so a live run refused with `PREREQUISITE NOT MET` (exit 3) while holding, and printing, the plan that would have completed the read. Loud and correct, one re-exec short of an answer. ## What changed (one file: `scripts/check-issue-citations.mjs`) `rearmThroughProxy()` now EXECUTES that plan, on the shape `scripts/pm/post-stamped.mjs` and `scripts/pm/board-snapshot.mjs` have run daily: re-exec this file under `--use-env-proxy`, adopt the child's exit code, degrade loudly when the re-exec cannot happen. All three arms are kept and each is an answer: | arm | behaviour | |---|---| | `plan.hint` | print why the route cannot be re-armed, carry on, refuse below if the read fails | | `plan.rearm` | re-exec with the flag and adopt the child's exit code | | re-exec failed | continue in-process **and say** every request will bypass the proxy | The guard is **this gate's own** variable, `OS_ISSUE_CITATIONS_PROXY_REARMED` — never a shared or inherited name. A sibling instrument's guard answering "already re-armed" here is silent, and turns a route failure into a false story about the credential. The re-exec is taken by the live modes only: `--self-test` is offline (and pinned offline) and `--list` makes no request, so neither spawns a child to prove a route nothing is about to use. ## Evidence (this container) **Before** — `node scripts/check-issue-citations.mjs --census`, `exit=3`: ```text ❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read. GET https://api.github.com/repos/objectstack-ai/objectstack/issues?state=all&per_page=100&sort=created&direction=asc -> HTTP 401 (HTTPS_PROXY is set (http://127.0.0.1:36723) and node's fetch does not read it — re-run with `node --use-env-proxy …` or NODE_USE_ENV_PROXY=1) ⛔ Nothing below is a reading. … ``` **After** — same command, `exit=0`, the board is actually read: ```text ℹ️ re-exec with --use-env-proxy: HTTPS_PROXY is set (http://127.0.0.1:36723) and node's fetch does not read it. board: enumerated (175 pages), frontier #19252, 17411 numbers resolve citations judged: 34710 across 2405 file(s) 29805 resolves 2167 allocated-but-absent 1460 resolves-as-pull-request 1278 cross-repo-unjudged … ℹ️ census: 2167 unresolvable citation site(s). Report-only — this mode never fails. ``` ## "Cannot read" was NOT converted into "silently passes" Three genuinely unreadable runs, each still `exit=3` with `PREREQUISITE NOT MET`: ```text A OS_ISSUE_CITATIONS_PROXY_REARMED=1 (the guard already spent) -> exit 3 ℹ️ … the one re-exec this run allows was already spent … unsetting OS_ISSUE_CITATIONS_PROXY_REARMED is the fix rather than a new token ❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read. B HTTPS_PROXY=http://127.0.0.1:1 (re-exec happens, the child cannot reach out) -> exit 3 ℹ️ re-exec with --use-env-proxy: … ❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read. fetch failed C no proxy, no token at all -> exit 3 ❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read. GET …/issues?state=all… -> HTTP 403 ``` ⚠️ Note for whoever repeats this: a bare "no token" run is NOT unreadable in an agent container — this box's proxy carries the credential, so `--census` with `GITHUB_TOKEN`/`GH_TOKEN` unset still reads the board and exits 0. The three cases above are constructed so the read genuinely cannot happen. **The third arm, proven by ablation** (mutation confirmed on disk — anchor gone, marker present — then restored, `git diff HEAD` empty and the blob hash back to `e7a3516c5`): with `process.execPath` replaced by a path that does not exist, ```text ℹ️ re-exec with --use-env-proxy: … ⚠️ could not re-exec with --use-env-proxy (spawnSync /nonexistent/node-ABLATION ENOENT); continuing in-process — every request will bypass the proxy. ❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read. exit 3 ``` ## Tests A new `proxy-rearm` self-test battery (10 cases, floor pinned) covers the guard name against the patrol's, all three arms, the Actions-runner leg (no proxy ⇒ no re-exec, not one extra line), and structurally: the plan is imported rather than restated, the dispatch really hands it THIS file's guard, and the re-exec sits in the live-mode arm only. `--self-test` stays offline. ```text ✅ check-issue-citations --self-test: … (66 cases, 7 batteries) # was 56 cases, 6 batteries ``` All 26 commands from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (derived against this branch's own change set) exit 0, plus `pnpm check:issue-citations`. `npx eslint scripts/check-issue-citations.mjs --no-inline-config` exits 0. ## Declared gaps — ⛔ not fixed here, and not claimed to be - **CI wiring is untouched.** No workflow invokes this gate; that is #18224's lane and it is not addressed here. A local re-exec changes nothing in CI, and the CI wiring changes nothing locally. - `scripts/pm/check-half-states.mjs`, `scripts/pm/check-governed-merges.mjs` (`proxyRearmPlan` itself) and `scripts/pm/post-stamped.mjs` are untouched — other surfaces. - No changeset: this file ships in no package's `files[]` and the root manifest is `private: true`, so nothing released moves. The PR carries `skip-changeset`, which `.github/workflows/lint.yml` names as the textbook case for a CI-internal script. ## Acceptance notes - noted, not filed: the census now reads 2,167 `allocated-but-absent` sites against a frontier of #19252 (the card's own 2026-09-14 reading was 2,785 against #18219) — a standing, report-only debt this gate declares by design, not a finding. Owner of a follow-up: none identified. - noted, not filed: `OS_<TOOL>_PROXY_REARMED` is now spelled out by hand in six instruments (`post-stamped`, `board-snapshot`, `check-expected-skips`, `check-clause2-carriers`, `check-governed-merges`, and this gate); each correctly owns its name, so there is no defect, only a convention nothing enumerates. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb005e0 commit 0f42d36

1 file changed

Lines changed: 125 additions & 3 deletions

File tree

‎scripts/check-issue-citations.mjs‎

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,19 +140,53 @@
140140
*
141141
* Neither is installed here. That is a DECLARED gap, recorded in the PR body and
142142
* the report, not an oversight.
143+
*
144+
* ## The local route -- why this gate re-execs itself
145+
*
146+
* A seat runs the live modes from an agent container whose only way out is
147+
* `HTTPS_PROXY`, and node's `fetch` does not read that variable. Every board
148+
* read there answered HTTP 401 and the gate refused, correctly, with
149+
* `PREREQUISITE NOT MET` -- while holding, and printing, the very plan that
150+
* completes the read. `rearmThroughProxy` below now EXECUTES that plan instead
151+
* of prescribing it, on the shape `scripts/pm/post-stamped.mjs` and
152+
* `scripts/pm/board-snapshot.mjs` have run daily: re-exec this file under
153+
* `--use-env-proxy`, adopt the child's exit code, degrade loudly if the re-exec
154+
* cannot happen.
155+
*
156+
* ⛔ Two things it deliberately is not. It is not a relaxation: the child is
157+
* this same file, so a board that still cannot be read still exits with the
158+
* same `EXIT_PREREQUISITE_NOT_MET` refusal -- "cannot read" never
159+
* becomes "silently passes". And it is not taken by `--self-test` or `--list`,
160+
* which make no request; re-execing them would spawn a process to prove a route
161+
* nothing is about to use.
143162
*/
144163

145-
import { execFileSync } from 'node:child_process';
164+
import { execFileSync, spawnSync } from 'node:child_process';
146165
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
147166
import { tmpdir } from 'node:os';
148167
import { dirname, join } from 'node:path';
168+
import { fileURLToPath } from 'node:url';
149169

150170
import { gitFreeEnv } from './git-env.mjs';
151171
import { globToRegExp } from './glob-match.mjs';
152172
import { isEntrypoint } from './invoked-as.mjs';
153173
import { commentProse } from './symbol-anchors.mjs';
154174
import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan } from './pm/check-half-states.mjs';
155175

176+
/** This file, as the re-exec below has to name it on a child's argv. */
177+
const SELF_PATH = fileURLToPath(import.meta.url);
178+
179+
/**
180+
* THIS gate's OWN re-exec guard -- never a shared name, and never the
181+
* patrol's (#18939). A tool that reads a sibling instrument's guard out of
182+
* `process.env` is told "already re-armed" by a process that re-armed
183+
* something else; the un-re-armed run then bypasses the proxy and answers 401
184+
* Bad credentials, which is a false story about the credential rather than
185+
* about the route. The failure is SILENT, so the own name is the whole
186+
* defence and `--self-test` pins it.
187+
*/
188+
const PROXY_REARM_GUARD = 'OS_ISSUE_CITATIONS_PROXY_REARMED';
189+
156190
/**
157191
* POPULATION DECLARATION -- what `scripts/pm/dispatch-gates.mjs` is told this
158192
* gate reads. Provenance ONLY: nothing in this file reads this array; the
@@ -620,6 +654,52 @@ async function buildBoard({ rows, ownerRepo, token, strategy }) {
620654
return probeBoard(wanted, { ownerRepo, token });
621655
}
622656

657+
/**
658+
* Route this process's `fetch` through `HTTPS_PROXY` before asking the board
659+
* anything: node's fetch does not read that variable, so in a container that
660+
* only reaches the network through a proxy every request answers 401 and the
661+
* gate refuses with `EXIT_PREREQUISITE_NOT_MET` -- loud and correct, one
662+
* re-exec short of an answer. The PLAN is imported (one implementation, in
663+
* `check-governed-merges.mjs`); only the guard variable is this file's.
664+
*
665+
* Three arms, and each one is an answer:
666+
*
667+
* plan.hint the route cannot be re-armed (the one re-exec was spent, or
668+
* this node does not take the flag). SAY SO and carry on --
669+
* a refusal below may be about the route, not the credential.
670+
* plan.rearm re-exec with the flag and ADOPT the child's exit code, so
671+
* the verdict a caller reads is the re-armed run's verdict.
672+
* re-exec failed degrade in-process, and say plainly that every request
673+
* from here on bypasses the proxy.
674+
*
675+
* ⛔ It never converts a failed read into a pass: the child runs this same
676+
* file, so an unreadable board still reaches `prerequisiteRefusal` below.
677+
*
678+
* @returns {number|null} the exit code to adopt, or null to continue here.
679+
*/
680+
function rearmThroughProxy(args) {
681+
const plan = proxyRearmPlan({
682+
env: process.env,
683+
execArgv: process.execArgv,
684+
flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG),
685+
guard: PROXY_REARM_GUARD,
686+
});
687+
if (plan.hint) {
688+
console.error(`ℹ️ ${plan.reason}. A refusal below may be about the route, not this container.`);
689+
return null;
690+
}
691+
if (!plan.rearm) return null;
692+
console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`);
693+
const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : [];
694+
const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], {
695+
stdio: 'inherit',
696+
env: { ...process.env, [PROXY_REARM_GUARD]: '1' },
697+
});
698+
if (typeof child.status === 'number') return child.status;
699+
console.error(`⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — every request will bypass the proxy.`);
700+
return null;
701+
}
702+
623703
function prerequisiteRefusal(message) {
624704
console.error('❌ check-issue-citations: PREREQUISITE NOT MET — the board was not read.');
625705
console.error(` ${message}`);
@@ -646,8 +726,18 @@ export async function run({ root = process.cwd(), scope = 'diff', base = null, j
646726
try {
647727
board = await buildBoard({ rows, ownerRepo, token, strategy });
648728
} catch (err) {
649-
const plan = proxyRearmPlan({ env: process.env, execArgv: process.execArgv });
650-
const route = plan.rearm ? ` (${plan.reason} — re-run with \`node ${PROXY_FLAG} …\` or NODE_USE_ENV_PROXY=1)` : '';
729+
/* The route is re-armed at the dispatch, BEFORE this read, so on any run
730+
* that could re-arm `plan.rearm` is already false here. What is left to say
731+
* is why it could not: the plan's `hint` arm names the spent guard or the
732+
* node that will not take the flag, and that sentence is the difference
733+
* between "the credential is bad" and "the request never left the box". */
734+
const plan = proxyRearmPlan({
735+
env: process.env,
736+
execArgv: process.execArgv,
737+
flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG),
738+
guard: PROXY_REARM_GUARD,
739+
});
740+
const route = plan.rearm || plan.hint ? ` (${plan.reason} — re-run with \`node ${PROXY_FLAG} …\` or NODE_USE_ENV_PROXY=1)` : '';
651741
prerequisiteRefusal(`${err.message}${route}`);
652742
}
653743

@@ -722,6 +812,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
722812
'scope-contract': 12,
723813
'diff-scope': 6,
724814
'live-corpus': 3,
815+
'proxy-rearm': 10,
725816
});
726817

727818
/** Deleting a roster entry silences its floor, so the roster's size is pinned too. */
@@ -920,6 +1011,35 @@ export async function selfTest() {
9201011
check(live.rows.some((r) => r.qualifier), 'the live corpus must contain at least one cross-repo citation — the arm that must never be reported as a phantom');
9211012
}
9221013

1014+
/* 7. THE LOCAL ROUTE. Offline, over the imported plan: the arms that decide
1015+
* whether a live run re-execs, and the guard-name mix-up whose only
1016+
* symptom is a 401 blamed on the token. ⛔ Nothing here re-execs anything —
1017+
* a self-test that spawned a child would be a self-test with a network. */
1018+
battery('proxy-rearm');
1019+
{
1020+
const PATROL_GUARD = 'OS_HALF_STATES_PROXY_REARMED';
1021+
const proxied = { HTTPS_PROXY: 'http://127.0.0.1:1' };
1022+
const rearm = (env) => proxyRearmPlan({ env, guard: PROXY_REARM_GUARD, flagSupported: true });
1023+
const own = { ...proxied, [PROXY_REARM_GUARD]: '1' };
1024+
const ownSource = readFileSync(SELF_PATH, 'utf8');
1025+
check(PROXY_REARM_GUARD !== PATROL_GUARD, "this gate's guard must be its own name, never the patrol's");
1026+
check(proxyRearmPlan({ env: { ...proxied, [PATROL_GUARD]: '1' } }).guarded === PATROL_GUARD,
1027+
"…and the patrol name pinned here IS the plan's default, so a rename reds this battery rather than passing it");
1028+
check(rearm(proxied).rearm === true && rearm(proxied).flag === PROXY_FLAG,
1029+
'a proxied container with this guard unset must RE-EXEC — advising the operator is what this card was filed about');
1030+
check(rearm({ ...proxied, [PATROL_GUARD]: '1' }).rearm === true,
1031+
"a sibling instrument's inherited guard must NOT answer for this one — that silence is the 401-blamed-on-the-token failure");
1032+
check(rearm(own).rearm === false && rearm(own).hint === true, 'having re-armed once, this gate must not loop');
1033+
check(rearm(own).reason.includes(PROXY_REARM_GUARD), '…and must name the variable a reader has to unset');
1034+
check(rearm({}).rearm === false && rearm({}).hint === false,
1035+
'the Actions-runner leg is unchanged: no proxy, no re-exec, not one extra line of output');
1036+
check(/\n\s+guard: PROXY_REARM_GUARD,\n/.test(ownSource), "structural: the dispatch really hands the plan THIS file's guard");
1037+
check(/\bproxyRearmPlan\b/.test(ownSource) && !/function\s+proxyRearmPlan\b/.test(ownSource),
1038+
'structural: the plan is imported, not restated here');
1039+
check(/\} else if \(flag\('--list'\)\) list\(\);\n\s*else \{\n\s+const rearmed = rearmThroughProxy\(argv\);/.test(ownSource),
1040+
'structural: the re-exec is taken by the LIVE modes only — never --self-test (offline, and pinned offline), never --list (no network at all)');
1041+
}
1042+
9231043
/* ── The floor: every declared battery RAN, and ran its cases ───────────── */
9241044
const floorMessages = [];
9251045
const floorFailure = (m) => { floorMessages.push(m); };
@@ -974,6 +1094,8 @@ if (isEntrypoint(import.meta.url)) {
9741094
}
9751095
} else if (flag('--list')) list();
9761096
else {
1097+
const rearmed = rearmThroughProxy(argv);
1098+
if (rearmed !== null) process.exit(rearmed);
9771099
process.exit(await run({
9781100
scope: flag('--census') ? 'census' : 'diff',
9791101
base: value('--base'),

0 commit comments

Comments
 (0)