@codeyam-editor/codeyam-editor 0.1.10 → 0.1.11-staging.g493e6ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/npm/cloud.js CHANGED
@@ -115,6 +115,9 @@ const { addToRoster, readRoster } = require("./fleet-roster");
115
115
  // container) apart from a real `docker compose up -d` failure, so the abort path
116
116
  // can converge instead of escalating to instance deletion.
117
117
  const { classifyComposeUpFailure } = require("./compose-conflict");
118
+ // The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
119
+ // and for the advance's provisioning pre-flight, which checks the same set.
120
+ const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
118
121
 
119
122
  // =============================================================================
120
123
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -1540,6 +1543,7 @@ async function waitForIapReady(cfg) {
1540
1543
  const deadline = Date.now() + 180_000;
1541
1544
  let attempts = 0;
1542
1545
  let lastErr = "";
1546
+ const probeStderrs = [];
1543
1547
  while (Date.now() < deadline) {
1544
1548
  attempts++;
1545
1549
  const probe = runGcloud(
@@ -1550,17 +1554,42 @@ async function waitForIapReady(cfg) {
1550
1554
  console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
1551
1555
  return;
1552
1556
  }
1553
- lastErr = String(probe.stderr || "").trim().split("\n").slice(-1)[0] || "";
1557
+ const stderr = String(probe.stderr || "").trim();
1558
+ probeStderrs.push(stderr);
1559
+ lastErr = stderr.split("\n").slice(-1)[0] || "";
1554
1560
  // Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
1555
1561
  const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
1556
1562
  await new Promise((r) => setTimeout(r, delay));
1557
1563
  }
1564
+ const cause = iapProbeCause(probeStderrs);
1558
1565
  throw new Error(
1559
1566
  `IAP did not become ready for ${cfg.instanceName} within 180s ` +
1560
- `(${attempts} probes; last error: ${lastErr || "unknown"})`,
1567
+ `(${attempts} probes; ${cause ? `likely cause: ${cause}; ` : ""}` +
1568
+ `last error: ${lastErr || "unknown"})`,
1561
1569
  );
1562
1570
  }
1563
1571
 
1572
+ /** Name the likely cause behind a run of failed IAP SSH probes. The last stderr
1573
+ * line alone is almost always `ssh exited with return code [255]`, which
1574
+ * collapses three different waits — each with its own expected duration — into
1575
+ * one unactionable string. The full stderr of each probe still distinguishes
1576
+ * them. Scans newest-first so the label describes where the wait ENDED (IAP lag
1577
+ * often gives way to key propagation on a fresh box). Returns null when no
1578
+ * signature matches; the raw last line is kept beside it either way. */
1579
+ function iapProbeCause(probeStderrs) {
1580
+ const signatures = [
1581
+ [/4047|Failed to lookup instance/i, "IAP has not registered the instance yet"],
1582
+ [/Permission denied \(publickey/i, "OS Login SSH key not propagated yet"],
1583
+ [/Connection (refused|closed|reset)/i, "sshd on the instance not accepting connections yet"],
1584
+ ];
1585
+ for (const text of [...(probeStderrs || [])].reverse()) {
1586
+ for (const [re, label] of signatures) {
1587
+ if (re.test(text)) return label;
1588
+ }
1589
+ }
1590
+ return null;
1591
+ }
1592
+
1564
1593
  /** Read `process.env[FORWARD_ENV_KEYS_ENV]` (comma-separated key list set by
1565
1594
  * the fleet dashboard) and return the subset of `process.env` that should
1566
1595
  * ride along into the VM's project-bootstrap shell. Whitelisted to the
@@ -2216,6 +2245,20 @@ function buildReconcileAdminCommand(repoName, value) {
2216
2245
  );
2217
2246
  }
2218
2247
 
2248
+ // Every library `scripts/gcp-project-bootstrap.sh` sources, keyed by the path it
2249
+ // sources it AS — i.e. relative to the script's own directory, which on the VM is
2250
+ // `$HOME`. A streamed script is a SINGLE FILE, so each of these must be shipped
2251
+ // beside it or the script dies at its source line and cloud:up tears the
2252
+ // freshly-created VM back down. Adding a `source` line to that script without
2253
+ // adding it here has now broken every add on the fleet twice (`lib/npm-pin.sh`
2254
+ // 2026-09-04, `lib/render-cloud-compose.sh` 2026-09-18), so this list is the one
2255
+ // place to register it: STREAMED_SCRIPTS derives its library entries from this
2256
+ // constant rather than repeating them, and a unit test reads the shipped script
2257
+ // and fails on any source line missing from it. Keep it a ONE-LINE array literal:
2258
+ // the advance's provisioning pre-flight reads it out of a branch's cloud.js TEXT
2259
+ // (provisioning-contract.js → parseProjectBootstrapLibs).
2260
+ const PROJECT_BOOTSTRAP_LIBS = ["lib/npm-pin.sh", "lib/render-cloud-compose.sh"];
2261
+
2219
2262
  async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2220
2263
  const repoName = resolveProject(cfg).name;
2221
2264
  console.error(`Checking project bootstrap state for ${repoName}...`);
@@ -2245,13 +2288,16 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2245
2288
  console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
2246
2289
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
2247
2290
  const scriptBody = readStreamedScript(scriptPath, cfg);
2248
- // gcp-project-bootstrap.sh sources lib/npm-pin.sh relative to its own
2249
- // directory. Read it through readStreamedScript too, so the library is subject
2250
- // to the same drift guard as the script that sources it — streaming a reviewed
2251
- // remote script beside a stale on-disk library would defeat the guard.
2252
- const bootstrapLibs = {
2253
- "lib/npm-pin.sh": readStreamedScript(path.join(rootDir, "scripts", "lib", "npm-pin.sh"), cfg),
2254
- };
2291
+ // gcp-project-bootstrap.sh sources its libraries relative to its own
2292
+ // directory. Read them through readStreamedScript too, so each library is
2293
+ // subject to the same drift guard as the script that sources it — streaming a
2294
+ // reviewed remote script beside a stale on-disk library would defeat the guard.
2295
+ const bootstrapLibs = Object.fromEntries(
2296
+ PROJECT_BOOTSTRAP_LIBS.map((rel) => [
2297
+ rel,
2298
+ readStreamedScript(path.join(rootDir, "scripts", ...rel.split("/")), cfg),
2299
+ ]),
2300
+ );
2255
2301
  const scriptArgs = clientProjectBootstrapArgs(cfg);
2256
2302
  // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
2257
2303
  // toggle — see projectBootstrapEnv for what each key is for and when it is
@@ -3635,14 +3681,18 @@ function promptForInstanceName(expected) {
3635
3681
  }
3636
3682
 
3637
3683
  // Every file streamed to a VM, and therefore every file the drift guard must
3638
- // compare against the reviewed remote tip. `scripts/lib/npm-pin.sh` is here
3639
- // because gcp-project-bootstrap.sh SOURCES it: a library that reaches the VM is
3640
- // as much streamed content as the script that sources it, and leaving it out
3641
- // meant the drift guard silently vouched for only half of what was shipped.
3684
+ // compare against the reviewed remote tip. The libraries are here because
3685
+ // gcp-project-bootstrap.sh SOURCES them: a library that reaches the VM is as
3686
+ // much streamed content as the script that sources it, and leaving one out meant
3687
+ // the drift guard silently vouched for only half of what was shipped. They are
3688
+ // DERIVED from PROJECT_BOOTSTRAP_LIBS rather than listed again — when the two
3689
+ // were maintained separately, a library added to neither took the whole fleet's
3690
+ // provisioning down, and a library added to only one would have been shipped
3691
+ // unvouched-for.
3642
3692
  const STREAMED_SCRIPTS = [
3643
- "scripts/gcp-bootstrap.sh",
3644
- "scripts/gcp-project-bootstrap.sh",
3645
- "scripts/lib/npm-pin.sh",
3693
+ BOOTSTRAP_SCRIPT,
3694
+ PROJECT_BOOTSTRAP_SCRIPT,
3695
+ ...PROJECT_BOOTSTRAP_LIBS.map((rel) => `scripts/${rel}`),
3646
3696
  ];
3647
3697
 
3648
3698
  /** The configured editor branch to compare/stream streamed scripts against.
@@ -5647,6 +5697,7 @@ async function cloudMain() {
5647
5697
  module.exports = {
5648
5698
  // pure helpers
5649
5699
  isSpawnTimeout,
5700
+ iapProbeCause,
5650
5701
  gcloudCallTimeoutMs,
5651
5702
  provisionFailureMessage,
5652
5703
  GCLOUD_PROVISION_TIMEOUT_MS,
@@ -5760,6 +5811,7 @@ module.exports = {
5760
5811
  gatherScriptDriftFacts,
5761
5812
  readStreamedScript,
5762
5813
  STREAMED_SCRIPTS,
5814
+ PROJECT_BOOTSTRAP_LIBS,
5763
5815
  // ingress wiring (pure decision + spawn wrappers)
5764
5816
  envPosInt,
5765
5817
  ingressWiringPlan,
@@ -47,6 +47,13 @@ const { parseRosterMarkers } = require('./fleet-early-roster');
47
47
  // `ensureBootstrapped` skips on the present image, and
48
48
  // `ensureProjectBootstrapped` re-attaches to the in-flight install rather
49
49
  // than starting a second `npm install` over the first.
50
+ // `iap-not-ready` is the same ground in its strongest form: the throw comes
51
+ // from waitForIapReady, which ensureInstance calls BEFORE ensureBootstrapped,
52
+ // ensureProjectBootstrapped or ensureEditorContainerUp — so the provable state
53
+ // is "an instance exists and nothing else has run". The retry reuses that
54
+ // live instance and every later step runs for the first time. (VM-4,
55
+ // 2026-09-17: destroy + an identical re-add succeeded in 10 minutes; the
56
+ // failure was a transient IAP/SSH race, not a broken box.)
50
57
  //
51
58
  // Ground 2 is deliberately narrow — it is NOT "the VM probably still works".
52
59
  // `container-stuck` and `incomplete-workspace` also leave a live VM, but their
@@ -56,6 +63,7 @@ const ADD_FAILURE_RETRYABLE = new Set([
56
63
  'instance-vanished',
57
64
  'capacity-stockout',
58
65
  'project-bootstrap-timeout',
66
+ 'iap-not-ready',
59
67
  ]);
60
68
 
61
69
  // Collect the ` - <path>` bullets the drift guard prints AFTER the "following
@@ -350,6 +358,92 @@ function wedgedInsertRecovery({ code, instance } = {}) {
350
358
  );
351
359
  }
352
360
 
361
+ // Facts for the iap-not-ready arm: waitForIapReady (npm/cloud.js) threw its
362
+ // readiness timeout. Returns { timedOut, instance, budgetSecs, cause } — `cause` is
363
+ // the label waitForIapReady derived from its probe stderr (null when none of its
364
+ // signatures matched, or on a transcript from before the label existed), so the
365
+ // recovery copy can name WHICH wait was blown instead of only `return code [255]`.
366
+ //
367
+ // The signature is emitted from exactly one place, and that place runs before any
368
+ // bootstrap work, which is what lets this arm assert the instance is bare.
369
+ const IAP_NOT_READY = /IAP did not become ready for (\S+) within (\d+)s/;
370
+
371
+ function iapNotReadyFacts(logTail) {
372
+ const m = String(logTail || '').match(IAP_NOT_READY);
373
+ if (!m) return { timedOut: false, instance: null, budgetSecs: null, cause: null };
374
+ const cause = (String(logTail).match(/likely cause: ([^;)]+)/) || [])[1] || null;
375
+ return { timedOut: true, instance: m[1], budgetSecs: Number(m[2]), cause: cause && cause.trim() };
376
+ }
377
+
378
+ // Operator-facing recovery copy for the iap-not-ready arm. It states what the
379
+ // generic arm cannot: the instance EXISTS and is BARE. "A partial instance may
380
+ // exist" is what steered VM-4's operator toward destroy + re-add (15-25 minutes)
381
+ // when Retry resumes on the same box in seconds — and it left nothing on the card
382
+ // saying a RUNNING, billing instance was sitting there.
383
+ function iapNotReadyRecovery({ instance, budgetSecs, cause } = {}) {
384
+ const named = instance || 'The instance';
385
+ const within = budgetSecs ? ` within ${budgetSecs}s` : '';
386
+ const why = cause ? ` (likely cause: ${cause})` : '';
387
+ return (
388
+ `${named} was created and is RUNNING, but SSH over IAP did not answer${within}${why}. This is usually a transient ` +
389
+ 'control-plane race. Nothing was installed on it — the wait runs before any bootstrap step — so it is a bare box and ' +
390
+ 'there is nothing partial to reconcile. Retry add resumes on this same instance rather than creating a second one. ' +
391
+ 'Until you retry or destroy it, the instance keeps running and billing.'
392
+ );
393
+ }
394
+
395
+ // Facts for the streamed-library-missing arm: a streamed bootstrap script sourced
396
+ // a `$HOME`-rooted `lib/…` file that was never shipped to the VM, and bash
397
+ // reported `<script>: line N: <path>: No such file or directory`. This took ALL
398
+ // fleet provisioning down twice (lib/npm-pin.sh 2026-09-04, lib/render-cloud-
399
+ // compose.sh 2026-09-18). Returns { missing, script, library } — `script` is the
400
+ // basename of the aborting script, `library` the `lib/…` path relative to $HOME,
401
+ // which is how PROJECT_BOOTSTRAP_LIBS (npm/cloud.js) names its entries.
402
+ const STREAMED_LIBRARY_MISSING = /(\S+): line \d+: \/(?:home\/[^/\s]+|root)\/(lib\/[^\s:]+): No such file or directory/;
403
+
404
+ function streamedLibraryMissingFacts(logTail) {
405
+ const m = String(logTail || '').match(STREAMED_LIBRARY_MISSING);
406
+ if (!m) return { missing: false, script: null, library: null };
407
+ return { missing: true, script: m[1].split('/').pop(), library: m[2] };
408
+ }
409
+
410
+ // Did cloud:up report that it deleted the instance it created this run? The abort
411
+ // path (npm/cloud.js) prints the teardown marker BEFORE it issues the delete, and
412
+ // prints a `during abort teardown` warning when that delete fails — so the marker
413
+ // alone is a claim of intent, and only the marker WITHOUT the warning is evidence
414
+ // that no orphan was left behind.
415
+ function cleanTeardownReported(logTail) {
416
+ const log = String(logTail || '');
417
+ return /tearing down the VM cloud:up created this run \(no orphan left behind\)/.test(log)
418
+ && !/during abort teardown/.test(log);
419
+ }
420
+
421
+ // Operator-facing recovery copy for the streamed-library-missing arm. It names
422
+ // what to fix — register the library so it is streamed — and, when the transcript
423
+ // proves a clean teardown, says there is no orphan to hunt for. Every add on this
424
+ // branch fails identically until the registry is fixed, so it never offers Retry.
425
+ function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {}) {
426
+ const lib = library || 'a lib/ file';
427
+ const from = script ? ` sourced by ${script}` : '';
428
+ const orphan = cleanTeardown
429
+ ? 'cloud:up already deleted the VM it created, so there is nothing to reconcile or destroy.'
430
+ : 'The transcript does not confirm the VM was deleted — check whether a partial instance exists before re-adding.';
431
+ return (
432
+ `${lib}${from} was never shipped to the VM, so the bootstrap aborted. Add it to PROJECT_BOOTSTRAP_LIBS in npm/cloud.js, ` +
433
+ `push, and re-add — every add from this branch fails the same way until then. ${orphan}`
434
+ );
435
+ }
436
+
437
+ // Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
438
+ // instance may exist" is only true when the transcript does not show cloud:up
439
+ // deleting what it created; saying it regardless sends the operator hunting for
440
+ // an orphan that is not there.
441
+ function cloudUpFailedRecovery({ cleanTeardown } = {}) {
442
+ return cleanTeardown
443
+ ? 'cloud:up failed during provisioning and deleted the VM it created, so nothing was left behind. Read the transcript for the cause before re-adding.'
444
+ : 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.';
445
+ }
446
+
353
447
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
354
448
  // can never drift from the category.
355
449
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -392,7 +486,12 @@ function buildFailureVerdict(category, headline, recovery, extra) {
392
486
  // instance. `wedged-insert` (arm 8) is the third: it sits directly above the
393
487
  // generic arm so a create GCE accepted-and-abandoned cannot report as an ordinary
394
488
  // provisioning failure whose advice is to destroy the box — a destroy that, on a
395
- // wedged slot, queues behind the wedge instead of clearing it.
489
+ // wedged slot, queues behind the wedge instead of clearing it. `iap-not-ready`
490
+ // (arm 9) is the fourth: it keeps a bare, retry-safe instance from reporting as a
491
+ // possibly-partial one whose only recovery is destroy + re-add.
492
+ // `streamed-library-missing` (arm 10) is the fifth: it names the unshipped library
493
+ // that broke every add, instead of advising a hunt for an orphan cloud:up already
494
+ // deleted.
396
495
  function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
397
496
  const log = String(logTail || '');
398
497
 
@@ -554,28 +653,64 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
554
653
  wedgedInsertRecovery(wedgedInsert));
555
654
  }
556
655
 
557
- // 9. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
558
- // vanished / stuck / stockout / missing-ref / wedged-insert signatures above
559
- // did not match. A partial instance may exist, so not retryable.
656
+ // 9. iap-not-ready — the instance reached RUNNING but IAP-tunneled SSH never
657
+ // answered inside waitForIapReady's budget. That wait is called from
658
+ // ensureInstance before any bootstrap work, so the box exists and is bare.
659
+ // Retryable on the re-entrancy ground (see ADD_FAILURE_RETRYABLE); the budget
660
+ // was already raised 60s → 180s for this race, and VM-4 still blew it, so the
661
+ // answer is a cheap retry rather than a longer wait for every dead add.
662
+ //
663
+ // Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
664
+ // shell echoes cloud:up's tail into every transcript, so matching the generic
665
+ // line first refuses the safe retry — exactly the VM-4 failure.
666
+ const iapNotReady = iapNotReadyFacts(log);
667
+ if (iapNotReady.timedOut) {
668
+ return buildFailureVerdict('iap-not-ready', 'SSH never became reachable',
669
+ iapNotReadyRecovery(iapNotReady));
670
+ }
671
+
672
+ // 10. streamed-library-missing — a streamed bootstrap script sourced a `lib/…`
673
+ // file that was never shipped to the VM. The failure that has taken ALL fleet
674
+ // provisioning down twice (2026-09-04, 2026-09-18); both times it fell through
675
+ // to cloud-up-failed, whose recovery told six operators to hunt for a partial
676
+ // instance cloud:up had already deleted, and said nothing about the cause.
677
+ //
678
+ // NOT retryable: re-issuing the same add streams the same scripts without the
679
+ // library and fails identically — the missing-branch-ref reasoning. The fix is
680
+ // a code change (register the library), which is what the recovery names.
681
+ //
682
+ // Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
683
+ // shell echoes cloud:up's tail into every transcript, so matching the generic
684
+ // line first is precisely the bug being fixed.
685
+ const missingLib = streamedLibraryMissingFacts(log);
686
+ if (missingLib.missing) {
687
+ return buildFailureVerdict('streamed-library-missing', `Bootstrap library never shipped: ${missingLib.library}`,
688
+ streamedLibraryMissingRecovery({ ...missingLib, cleanTeardown: cleanTeardownReported(log) }));
689
+ }
690
+
691
+ // 11. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
692
+ // vanished / stuck / stockout / missing-ref / wedged-insert / iap-not-ready /
693
+ // streamed-library signatures above did not match. Not retryable. Whether a
694
+ // partial instance may exist is read off the transcript's teardown evidence.
560
695
  if (/cloud:up FAILED \(exit/i.test(log)) {
561
696
  return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
562
- 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
697
+ cloudUpFailedRecovery({ cleanTeardown: cleanTeardownReported(log) }));
563
698
  }
564
699
 
565
- // 10. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
700
+ // 12. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
566
701
  // Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
567
702
  if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
568
703
  return buildFailureVerdict('interrupted', 'Provisioning interrupted',
569
704
  'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
570
705
  }
571
706
 
572
- // 11. The post-add IAP read-back verdicts. Unlike every signature above, this one
707
+ // 13. The post-add IAP read-back verdicts. Unlike every signature above, this one
573
708
  // is not produced by the provision transcript at all — it is the BLOCKED
574
709
  // reason iapReadbackOutcome hands to annotateAddFailure, which is what
575
710
  // stops a read-back failure from being the one add failure that reaches the
576
711
  // card with no category at all (and so no differentiated headline, no
577
712
  // recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
578
- // calm). Tested AFTER all eight hard-failure signatures so a proven failure
713
+ // calm). Tested AFTER every hard-failure signature so a proven failure
579
714
  // always wins if both texts are ever present, and BEFORE converging/verifying
580
715
  // because it is strictly more specific than either.
581
716
  if (/IAP policy authorizes no human/i.test(log)) {
@@ -599,7 +734,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
599
734
  `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
600
735
  }
601
736
 
602
- // 12. converging — the add reached the early-roster marker (`CY_ROSTER`): the
737
+ // 14. converging — the add reached the early-roster marker (`CY_ROSTER`): the
603
738
  // container gate passed, the instance is real, and the VM is rostered, yet it
604
739
  // exited non-zero WITHOUT matching any specific hard-failure signature above.
605
740
  // By construction of the provision script, every genuine hard failure (drift,
@@ -617,7 +752,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
617
752
  'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
618
753
  }
619
754
 
620
- // 13. The residue: no failure signature above matched. The polarity here is
755
+ // 15. The residue: no failure signature above matched. The polarity here is
621
756
  // INVERTED from the original design. It used to read "no recognized success
622
757
  // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
623
758
  // VM whose container was Up and whose editor answered /api/health 200 got a
@@ -739,4 +874,14 @@ function wedgedRetryMessage(n) {
739
874
  );
740
875
  }
741
876
 
742
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
877
+ // Refusal copy for a one-click "Retry add" after a streamed-library abort,
878
+ // extracted for the same reason wedgedRetryMessage was. The retry is refused
879
+ // because every add from this branch fails identically until the library is
880
+ // registered, and the generic refusal would again send the operator after an
881
+ // orphan cloud:up already deleted. Repeats the classified recovery, which names
882
+ // the fix and says whether anything was left behind. Pure; no I/O.
883
+ function streamedLibraryRetryMessage(n, recovery) {
884
+ return `VM-${n} cannot be retried until the code is fixed: ${recovery || 'a streamed bootstrap library was never shipped to the VM.'}`;
885
+ }
886
+
887
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
package/npm/fleet-auth.js CHANGED
@@ -133,18 +133,56 @@ function listAvailableAccounts(vms) {
133
133
  // Pick the credential donor for a chosen account: the lowest-numbered VM in
134
134
  // `reachableDonors` whose authenticated account is `email`, or null when no
135
135
  // reachable VM holds it (or no email was asked for). `vms` is the per-VM poll
136
- // state keyed by VM number (`state.vms`). Pure.
136
+ // state keyed by VM number (`state.vms`). A donor whose credential failed the
137
+ // integrity probe (`providerAuth.credentialIntact === false`) is skipped as
138
+ // though it were not authenticated — its credential cannot be copied, so the
139
+ // sink would refuse it after an add had already committed to it. An unprobed
140
+ // verdict (null/absent) is usable: a probe failure is not evidence. Pure.
137
141
  function selectDonorForAccount(reachableDonors, vms, email) {
138
142
  if (!email) return null;
139
143
  const want = String(email).toLowerCase();
140
144
  for (const n of [...(reachableDonors || [])].sort((a, b) => a - b)) {
141
145
  const pa = vms && vms[n] && vms[n].providerAuth;
146
+ if (pa && pa.credentialIntact === false) continue;
142
147
  const got = pa && pa.state === 'authenticated' && pa.account && pa.account.email;
143
148
  if (got && String(got).toLowerCase() === want) return n;
144
149
  }
145
150
  return null;
146
151
  }
147
152
 
153
+ // Split the reachable donor candidates into those whose credential can be
154
+ // copied and those the integrity probe failed, so the add can drop the failed
155
+ // ones BEFORE resolveCredsSource's first-donor fallback could pick one, and name
156
+ // each dropped donor in the add log. Order is preserved; unprobed is usable.
157
+ // Returns { usable: number[], skipped: number[] }. Pure.
158
+ function partitionDonorsByIntegrity(reachableDonors, vms) {
159
+ const usable = [];
160
+ const skipped = [];
161
+ for (const n of reachableDonors || []) {
162
+ const pa = vms && vms[n] && vms[n].providerAuth;
163
+ if (pa && pa.credentialIntact === false) skipped.push(n);
164
+ else usable.push(n);
165
+ }
166
+ return { usable, skipped };
167
+ }
168
+
169
+ // Fold the dashboard's credential-integrity verdict into a VM's editor-reported
170
+ // providerAuth record. The editor derives `state` from whether the access token
171
+ // works NOW, so a credential with no refresh token still reads `authenticated`
172
+ // (VM-11, 2026-09-17) — a VM that will strand at its next token expiry and that
173
+ // no add can copy from. When the probe says the credential is incomplete, the
174
+ // card must say what the remedy is: `needs-login`. `credentialIntact` rides on
175
+ // the record either way (null = unprobed) so donor selection reads one place.
176
+ // Returns null for a null auth (editor didn't answer). Pure.
177
+ function applyCredentialIntegrity(auth, credentialIntact) {
178
+ if (!auth) return null;
179
+ const intact = credentialIntact === true || credentialIntact === false ? credentialIntact : null;
180
+ if (intact === false && auth.state === 'authenticated') {
181
+ return { ...auth, state: 'needs-login', credentialIntact: false };
182
+ }
183
+ return { ...auth, credentialIntact: intact };
184
+ }
185
+
148
186
  // A plausible Claude account email: one "@", a dotted domain, no whitespace or
149
187
  // shell-significant characters. The shared guard for every place an operator
150
188
  // can name an account (the Add form's per-launch override, the set-default
@@ -178,6 +216,8 @@ module.exports = {
178
216
  deriveDefaultAuthAccount,
179
217
  listAvailableAccounts,
180
218
  selectDonorForAccount,
219
+ partitionDonorsByIntegrity,
220
+ applyCredentialIntegrity,
181
221
  isAccountEmail,
182
222
  resolveChosenAccount,
183
223
  isAuthStartable,
@@ -72,7 +72,21 @@ function strandedAdoptDecision({ stranded, streaks, threshold } = {}) {
72
72
  // only would be the opposite mistake: reasons like `claude auth copy failed on
73
73
  // VM-2 (…)` are already recorded in live snapshots and carry no prefix at all,
74
74
  // so both shapes have to match.
75
+ //
76
+ // FIRST MATCH WINS, so the anchored structured prefixes come before the loose
77
+ // free-text patterns: a `binary-stale:` reason names a branch, and a branch
78
+ // called `fix-login` must not route it to the auth witness.
79
+ //
80
+ // `binary-stale:` (gcp-bootstrap.sh's report_binary_staleness) describes the
81
+ // binary installed AT ADD TIME. Its witness is that binary having since been
82
+ // replaced: a deployment record installed after the add finished. Every path
83
+ // that writes that record after provisioning — the operator refresh, and so any
84
+ // fleet advance — refuses to install an artifact behind its branch tip, so a
85
+ // later install is positive evidence the stale binary is gone. A record from
86
+ // the provision itself predates `finishedAt` and proves nothing, and no record
87
+ // at all is silence, which never clears a reason.
75
88
  const REASON_WITNESSES = [
89
+ { match: /^\s*binary-stale\b/i, witness: 'binaryReplaced' },
76
90
  { match: /^\s*claude-auth-not-seeded\b/i, witness: 'authed' },
77
91
  { match: /claude auth|claude\.json|login/i, witness: 'authed' },
78
92
  { match: /container|never came up|provisioning failed/i, witness: 'reachableServing' },
@@ -81,11 +95,21 @@ const REASON_WITNESSES = [
81
95
  // Map a single degraded reason to the live health signal that resolves it.
82
96
  // Returns true only when that signal now passes; a reason with no mapped signal
83
97
  // returns false (retained — a genuine config problem, never a false-green).
84
- function reasonClearedByHealth(reason, { reachableServing, authed }) {
98
+ function reasonClearedByHealth(reason, witnesses) {
85
99
  const r = String(reason || '');
86
100
  const entry = REASON_WITNESSES.find((e) => e.match.test(r));
87
101
  if (!entry) return false;
88
- return entry.witness === 'authed' ? !!authed : !!reachableServing;
102
+ return !!witnesses[entry.witness];
103
+ }
104
+
105
+ // True only when the VM's live deployment record was installed strictly after
106
+ // the add job finished. Either timestamp missing or unparseable → false: an
107
+ // unprovable replacement is not a replacement.
108
+ function binaryReplacedAfter(installedAt, finishedAt) {
109
+ const installed = Date.parse(installedAt || '');
110
+ const finished = Date.parse(finishedAt || '');
111
+ if (Number.isNaN(installed) || Number.isNaN(finished)) return false;
112
+ return installed > finished;
89
113
  }
90
114
 
91
115
  // Given a terminal add-job badge and the VM's live health, return the corrected
@@ -94,8 +118,8 @@ function reasonClearedByHealth(reason, { reachableServing, authed }) {
94
118
  // `error` add to `done` when the VM is reachable + serving; leaves any reason
95
119
  // with no health signal intact (no false-green). The poll loop supplies the live
96
120
  // facts (I/O at the edge) and applies the result; the verdict itself is pure.
97
- // job: { state: 'done'|'error', degraded, degradedReasons }
98
- // health: { reachable, editorHttpOk, providerAuthState }
121
+ // job: { state: 'done'|'error', degraded, degradedReasons, finishedAt }
122
+ // health: { reachable, editorHttpOk, providerAuthState, deploymentInstalledAt }
99
123
  // → null | { state, degraded, degradedReasons, retiredReasons, transition }
100
124
  //
101
125
  // `retiredReasons` names the reasons this verdict just dropped. Without it a
@@ -108,6 +132,7 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
108
132
  if (!job || !health) return null;
109
133
  const reachableServing = !!(health.reachable && health.editorHttpOk);
110
134
  const authed = health.providerAuthState === 'authenticated';
135
+ const binaryReplaced = binaryReplacedAfter(health.deploymentInstalledAt, job.finishedAt);
111
136
 
112
137
  // Bare `error` add (no degraded reasons): flip to `done` once the VM is
113
138
  // reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
@@ -133,7 +158,8 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
133
158
  const remaining = [];
134
159
  const retired = [];
135
160
  for (const reason of reasons) {
136
- const bucket = reasonClearedByHealth(reason, { reachableServing, authed }) ? retired : remaining;
161
+ const cleared = reasonClearedByHealth(reason, { reachableServing, authed, binaryReplaced });
162
+ const bucket = cleared ? retired : remaining;
137
163
  bucket.push(reason);
138
164
  }
139
165
  if (retired.length === 0) return null; // nothing cleared → no-op
@@ -39,6 +39,31 @@ function parseDriftReason(reason) {
39
39
  return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
40
40
  }
41
41
 
42
+ // The Claude-auth reason shapes that classify as the calm `auth` setup step.
43
+ // The structured `claude-auth-not-seeded` prefix is what every producer emits
44
+ // now; the two anchored free-text shapes are what `buildCredsSteps`
45
+ // (npm/launch-config.js) emitted before it carried the prefix, and they are
46
+ // already persisted in live add-job snapshots, so they must keep classifying.
47
+ //
48
+ // NARROW AND ANCHORED, ON PURPOSE — do not "unify" these with the broad
49
+ // `/claude auth|claude\.json|login/i` in npm/fleet-reconcile.js. Over-matching
50
+ // there only retires a reason once a live witness proves the VM authenticated,
51
+ // so a false positive costs nothing. Here it is inverted: over-matching routes a
52
+ // genuine problem into the calm checklist and hides it.
53
+ const AUTH_REASON_PATTERNS = [
54
+ /^claude-auth-not-seeded/,
55
+ /^claude auth copy failed\b/,
56
+ /^claude\.json copy failed\b/,
57
+ ];
58
+
59
+ // True when a degraded reason means "this VM's only gap is Claude login". The
60
+ // single match rule for the `auth` step — server.js's auto-kick and login
61
+ // follow-up gates call this rather than hand-rolling their own prefix test.
62
+ function isAuthReason(reason) {
63
+ const r = String(reason);
64
+ return AUTH_REASON_PATTERNS.some((re) => re.test(r));
65
+ }
66
+
42
67
  // Partition degradedReasons into { setupSteps, attentionReasons }.
43
68
  //
44
69
  // setupSteps ordered [{ code, label, detail, action? }] — expected
@@ -54,7 +79,7 @@ function classifyReasons(degradedReasons = []) {
54
79
  const list = Array.isArray(degradedReasons) ? degradedReasons : [];
55
80
  for (const raw of list) {
56
81
  const reason = String(raw);
57
- if (reason.startsWith('claude-auth-not-seeded')) {
82
+ if (isAuthReason(reason)) {
58
83
  setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
59
84
  } else if (reason.startsWith('ingress-unwired')) {
60
85
  // System-handled: the dashboard reconcile wires the public URL
@@ -109,4 +134,4 @@ function onlySetupCode(degradedReasons = [], code) {
109
134
  && setupSteps.every((s) => s.code === code);
110
135
  }
111
136
 
112
- module.exports = { classifyReasons, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
137
+ module.exports = { classifyReasons, isAuthReason, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
@@ -33,6 +33,11 @@
33
33
  // 'unreachable' — the editor did not answer over the tunnel. Nothing
34
34
  // downstream (broker, LB health) is meaningful; the link is
35
35
  // gated exactly as today.
36
+ // 'access-locked' — reachable, but the editor's /api/health reports
37
+ // `accessModeUndeclared: true`: no access mode is declared on
38
+ // its public bind, so it issues no session and serves only the
39
+ // "configure an access mode" page (a 200). Remedy is a config
40
+ // declaration — never a restart, rebuild, or re-wire.
36
41
  // 'half-wired' — LB mode, the editor backend service is ABSENT (never
37
42
  // created). The permanent 503 case — the public URL will fail
38
43
  // until re-wired. The state that had NO signal before.
@@ -88,6 +93,7 @@
88
93
  const READINESS_STATES = [
89
94
  'provisioning',
90
95
  'unreachable',
96
+ 'access-locked',
91
97
  'half-wired',
92
98
  'iap-access-missing',
93
99
  'preview-iap-access-missing',
@@ -176,6 +182,19 @@ function agentRosterReady(verdict) {
176
182
  return null;
177
183
  }
178
184
 
185
+ // Read the access-mode lockout off a parsed /api/health body as a tri-state:
186
+ // true — the editor reports it issues a session to no one (no access mode
187
+ // declared on a public bind). Feeds the 'access-locked' readiness.
188
+ // false — the editor reports it is NOT locked out.
189
+ // null — no body (probe failed) or a body without a boolean field (a binary
190
+ // that predates it). Absence of evidence, never a verdict either way,
191
+ // so an older VM keeps today's card instead of reading as locked.
192
+ function readAccessModeUndeclared(healthBody) {
193
+ if (!healthBody || typeof healthBody !== 'object') return null;
194
+ const flag = healthBody.accessModeUndeclared;
195
+ return typeof flag === 'boolean' ? flag : null;
196
+ }
197
+
179
198
  // A VM mid-provision: an Add (incl. its recovering re-run) that is queued
180
199
  // (number reserved, cloud:up not started) or running. Its public URL has no
181
200
  // meaningful readiness yet, so this takes precedence over every other signal —
@@ -201,6 +220,13 @@ function deriveVmReadiness(v, ctx) {
201
220
  // 2. Editor unreachable over the tunnel: we polled nothing, so broker / LB
202
221
  // health are unknowable. Link gated, exactly as the pre-readiness card did.
203
222
  if (!v || !v.reachable) return 'unreachable';
223
+ // 2b. Reachable, but the editor itself reports it serves no one: no access
224
+ // mode is declared on its public bind, so `/` is the "configure an access
225
+ // mode" page — a 200, which is why every HTTP check read it as healthy.
226
+ // Ahead of the LB / broker / CLI checks because none of their remedies
227
+ // help: only declaring a mode does. Only an EXPLICIT `true` fires; a
228
+ // binary predating the field leaves it null and falls through.
229
+ if (v.accessModeUndeclared === true) return 'access-locked';
204
230
  // 3. LB mode only: the public URL's fate is the editor backend's LB health.
205
231
  // Skipped entirely in legacy mode (no LB) or before the first probe lands
206
232
  // (no backendHealth) — both fall through to the reachability/broker path.
@@ -275,4 +301,5 @@ module.exports = {
275
301
  isProvisioning,
276
302
  classifyAgentUsability,
277
303
  agentRosterReady,
304
+ readAccessModeUndeclared,
278
305
  };