Skip to content

Commit b453a30

Browse files
committed
wip(pm): route check-half-states through the container proxy (#13526 leg 1)
1 parent 889ec5b commit b453a30

1 file changed

Lines changed: 152 additions & 2 deletions

File tree

‎scripts/pm/check-half-states.mjs‎

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,8 @@
802802
*/
803803

804804
import process from 'node:process';
805-
import { execFileSync } from 'node:child_process';
805+
import { execFileSync, spawnSync } from 'node:child_process';
806+
import { fileURLToPath } from 'node:url';
806807
import { isEntrypoint } from '../invoked-as.mjs';
807808

808809
/**
@@ -7151,6 +7152,120 @@ export function parseOutputOptions(argv) {
71517152
return out;
71527153
}
71537154

7155+
// ---------------------------------------------------------------------------
7156+
// The EGRESS PREREQUISITE — why this gate could not run in an agent container,
7157+
// and the one line of plumbing that was actually missing (#13526).
7158+
//
7159+
// ## The finding, and why it read as a credential problem for months
7160+
//
7161+
// This file's standing diagnosis of an agent container was "no usable
7162+
// credential": `GITHUB_TOKEN` holds the proxy's 14-character `prox…`
7163+
// placeholder, which 401s, and the anonymous fallback is a shared-NAT quota
7164+
// that neighbours have already spent — so `classifyTransportProbe` returned
7165+
// `bad-credential` and the run exited 3. Every word of that verdict was TRUE
7166+
// and the conclusion drawn from it was wrong, because it measured the wrong
7167+
// transport.
7168+
//
7169+
// Measured 2026-08-31 in an agent container, same container, same second:
7170+
//
7171+
// node fetch('https://api.github.com/rate_limit') -> 401 / anon 0 left
7172+
// curl https://api.github.com/rate_limit -> 200, limit 15000
7173+
// curl https://api.github.com/repos/objectstack-ai/objectstack -> 200
7174+
//
7175+
// The container has FULL authenticated repo-scoped REST access. It is reached
7176+
// through `HTTPS_PROXY`, which injects the credential — and **Node's global
7177+
// `fetch` does not honour `HTTPS_PROXY`**. So this script egressed directly,
7178+
// arrived unauthenticated on the shared NAT IP, and correctly diagnosed the
7179+
// only transport it had ever tried.
7180+
//
7181+
// ⭐ The knowledge was already in this file and never acted on: the
7182+
// `host-unreachable` verdict says, in as many words, "Node's fetch does not use
7183+
// HTTPS_PROXY, so this says nothing about `curl`, `gh` or the `mcp__github__*`
7184+
// tools". It was written as a caveat on a failure message instead of as the
7185+
// cause, and the population the gate guards grew unobserved behind it — the
7186+
// #13526 filing's whole point: **a gate nobody can run is indistinguishable
7187+
// from a gate that finds nothing.**
7188+
//
7189+
// ## The fix, and why it is a RE-EXEC rather than a dispatcher call
7190+
//
7191+
// Node 22 can route `fetch` through the environment's proxy, but only via
7192+
// `NODE_USE_ENV_PROXY=1`, which is read at BOOTSTRAP — after this module runs
7193+
// there is no supported way to set it, and `undici` is not resolvable here (no
7194+
// dependency in `scripts/`). So the process re-executes itself ONCE with the
7195+
// flag set. The decision is a pure function the self-test drives; the exec is
7196+
// three lines in the entrypoint.
7197+
//
7198+
// ⚠️ The properties that make this safe, each deliberate:
7199+
//
7200+
// - **CI is byte-identical.** A GitHub Actions runner sets no proxy variable,
7201+
// so `resolveProxyRelaunch` returns `relaunch: false` and the scheduled
7202+
// patrol's every request path is unchanged. This adds a path for the
7203+
// container that could not run; it does not alter the one that could.
7204+
// - **Exactly once.** The child carries `PM_SWEEP_PROXY_RELAUNCHED`, so a
7205+
// misconfigured proxy cannot produce a fork bomb — the child runs with
7206+
// whatever transport it has and reports its own verdict.
7207+
// - **`--self-test` never relaunches.** It makes no request and must stay
7208+
// runnable in any container whatever the environment carries — the same
7209+
// exemption the malformed-repo and malformed-floor refusals already make.
7210+
// - **An unsupported flag is not passed.** `--disable-warning` (which mutes
7211+
// undici's experimental `EnvHttpProxyAgent` notice) is Node 21.3+; on an
7212+
// older runtime passing it would make node refuse to start, turning a
7213+
// degraded reading into no reading at all. It is gated on
7214+
// `process.allowedNodeEnvironmentFlags`, and an older Node that ignores
7215+
// `NODE_USE_ENV_PROXY` simply lands back on today's honest exit 3.
7216+
// ---------------------------------------------------------------------------
7217+
7218+
/** The guard the child carries, so a relaunch can happen at most once. */
7219+
export const PROXY_RELAUNCH_GUARD = 'PM_SWEEP_PROXY_RELAUNCHED';
7220+
7221+
/**
7222+
* The proxy variables Node's `EnvHttpProxyAgent` itself reads, in its own
7223+
* precedence order. Listed rather than probed so the decision stays pure, and
7224+
* matched case-insensitively in BOTH spellings because the lowercase form is
7225+
* the one most container images actually export.
7226+
*/
7227+
export const PROXY_ENV_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'];
7228+
7229+
/**
7230+
* Should this process re-exec itself with `NODE_USE_ENV_PROXY=1`?
7231+
*
7232+
* Pure, so the self-test pins every branch — including the two that must NEVER
7233+
* relaunch (a CI runner with no proxy, and a child that already did).
7234+
*
7235+
* @param {Record<string,string|undefined>} env
7236+
* @param {string[]} argv — the argv TAIL (`process.argv.slice(2)`).
7237+
* @returns {{ relaunch: boolean, reason: string, proxy: string|null }}
7238+
*/
7239+
export function resolveProxyRelaunch(env = {}, argv = []) {
7240+
const proxyVar = PROXY_ENV_VARS.find((name) => String(env?.[name] ?? '').trim().length > 0);
7241+
const proxy = proxyVar ? String(env[proxyVar]).trim() : null;
7242+
if (argv.includes('--self-test')) {
7243+
return { relaunch: false, reason: 'self-test makes no request and stays runnable anywhere', proxy };
7244+
}
7245+
if (String(env?.[PROXY_RELAUNCH_GUARD] ?? '') === '1') {
7246+
return { relaunch: false, reason: 'already relaunched once; this IS the child', proxy };
7247+
}
7248+
if (String(env?.NODE_USE_ENV_PROXY ?? '') === '1') {
7249+
return { relaunch: false, reason: 'the caller already set NODE_USE_ENV_PROXY', proxy };
7250+
}
7251+
if (!proxy) {
7252+
return { relaunch: false, reason: 'no proxy in the environment (a CI runner: direct egress)', proxy: null };
7253+
}
7254+
return { relaunch: true, reason: `${proxyVar} is set and node's fetch ignores it`, proxy };
7255+
}
7256+
7257+
/**
7258+
* The child's `execArgv`. Separated from the decision because it depends on the
7259+
* RUNTIME's flag support rather than on the environment, and because passing an
7260+
* unsupported flag is the one way this mechanism could make things worse.
7261+
*
7262+
* @param {{ has: (flag: string) => boolean }} allowed — normally
7263+
* `process.allowedNodeEnvironmentFlags`.
7264+
*/
7265+
export function proxyRelaunchExecArgv(allowed) {
7266+
return allowed?.has?.('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : [];
7267+
}
7268+
71547269
// ---------------------------------------------------------------------------
71557270
// Transport prerequisite — the classifier (pure) and the probe that feeds it.
71567271
//
@@ -7627,7 +7742,17 @@ function reportPrerequisiteNotMet(v, options = {}) {
76277742
v.detail.map((l) => (l ? ` ${l}` : '')).join('\n') +
76287743
`\n\n Fix: ${v.fix[0] ?? 'unknown'}\n` +
76297744
v.fix.slice(1).map((l) => ` ${l}\n`).join('') +
7630-
`\n${nothing.join('\n')}\n` +
7745+
`\n${nothing.join('\n')}\n\n` +
7746+
` A GATE NOBODY CAN RUN IS INDISTINGUISHABLE FROM A GATE THAT FINDS NOTHING (#13526).\n` +
7747+
` That is not a slogan here, it is this refusal's measured cost: while this exit was the\n` +
7748+
` only thing an agent container could get out of this script, the population it guards —\n` +
7749+
` closed cards still carrying \`pm:*\` state — grew to 100+ per lane per label across at\n` +
7750+
` least three lanes, back to 2026-08-05, and no reading anywhere said so. If you are\n` +
7751+
` seeing this in a proxy-mediated container, check the egress prerequisite FIRST: node's\n` +
7752+
` \`fetch\` ignores \`HTTPS_PROXY\`, and this script now re-execs itself with\n` +
7753+
` \`NODE_USE_ENV_PROXY=1\` when a proxy is set (see \`resolveProxyRelaunch\`). Reaching this\n` +
7754+
` line WITH a proxy configured means the relaunch already happened and the credential the\n` +
7755+
` proxy injects is genuinely not working — not that the transport was never tried.\n` +
76317756
` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` +
76327757
` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`,
76337758
);
@@ -12937,6 +13062,31 @@ function selfTest() {
1293713062

1293813063
const isMain = isEntrypoint(import.meta.url);
1293913064
if (isMain) {
13065+
// The EGRESS prerequisite (#13526), answered before anything else — including
13066+
// the malformed-input refusals below, which cost nothing to re-run in the
13067+
// child and would otherwise be printed twice. See `resolveProxyRelaunch` for
13068+
// why this exists: without it, node's `fetch` bypasses the proxy that holds
13069+
// this container's credential and every run here ends in exit 3.
13070+
const relaunch = resolveProxyRelaunch(process.env, process.argv.slice(2));
13071+
if (relaunch.relaunch) {
13072+
const child = spawnSync(
13073+
process.execPath,
13074+
[
13075+
...proxyRelaunchExecArgv(process.allowedNodeEnvironmentFlags),
13076+
fileURLToPath(import.meta.url),
13077+
...process.argv.slice(2),
13078+
],
13079+
{
13080+
stdio: 'inherit',
13081+
env: { ...process.env, NODE_USE_ENV_PROXY: '1', [PROXY_RELAUNCH_GUARD]: '1' },
13082+
},
13083+
);
13084+
// A child that could not be spawned at all falls THROUGH to the in-process
13085+
// path rather than failing the run: the parent's transport is worse, but a
13086+
// worse reading beats no reading, and its own prerequisite verdict is the
13087+
// honest report of what it then finds. A child that ran owns the exit code.
13088+
if (!child.error) process.exit(child.status ?? 1);
13089+
}
1294013090
// A malformed sweep target is bad usage (exit 2), refused BEFORE any request
1294113091
// — including the probe's, whose second stage is a repo-scoped read of this
1294213092
// very string. Silently falling back to the default would sweep a board

0 commit comments

Comments
 (0)