|
802 | 802 | */ |
803 | 803 |
|
804 | 804 | 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'; |
806 | 807 | import { isEntrypoint } from '../invoked-as.mjs'; |
807 | 808 |
|
808 | 809 | /** |
@@ -7151,6 +7152,120 @@ export function parseOutputOptions(argv) { |
7151 | 7152 | return out; |
7152 | 7153 | } |
7153 | 7154 |
|
| 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 | + |
7154 | 7269 | // --------------------------------------------------------------------------- |
7155 | 7270 | // Transport prerequisite — the classifier (pure) and the probe that feeds it. |
7156 | 7271 | // |
@@ -7627,7 +7742,17 @@ function reportPrerequisiteNotMet(v, options = {}) { |
7627 | 7742 | v.detail.map((l) => (l ? ` ${l}` : '')).join('\n') + |
7628 | 7743 | `\n\n Fix: ${v.fix[0] ?? 'unknown'}\n` + |
7629 | 7744 | 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` + |
7631 | 7756 | ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from the unclassified failure's 2 — but piping this\n` + |
7632 | 7757 | ` reports the PIPE's status, so \`… | tail -4\` reads green either way. Use \`echo "EXIT=$?"\`.)`, |
7633 | 7758 | ); |
@@ -12937,6 +13062,31 @@ function selfTest() { |
12937 | 13062 |
|
12938 | 13063 | const isMain = isEntrypoint(import.meta.url); |
12939 | 13064 | 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 | + } |
12940 | 13090 | // A malformed sweep target is bad usage (exit 2), refused BEFORE any request |
12941 | 13091 | // — including the probe's, whose second stage is a repo-scoped read of this |
12942 | 13092 | // very string. Silently falling back to the default would sweep a board |
|
0 commit comments