@codeyam-editor/codeyam-editor 0.1.11-staging.gf7d5b1a → 0.1.12-staging.g83e4a0c

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
@@ -3197,10 +3243,18 @@ async function seedPreviewOrigin(
3197
3243
  * Best-effort and non-fatal by contract: a probe failure, a still-absent
3198
3244
  * stack.json (verdict stays `true`), or a heal that can only reach a loopback
3199
3245
  * bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
3200
- * leave the add green. The cloudflared-monitor's periodic tick (section 2) is
3201
- * the durable owner that heals post-add flips — onboarding finishing minutes
3202
- * after READY. */
3203
- async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
3246
+ * leave the add green.
3247
+ *
3248
+ * TWO TRIGGERS, and the second is the point. This used to be called only from
3249
+ * the add/READY path, and its own comment named "the cloudflared-monitor's
3250
+ * periodic tick" as the durable owner of post-add flips. That owner does not
3251
+ * exist on this fleet: the fleet runs on the GCP HTTPS LB, there is no
3252
+ * cloudflared tick, and so a VM that developed the failure MID-LIFE — the
3253
+ * normal case, since the failure needs an app to exist before it can fail to
3254
+ * hydrate — was never healed by anything. It is now also called per-VM from
3255
+ * `cmdReconcileIngress`, the ~15-minute roster sweep that genuinely does run,
3256
+ * which is what makes a mid-life flip self-heal without an Add. */
3257
+ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPort = null } = {}) {
3204
3258
  const probe = runGcloud(
3205
3259
  buildGcloudArgs("ssh-probe", cfg, {
3206
3260
  command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
@@ -3236,7 +3290,14 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
3236
3290
  // inside the seed answers `true` and re-writes the same-origin sentinel that
3237
3291
  // just failed to hydrate. We got here precisely BECAUSE the editor said
3238
3292
  // `false`, so `false` is the answer the seed must use.
3239
- await seedPreviewOrigin(cfg, appPort, false);
3293
+ // Resolve the real app port only NOW, past the two guards above, because
3294
+ // reading it costs an SSH round-trip per VM. The add path already has the port
3295
+ // in hand and passes it directly; the periodic sweep passes a resolver instead,
3296
+ // so a fleet of healthy VMs pays nothing and only a VM actually being healed
3297
+ // pays the read. Falls back to whatever `appPort` already is if the resolver
3298
+ // fails, which is the same VITE_PORT bootstrap default it had before.
3299
+ const seedPort = resolveAppPort ? ((await resolveAppPort()) ?? appPort) : appPort;
3300
+ await seedPreviewOrigin(cfg, seedPort, false);
3240
3301
  // Tell the running server to re-read editor.local.json — no restart (the
3241
3302
  // footgun that killed VM7's live Claude session).
3242
3303
  const reload = runGcloud(
@@ -3635,14 +3696,18 @@ function promptForInstanceName(expected) {
3635
3696
  }
3636
3697
 
3637
3698
  // 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.
3699
+ // compare against the reviewed remote tip. The libraries are here because
3700
+ // gcp-project-bootstrap.sh SOURCES them: a library that reaches the VM is as
3701
+ // much streamed content as the script that sources it, and leaving one out meant
3702
+ // the drift guard silently vouched for only half of what was shipped. They are
3703
+ // DERIVED from PROJECT_BOOTSTRAP_LIBS rather than listed again — when the two
3704
+ // were maintained separately, a library added to neither took the whole fleet's
3705
+ // provisioning down, and a library added to only one would have been shipped
3706
+ // unvouched-for.
3642
3707
  const STREAMED_SCRIPTS = [
3643
- "scripts/gcp-bootstrap.sh",
3644
- "scripts/gcp-project-bootstrap.sh",
3645
- "scripts/lib/npm-pin.sh",
3708
+ BOOTSTRAP_SCRIPT,
3709
+ PROJECT_BOOTSTRAP_SCRIPT,
3710
+ ...PROJECT_BOOTSTRAP_LIBS.map((rel) => `scripts/${rel}`),
3646
3711
  ];
3647
3712
 
3648
3713
  /** The configured editor branch to compare/stream streamed scripts against.
@@ -4965,6 +5030,40 @@ async function cmdIngressUp(args, projectDir = rootDir) {
4965
5030
  );
4966
5031
  }
4967
5032
 
5033
+ /** Re-seed the preview origin on any rostered VM whose editor reports the
5034
+ * subpath-hydration escalation but whose configured origin never followed.
5035
+ *
5036
+ * The second trigger for `healPreviewOriginAtReady`, whose only other caller is
5037
+ * the add/READY path — so a VM that developed the failure MID-LIFE was never
5038
+ * reached by it. That is the normal way this failure appears (it needs an app to
5039
+ * exist before the app can fail to hydrate), and it is why the same bug recurred
5040
+ * on four VMs that had each been added long before they broke.
5041
+ *
5042
+ * Runs over the WHOLE roster, not just the sweep's re-wire targets: a VM can be
5043
+ * perfectly wired at the load balancer and still be serving its preview from the
5044
+ * wrong origin — the two conditions are unrelated, and narrowing to the re-wire
5045
+ * set would miss exactly the healthy-but-wrong VM this exists for. Safe to do so
5046
+ * because the heal's own two guards return early unless the editor reports a
5047
+ * not-same-origin-safe stack AND a wrong-mode origin, so a healthy VM costs one
5048
+ * bounded probe and writes nothing; the app-port read is deferred behind those
5049
+ * guards, so only a VM actually being healed pays for it.
5050
+ *
5051
+ * Per-VM failures are logged and swallowed: a preview-origin heal must never
5052
+ * abort an ingress sweep that has other VMs left to reconcile. */
5053
+ async function healPreviewOriginsAcrossRoster(cfg, roster) {
5054
+ for (const n of roster) {
5055
+ const label = slugFor(n) || `vm-${n}`;
5056
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
5057
+ try {
5058
+ await healPreviewOriginAtReady(perVmCfg, VITE_PORT, {
5059
+ resolveAppPort: () => readVmAppPort(perVmCfg),
5060
+ });
5061
+ } catch (e) {
5062
+ console.error(` VM-${n} ${label}: preview-origin heal skipped (${e && e.message})`);
5063
+ }
5064
+ }
5065
+ }
5066
+
4968
5067
  /** Detect unwired / half-wired rostered VMs and re-wire them idempotently.
4969
5068
  * Enumerates the fleet roster (the same source the dashboard reads), runs the
4970
5069
  * read-only `vm-ingress.sh <N> status` probe per VM, and for any reporting
@@ -5054,6 +5153,8 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
5054
5153
  );
5055
5154
  }
5056
5155
 
5156
+ await healPreviewOriginsAcrossRoster(cfg, roster);
5157
+
5057
5158
  // Converge each rostered backend-service's timeout to 86400 (24h). A fully-wired
5058
5159
  // BES still at GCP's 30s default reports `wired` above and is skipped by the
5059
5160
  // re-wire loop, yet on an External HTTPS LB that 30s is the MAX WebSocket lifetime
@@ -5647,6 +5748,7 @@ async function cloudMain() {
5647
5748
  module.exports = {
5648
5749
  // pure helpers
5649
5750
  isSpawnTimeout,
5751
+ iapProbeCause,
5650
5752
  gcloudCallTimeoutMs,
5651
5753
  provisionFailureMessage,
5652
5754
  GCLOUD_PROVISION_TIMEOUT_MS,
@@ -5713,6 +5815,7 @@ module.exports = {
5713
5815
  buildEditorLocalSeedWriteCommand,
5714
5816
  seedPreviewOrigin,
5715
5817
  healPreviewOriginAtReady,
5818
+ healPreviewOriginsAcrossRoster,
5716
5819
  buildGcloudArgs,
5717
5820
  sshScriptCommand,
5718
5821
  bootstrapRunnerScript,
@@ -5760,6 +5863,7 @@ module.exports = {
5760
5863
  gatherScriptDriftFacts,
5761
5864
  readStreamedScript,
5762
5865
  STREAMED_SCRIPTS,
5866
+ PROJECT_BOOTSTRAP_LIBS,
5763
5867
  // ingress wiring (pure decision + spawn wrappers)
5764
5868
  envPosInt,
5765
5869
  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,