@codeyam-editor/codeyam-editor 0.1.9 → 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
@@ -23,6 +23,11 @@ const {
23
23
  killChildProcess,
24
24
  } = require("./utils");
25
25
  const { open } = require("./open");
26
+ // Bounded gcloud/ssh spawns run through spawnGroupBoundedSync so a timeout takes
27
+ // gcloud's ssh descendant down with it instead of orphaning it to PID 1; the
28
+ // reaper sweeps sessions already leaked before that existed.
29
+ const { spawnGroupBoundedSync } = require("./group-bounded-spawn");
30
+ const { reapOrphanedFleetSsh } = require("./orphan-ssh-reaper");
26
31
  // `FORWARD_ENV_KEYS_ENV` is the env-var name (`CY_FORWARD_ENV_KEYS`) the
27
32
  // fleet dashboard sets on `cloud:up`'s child to name the subset of
28
33
  // `process.env` keys that came from the operator-supplied launch form.
@@ -110,6 +115,9 @@ const { addToRoster, readRoster } = require("./fleet-roster");
110
115
  // container) apart from a real `docker compose up -d` failure, so the abort path
111
116
  // can converge instead of escalating to instance deletion.
112
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");
113
121
 
114
122
  // =============================================================================
115
123
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -1090,6 +1098,10 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
1090
1098
  // reference one value. 2 min is generous for any single describe/update/SSH probe
1091
1099
  // yet comfortably under the 600000 ms outer reconcile budget.
1092
1100
  //
1101
+ // All three spawn through spawnGroupBoundedSync, because a bare spawnSync timeout
1102
+ // signals only the direct child: gcloud died and its `ssh` survived, re-parented
1103
+ // to PID 1 — the budget bounded the wrapper, never the descendant.
1104
+ //
1093
1105
  // It is a PROBE budget, and provisioning is exempt — see
1094
1106
  // GCLOUD_PROVISION_TIMEOUT_MS below. Do not re-collapse the two: raising this
1095
1107
  // constant to accommodate a slow create would un-bound the reconcile sweep,
@@ -1161,7 +1173,7 @@ function isSpawnTimeout(result) {
1161
1173
  }
1162
1174
 
1163
1175
  function runGcloud(args, opts = {}) {
1164
- return spawnSync("gcloud", args, {
1176
+ return spawnGroupBoundedSync("gcloud", args, {
1165
1177
  stdio: opts.stdio || "inherit",
1166
1178
  input: opts.input,
1167
1179
  encoding: opts.encoding || "utf8",
@@ -1531,6 +1543,7 @@ async function waitForIapReady(cfg) {
1531
1543
  const deadline = Date.now() + 180_000;
1532
1544
  let attempts = 0;
1533
1545
  let lastErr = "";
1546
+ const probeStderrs = [];
1534
1547
  while (Date.now() < deadline) {
1535
1548
  attempts++;
1536
1549
  const probe = runGcloud(
@@ -1541,17 +1554,42 @@ async function waitForIapReady(cfg) {
1541
1554
  console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
1542
1555
  return;
1543
1556
  }
1544
- 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] || "";
1545
1560
  // Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
1546
1561
  const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
1547
1562
  await new Promise((r) => setTimeout(r, delay));
1548
1563
  }
1564
+ const cause = iapProbeCause(probeStderrs);
1549
1565
  throw new Error(
1550
1566
  `IAP did not become ready for ${cfg.instanceName} within 180s ` +
1551
- `(${attempts} probes; last error: ${lastErr || "unknown"})`,
1567
+ `(${attempts} probes; ${cause ? `likely cause: ${cause}; ` : ""}` +
1568
+ `last error: ${lastErr || "unknown"})`,
1552
1569
  );
1553
1570
  }
1554
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
+
1555
1593
  /** Read `process.env[FORWARD_ENV_KEYS_ENV]` (comma-separated key list set by
1556
1594
  * the fleet dashboard) and return the subset of `process.env` that should
1557
1595
  * ride along into the VM's project-bootstrap shell. Whitelisted to the
@@ -2207,6 +2245,20 @@ function buildReconcileAdminCommand(repoName, value) {
2207
2245
  );
2208
2246
  }
2209
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
+
2210
2262
  async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2211
2263
  const repoName = resolveProject(cfg).name;
2212
2264
  console.error(`Checking project bootstrap state for ${repoName}...`);
@@ -2236,13 +2288,16 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2236
2288
  console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
2237
2289
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
2238
2290
  const scriptBody = readStreamedScript(scriptPath, cfg);
2239
- // gcp-project-bootstrap.sh sources lib/npm-pin.sh relative to its own
2240
- // directory. Read it through readStreamedScript too, so the library is subject
2241
- // to the same drift guard as the script that sources it — streaming a reviewed
2242
- // remote script beside a stale on-disk library would defeat the guard.
2243
- const bootstrapLibs = {
2244
- "lib/npm-pin.sh": readStreamedScript(path.join(rootDir, "scripts", "lib", "npm-pin.sh"), cfg),
2245
- };
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
+ );
2246
2301
  const scriptArgs = clientProjectBootstrapArgs(cfg);
2247
2302
  // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
2248
2303
  // toggle — see projectBootstrapEnv for what each key is for and when it is
@@ -3626,14 +3681,18 @@ function promptForInstanceName(expected) {
3626
3681
  }
3627
3682
 
3628
3683
  // Every file streamed to a VM, and therefore every file the drift guard must
3629
- // compare against the reviewed remote tip. `scripts/lib/npm-pin.sh` is here
3630
- // because gcp-project-bootstrap.sh SOURCES it: a library that reaches the VM is
3631
- // as much streamed content as the script that sources it, and leaving it out
3632
- // 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.
3633
3692
  const STREAMED_SCRIPTS = [
3634
- "scripts/gcp-bootstrap.sh",
3635
- "scripts/gcp-project-bootstrap.sh",
3636
- "scripts/lib/npm-pin.sh",
3693
+ BOOTSTRAP_SCRIPT,
3694
+ PROJECT_BOOTSTRAP_SCRIPT,
3695
+ ...PROJECT_BOOTSTRAP_LIBS.map((rel) => `scripts/${rel}`),
3637
3696
  ];
3638
3697
 
3639
3698
  /** The configured editor branch to compare/stream streamed scripts against.
@@ -4966,6 +5025,10 @@ async function cmdIngressUp(args, projectDir = rootDir) {
4966
5025
  async function cmdReconcileIngress(args, projectDir = rootDir) {
4967
5026
  ensureGcloud();
4968
5027
  parseFlags(args);
5028
+ // Clear ssh sessions earlier timed-out probes leaked before this sweep can add
5029
+ // load of its own. Only PID-1 orphans with the fleet signature — never a live
5030
+ // sweep's in-flight children.
5031
+ reapOrphanedFleetSsh();
4969
5032
  const cfg = loadCloudConfig(projectDir) || {};
4970
5033
  const domain = resolveIngressDomain(cfg, process.env);
4971
5034
  if (!domain) {
@@ -4996,7 +5059,7 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
4996
5059
  // whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
4997
5060
  // returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
4998
5061
  // this VM and the sweep continues to the next one.
4999
- const res = spawnSync(inv.command, inv.args, {
5062
+ const res = spawnGroupBoundedSync(inv.command, inv.args, {
5000
5063
  env: { ...process.env, ...inv.env },
5001
5064
  encoding: "utf8",
5002
5065
  timeout: GCLOUD_CALL_TIMEOUT_MS,
@@ -5057,7 +5120,7 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
5057
5120
  // Bound the per-BES converge too: a hung `gcloud backend-services update`
5058
5121
  // returns a non-zero (timed-out) result, so it falls into the skip branch
5059
5122
  // below and the sweep moves on to the next BES rather than stalling.
5060
- const res = spawnSync(inv.command, inv.args, {
5123
+ const res = spawnGroupBoundedSync(inv.command, inv.args, {
5061
5124
  encoding: "utf8",
5062
5125
  timeout: GCLOUD_CALL_TIMEOUT_MS,
5063
5126
  });
@@ -5634,6 +5697,7 @@ async function cloudMain() {
5634
5697
  module.exports = {
5635
5698
  // pure helpers
5636
5699
  isSpawnTimeout,
5700
+ iapProbeCause,
5637
5701
  gcloudCallTimeoutMs,
5638
5702
  provisionFailureMessage,
5639
5703
  GCLOUD_PROVISION_TIMEOUT_MS,
@@ -5747,6 +5811,7 @@ module.exports = {
5747
5811
  gatherScriptDriftFacts,
5748
5812
  readStreamedScript,
5749
5813
  STREAMED_SCRIPTS,
5814
+ PROJECT_BOOTSTRAP_LIBS,
5750
5815
  // ingress wiring (pure decision + spawn wrappers)
5751
5816
  envPosInt,
5752
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 };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-auth-account.js — the operator's chosen fleet Claude account
4
+ // (`.codeyam/logs/fleet-auth-account.json`). This is the account a newly added
5
+ // VM authenticates as: the Add path picks a credential donor holding it, and the
6
+ // Add dialog preselects it. Persisted so the choice survives a dashboard restart,
7
+ // and explicit so a background token refresh on some other VM can never re-point
8
+ // it (the drift the derived default in fleet-auth.js is subject to).
9
+ //
10
+ // Shape on disk: { "email": "<account email>", "updatedAt": "<ISO>" }. A seam
11
+ // modeled on fleet-vm-slugs.js: atomic temp-file + rename, tolerant of a
12
+ // missing/malformed file (both read as "never chosen" → null).
13
+ //
14
+ // Env override: `FLEET_AUTH_ACCOUNT_FILE` relocates the file (mirrors
15
+ // fleet-roster.js's `VMS_STATE_FILE`); resolved once at module load.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const { rootDir } = require('./utils');
21
+ const { isAccountEmail } = require('./fleet-auth');
22
+
23
+ const FLEET_AUTH_ACCOUNT_FILE = process.env.FLEET_AUTH_ACCOUNT_FILE
24
+ || path.join(rootDir, '.codeyam', 'logs', 'fleet-auth-account.json');
25
+
26
+ /** The chosen account email, or null when none has been chosen (file missing,
27
+ * malformed, or carrying no email). */
28
+ function readAuthAccount() {
29
+ try {
30
+ const data = JSON.parse(fs.readFileSync(FLEET_AUTH_ACCOUNT_FILE, 'utf8'));
31
+ if (data && typeof data.email === 'string' && data.email.trim()) return data.email.trim();
32
+ } catch { /* missing or malformed */ }
33
+ return null;
34
+ }
35
+
36
+ /** Persist `email` as the fleet's chosen account, atomically: write a temp file
37
+ * then `rename` it over the target so a concurrent reader never observes a
38
+ * half-written file. Returns the email written. A value that is not a
39
+ * plausible email is rejected (nothing is written) — forgetting the choice is
40
+ * `clearAuthAccount`. */
41
+ function writeAuthAccount(email) {
42
+ const e = typeof email === 'string' ? email.trim() : '';
43
+ if (!isAccountEmail(e)) throw new Error(`not a valid account email: "${e}"`);
44
+ fs.mkdirSync(path.dirname(FLEET_AUTH_ACCOUNT_FILE), { recursive: true });
45
+ const tmp = `${FLEET_AUTH_ACCOUNT_FILE}.${process.pid}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify({ email: e, updatedAt: new Date().toISOString() }));
47
+ fs.renameSync(tmp, FLEET_AUTH_ACCOUNT_FILE); // atomic replace
48
+ return e;
49
+ }
50
+
51
+ /** Forget the chosen account, so the fleet falls back to the derived default.
52
+ * Removing an absent file is a no-op. */
53
+ function clearAuthAccount() {
54
+ try {
55
+ fs.unlinkSync(FLEET_AUTH_ACCOUNT_FILE);
56
+ } catch (e) {
57
+ if (e.code !== 'ENOENT') throw e;
58
+ }
59
+ }
60
+
61
+ module.exports = { FLEET_AUTH_ACCOUNT_FILE, readAuthAccount, writeAuthAccount, clearAuthAccount };
package/npm/fleet-auth.js CHANGED
@@ -108,9 +108,117 @@ function deriveDefaultAuthAccount(vms, lastAuthAt) {
108
108
  return best ? best.account : null;
109
109
  }
110
110
 
111
+ // Enumerate the Claude accounts the fleet can seed a new VM from: one row per
112
+ // distinct account email held by an authenticated VM, carrying the account
113
+ // object and the VM numbers holding it (so the Add dialog can show
114
+ // "jared@codeyam.com — VM-14"). Pure over the polled VM snapshot — the same
115
+ // array deriveDefaultAuthAccount reads. Emails compare case-insensitively; rows
116
+ // sort by email and each row's VM numbers ascend, so the result is deterministic.
117
+ function listAvailableAccounts(vms) {
118
+ const byEmail = new Map();
119
+ for (const v of vms || []) {
120
+ const pa = v && v.providerAuth;
121
+ const acct = pa && pa.account;
122
+ if (!acct || !acct.email || pa.state !== 'authenticated') continue;
123
+ const key = String(acct.email).toLowerCase();
124
+ const row = byEmail.get(key) || { account: acct, vms: [] };
125
+ if (!row.vms.includes(v.n)) row.vms.push(v.n);
126
+ byEmail.set(key, row);
127
+ }
128
+ return [...byEmail.values()]
129
+ .map((r) => ({ account: r.account, vms: [...r.vms].sort((a, b) => a - b) }))
130
+ .sort((a, b) => String(a.account.email).localeCompare(String(b.account.email)));
131
+ }
132
+
133
+ // Pick the credential donor for a chosen account: the lowest-numbered VM in
134
+ // `reachableDonors` whose authenticated account is `email`, or null when no
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`). 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.
141
+ function selectDonorForAccount(reachableDonors, vms, email) {
142
+ if (!email) return null;
143
+ const want = String(email).toLowerCase();
144
+ for (const n of [...(reachableDonors || [])].sort((a, b) => a - b)) {
145
+ const pa = vms && vms[n] && vms[n].providerAuth;
146
+ if (pa && pa.credentialIntact === false) continue;
147
+ const got = pa && pa.state === 'authenticated' && pa.account && pa.account.email;
148
+ if (got && String(got).toLowerCase() === want) return n;
149
+ }
150
+ return null;
151
+ }
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
+
186
+ // A plausible Claude account email: one "@", a dotted domain, no whitespace or
187
+ // shell-significant characters. The shared guard for every place an operator
188
+ // can name an account (the Add form's per-launch override, the set-default
189
+ // action, the persisted sidecar). Deliberately NOT checked against the fleet,
190
+ // whose roster can change between naming an account and provisioning with it.
191
+ const ACCOUNT_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
192
+ function isAccountEmail(s) {
193
+ return typeof s === 'string' && ACCOUNT_EMAIL_RE.test(s.trim());
194
+ }
195
+
196
+ // Resolve the fleet's chosen Claude account email: the operator's persisted
197
+ // choice (`saved`, read from npm/fleet-auth-account.js by the caller) when it is
198
+ // a plausible email, else the derived default — the most recently auth-updated
199
+ // VM's account — so a fleet that has never chosen behaves as it did before the
200
+ // picker existed. Pure. Returns an email string, or null when neither exists.
201
+ function resolveChosenAccount(saved, vms, lastAuthAt) {
202
+ if (isAccountEmail(saved)) return saved.trim();
203
+ const derived = deriveDefaultAuthAccount(vms, lastAuthAt);
204
+ return (derived && derived.email) || null;
205
+ }
206
+
111
207
  // Return true if this one VM is eligible for an auth-start action now.
112
208
  function isAuthStartable(v, job) {
113
209
  return classifyAuthVm(v, job).eligible;
114
210
  }
115
211
 
116
- module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable };
212
+ module.exports = {
213
+ classifyAuthVm,
214
+ selectAuthTargets,
215
+ summarizeAuthBatch,
216
+ deriveDefaultAuthAccount,
217
+ listAvailableAccounts,
218
+ selectDonorForAccount,
219
+ partitionDonorsByIntegrity,
220
+ applyCredentialIntegrity,
221
+ isAccountEmail,
222
+ resolveChosenAccount,
223
+ isAuthStartable,
224
+ };