Skip to content

Commit cc2e951

Browse files
fix(pm): fleet-write fails closed on an indeterminate liveness read, reads it only behind the proxy re-exec, and stops reading a proxy 403 as a rate limit (#19780)
Fixes #19774 ## What changed `scripts/pm/**` only — the fleet's own write tools, not on the governed register. 1. **The liveness read runs only behind the proxy.** `relayLive()` in `fleet-write/dispatch.mjs` refuses to read from a process whose fetch would bypass `HTTPS_PROXY` (a proxy configured, no `--use-env-proxy` on the process): the reading is indeterminate, names the flag, and is not cached, so the re-exec'd child reads for itself. `post-stamped.mjs` now re-execs on `--dry-run` too (a dry run resolves the route, and the route's read is a request); `label-write`, `issue-create`, `close-cards` and `with-fleet.sh` already re-exec before resolving, and `--route` did. The ordering is pinned in post-stamped's self-test and the unrouted refusal in dispatch's. 2. **Fail closed on an indeterminate read.** `relayLive()` judges three buckets: 200 with `state === 'active'` is live (dispatch); the file's 404 and the `disabled_manually` state are the two DEFINITE not-live signals and still turn a cloud seat direct with the printed line; any other status (401, 403, 5xx), a 200 without a readable state, any other state, or no answer is INDETERMINATE, and `selectTransport` / `resolveRoute` refuse with exit 3 naming the status and the read — under `auto` and explicit `OS_FLEET_TRANSPORT=dispatch` alike, never `direct`, never silent. The refusal carries the session so a tool can say who was refused. Explicit `OS_FLEET_TRANSPORT=direct` remains the only way to write as the personal account in a cloud container, and the tool prints it. 3. **A proxy refusal is not a rate limit.** `classifyHttp` (label-write.mjs) no longer reads an absent `x-ratelimit-remaining` header as zero (`Number(null)` is `0`, which is how the mint's proxy 403 wrote a 30-minute STOP MARKER), and recognises the egress proxy's 403 body — `isProxyRefusal()` in write-pace.mjs: the message naming the proxy, or a `documentation_url` that is not GitHub's, the measured discriminant — as a route failure (`prerequisite`). `stopSignalFrom` writes no marker for such a body whatever verdict or header accompanies it; `noteResponse` gives back the slot `paceWrite` took (no budget spent) and prints one line; `fleet-token` names the proxy and the relay spelling in its refusal. Callers (dispatch.mjs, fleet-token.mjs) now hand the body to the classifier. Existing markers are not migrated; they expire. 4. **Unchanged:** ceilings, the size route, the op table, the executor, the workflow, every allow rule. The documented spellings that described the fall-back (dispatch.mjs header, with-fleet.sh header) now say when the tool refuses instead. ## Verification record **Live, in this cloud container, with NO `OS_FLEET_SESSION` set** (`origin/main` at 2005a55, worktree at 25ee9c2): ``` $ node scripts/pm/fleet-write/dispatch.mjs --route ℹ️ re-exec with --use-env-proxy: HTTPS_PROXY is set (http://127.0.0.1:43969) and node's fetch does not read it. {"requested":"auto","transport":"dispatch","failed":null,"session":"session_01GnJon4xkRvphn4w28xx3An","session_source":"CLAUDE_CODE_REMOTE_SESSION_ID","reason":"OS_FLEET_TRANSPORT is auto → dispatch: …"} exit 0 ``` A bare `node scripts/pm/post-stamped.mjs --comment=19774 --file=… --dry-run` (no proxy flag on the command) now prints the re-exec line and then `post-stamped: transport dispatch — …` with the derived session and `state active`; on `main` the same command read condition ③ as `HTTP 401` and reported `direct`. The forced-401 case is proven in the dispatch self-test's fake platform (exit-3 shaped refusal naming HTTP 401 under auto and under explicit dispatch), and `--route` behind a proxy that answers nothing is proven end to end (real re-exec, exit 3, error naming no answer). **Batteries, each run alone with its exit captured before any pipe:** | command | exit | verdict line | |---|---|---| | `pnpm check:pm-fleet-write-validate` | 0 | 69 cases pass across 7 batteries | | `pnpm check:pm-fleet-write-execute` | 0 | 48 cases pass across 9 batteries | | `pnpm check:pm-fleet-write-dispatch` | 0 | 101 cases pass across 11 batteries (was 90 across 10) | | `pnpm check:pm-with-fleet` | 0 | 32 cases pass | | `pnpm check:pm-post-stamped` | 0 | 610 cases pass across 21 batteries (was 609) | | `pnpm check:pm-label-write` | 0 | 91 cases pass across 10 batteries (was 88) | | `pnpm check:pm-issue-create` | 0 | 42 cases pass across 6 batteries | | `pnpm check:pm-close-cards` | 0 | 111 cases pass across 12 batteries | | `pnpm check:pm-write-pace` | 0 | 113 cases pass across 12 batteries (was 109) | | `pnpm check:pm-fleet-token` | 0 | 75 cases pass across 8 batteries (was 73) | `npx eslint --no-inline-config` on the five changed `.mjs` files: exit 0. **Gates** — `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on the edited tree derived 39 families (6 paths vs merge base 2005a55; 355 changed lines, under the human-merge threshold). Each was run alone with its exit code captured before any pipe; all 39 exited 0. `--ran` with the codes recorded: `39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3)`. Repo-wide `pnpm lint` is CI's run; the changed files were linted directly (above). ## Changeset None: `scripts/pm/**` publishes nothing from any released package, so `skip-changeset` applies. This dispatch forbids label writes, so the label is the seat's to apply. ## Acceptance notes - `disabled_inactivity` (GitHub disabling a workflow for inactivity) is treated as indeterminate, not as a definite not-live signal: the card names exactly two definite signals, and this one is neither the maintainer's switch nor an absent file. If the seat wants it definite, it is a one-line change in `relayLive` plus its pin. - `.claude/skills/pm-dispatch/references/rest-channel.md` and `platform-readings.md` still describe the previous fall-back on any non-200 read; governed paths, outside this card's `scripts/pm/**` scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01GnJon4xkRvphn4w28xx3An)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 531689c commit cc2e951

6 files changed

Lines changed: 282 additions & 73 deletions

File tree

‎scripts/pm/fleet-token.mjs‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ import { fileURLToPath } from 'node:url';
123123
import { isEntrypoint } from '../invoked-as.mjs';
124124
import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs';
125125
import { classifyHttp } from './label-write.mjs';
126-
import { EXIT_WRITE_PACE_REFUSED, isWriteMethod, noteResponse, paceFilePath, paceWrite, releaseWriteLease } from './write-pace.mjs';
126+
import { EXIT_WRITE_PACE_REFUSED, isProxyRefusal, isWriteMethod, noteResponse, paceFilePath, paceWrite, releaseWriteLease } from './write-pace.mjs';
127127

128128
const SELF_PATH = fileURLToPath(import.meta.url);
129129
const PROXY_REARM_GUARD = 'OS_FLEET_TOKEN_PROXY_REARMED';
@@ -457,8 +457,8 @@ function rateRemainingOf(headers) {
457457
return raw === null || String(raw).trim() === '' ? null : Number(raw);
458458
}
459459

460-
function exitForStatus(status, headers) {
461-
const verdict = classifyHttp({ status, rateRemaining: rateRemainingOf(headers) });
460+
function exitForStatus(status, headers, body = null) {
461+
const verdict = classifyHttp({ status, rateRemaining: rateRemainingOf(headers), body });
462462
if (verdict === 'prerequisite' || verdict === 'ratelimit') return EXIT_PREREQUISITE;
463463
if (status >= 500 || status === 0) return EXIT_PREREQUISITE;
464464
return EXIT_PLATFORM_REFUSAL;
@@ -542,16 +542,19 @@ export async function mintInstallationToken(inputs, deps = {}) {
542542
status: res.status,
543543
headers: res.headers,
544544
body: minted,
545-
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemainingOf(res.headers) }),
545+
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemainingOf(res.headers), body: minted }),
546546
},
547547
paceDeps,
548548
);
549549
if (res.status !== 201 || !minted || typeof minted.token !== 'string' || !minted.token) {
550550
throw new FleetTokenError(
551551
`POST ${mintPath} → HTTP ${res.status} ${scrub(platformSentence(minted), secrets)}`.trim() +
552+
(isProxyRefusal(minted)
553+
? ' — the egress PROXY refused the /app/** path; GitHub was never asked. A cloud seat container cannot mint the fleet identity: no stop marker was written and no budget was spent — take the relay instead (scripts/pm/with-fleet.sh --via dispatch --repo owner/name --actions FILE)'
554+
: '') +
552555
(res.status === 401 ? ' — the JWT was refused: check OS_FLEET_APP_ID and the private key belong to the same App, and the host clock' : '') +
553556
(res.status === 404 ? ' — the installation was not found under this App: check OS_FLEET_INSTALLATION_ID' : ''),
554-
exitForStatus(res.status, res.headers),
557+
exitForStatus(res.status, res.headers, minted),
555558
);
556559
}
557560
secrets.push(minted.token);
@@ -635,7 +638,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
635638
'the JWT: RS256 over node crypto, the claims the platform reads, no library': 9,
636639
'the key: PEM, PEM with literal \\n, base64 of the PEM — one key out of three spellings': 7,
637640
'the cache: 0600, refreshed five minutes early, keyed to the installation': 8,
638-
'the mint: what a fake platform answers, and what this tool does with it': 11,
641+
'the mint: what a fake platform answers, and what this tool does with it': 13,
639642
'redaction: a known token and a known key fed through every output path never come back out': 12,
640643
'the CLI: --print prints the token alone, --export prints shell, --status prints neither': 10,
641644
'the repository-variables route: read when the environment has none, environment wins, the key never lands anywhere': 11,
@@ -794,6 +797,13 @@ export async function selfTest() {
794797
t('a 404 on the installation is a platform refusal (exit 5) that names the installation id', [notFound.error?.exitCode, notFound.error?.message.includes('OS_FLEET_INSTALLATION_ID')], [EXIT_PLATFORM_REFUSAL, true]);
795798
const down = await mintWith({ ...happy, 'POST /app/installations/163654544/access_tokens': { throws: 'getaddrinfo ENOTFOUND api.example.test' } });
796799
t('an unreachable platform is a prerequisite failure, and the mint left no cache', [down.error?.exitCode, existsSync(down.file)], [EXIT_PREREQUISITE, false]);
800+
// The egress proxy's 403 on the /app/** path (measured in a cloud seat container): the route, not the platform.
801+
const PROXY_403 = { message: 'Access to this GitHub API path is not permitted through this proxy.', documentation_url: 'https://docs.anthropic.com/en/docs/claude-code/github-actions' };
802+
const writesBefore = existsSync(paceFile) ? readFileSync(paceFile, 'utf8').split('\n').filter((l) => l.includes('"t":')).length : 0;
803+
const proxied = await mintWith({ ...happy, 'POST /app/installations/163654544/access_tokens': { status: 403, json: PROXY_403 } });
804+
const paceText = existsSync(paceFile) ? readFileSync(paceFile, 'utf8') : '';
805+
t("⭐ the egress PROXY's 403 on the mint is a prerequisite failure (exit 3) whose message names the proxy and the relay spelling — not a platform refusal", [proxied.error?.exitCode, proxied.error?.message.includes('egress PROXY') && proxied.error?.message.includes('--via dispatch')], [EXIT_PREREQUISITE, true]);
806+
t('⛔ …and it wrote NO stop marker and spent NO budget: the pace log holds no marker and the write record was given back', [paceText.includes('"stop":'), paceText.split('\n').filter((l) => l.includes('"t":')).length, proxied.logs.some((l) => l.includes('No stop marker'))], [false, writesBefore, true]);
797807
const sibling = { token: 'ghs_SiblingMintedFirst00000000000000000000', expires_at: new Date(NOW + 3600_000).toISOString(), bot: { login: 'objectstack-fleet[bot]', id: 332303061 }, installation_id: inputs.installationId };
798808
const reused = await mintWith(happy, { recheck: () => sibling });
799809
t('⛔ a sibling\'s fresh mint, found after the lease was granted, is used and no second POST leaves', [reused.result?.token, reused.seen.length], [sibling.token, 0]);

0 commit comments

Comments
 (0)