@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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.
Files changed (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/cloud.js CHANGED
@@ -11,6 +11,8 @@
11
11
 
12
12
  const { spawn, spawnSync } = require("child_process");
13
13
  const fs = require("fs");
14
+ const https = require("https");
15
+ const os = require("os");
14
16
  const path = require("path");
15
17
  const readline = require("readline");
16
18
  const {
@@ -33,12 +35,76 @@ const { FORWARD_ENV_KEYS_ENV } = require("./fleet-env");
33
35
  // stable `https://vm-<N>.preview.<domain>` previewOrigin from its number. When
34
36
  // no domain is configured these stay dormant and the legacy quick-tunnel seed
35
37
  // path runs unchanged.
36
- const { resolveFleetDomain, vmUrls } = require("./vm-urls");
37
- // Shared fleet-roster writer. `cloud:up` rosters the VM it just provisioned so
38
- // the dashboard, the cloudflared-monitor tunnel publisher, and the watchdog see
39
- // it within one poll cycle — without a manual fleet-vms.json edit (the
40
- // invisible-VM gap improve38 closes).
41
- const { addToRoster } = require("./fleet-roster");
38
+ const { resolveFleetDomain, vmUrls, normalizeVmSlug } = require("./vm-urls");
39
+ // Per-VM custom-slug sidecar (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an
40
+ // optional human-readable override for a VM's leftmost hostname label; Add
41
+ // persists it (`setSlug`), Add/Destroy resolve it (`slugFor`) to route the same
42
+ // `<slug>.<surface>.<domain>` host, and Destroy (`cmdDestroy`) clears it
43
+ // unconditionally (`removeSlug`, decoupled from LB ingress teardown). Absent
44
+ // ⇒ the numeric `vm-<N>` scheme is unchanged.
45
+ const {
46
+ slugFor,
47
+ vmForSlug,
48
+ setSlug,
49
+ removeSlug,
50
+ } = require("./fleet-vm-slugs");
51
+ // Per-VM IAP access whitelist sidecar (`.codeyam/logs/fleet-vm-access.json`).
52
+ // Resource-level grants so a named collaborator can reach ONE VM's surfaces:
53
+ // Add *reconciles* the whitelist to exactly the requested list (`accessFor` +
54
+ // `setAccess`), revoking a reused number's stale grants for a clean slate; the
55
+ // edit CLI mutates the delta (`addAccess`/`removeAccess`); and Destroy revokes +
56
+ // clears (`accessFor` + `clearAccess`). Independent of the owner's project-wide grant.
57
+ const {
58
+ normalizeAccessList,
59
+ normalizePrincipal,
60
+ resolveDefaultOperator,
61
+ withDefaultOperator,
62
+ accessFor,
63
+ setAccess,
64
+ addAccess,
65
+ removeAccess,
66
+ clearAccess,
67
+ } = require("./fleet-vm-access");
68
+ // LB-ingress shell-invocation builders (pure; unit-tested in ingress-invoke.test.js).
69
+ // `resolveIngressDomain` reads the fleet domain from cloud.json (`fleetDomain`)
70
+ // with a FLEET_DOMAIN env fallback — null ⇒ legacy quick-tunnel mode, where the
71
+ // Add/Destroy path is unchanged. The *Invocation builders shape the exact
72
+ // `{ command, args, env }` cloud.js spawns for scripts/gcp/{google,vm}-ingress.sh.
73
+ const {
74
+ resolveIngressDomain,
75
+ resolveDnsZone,
76
+ regionFromZone,
77
+ vmIngressInvocation,
78
+ vmIngressStatusInvocation,
79
+ vmIngressGrantAccessInvocation,
80
+ parseIngressStatusVerdict,
81
+ parseIngressTeardownVerdict,
82
+ selectIngressReconcileTargets,
83
+ selectIngressGrantOnlyTargets,
84
+ selectOrphanIngressTargets,
85
+ classifyOrphanDownResult,
86
+ backendServicesListInvocation,
87
+ negListInvocation,
88
+ instancesListInvocation,
89
+ ingressConvergeDecision,
90
+ detachedAddTailInvocation,
91
+ ingressInflightMarkerPath,
92
+ ingressFailedMarkerPath,
93
+ iapAccessInvocation,
94
+ sharedIngressInvocation,
95
+ backendServiceTimeoutInvocation,
96
+ FLEET_FIREWALL_RULE_NAMES,
97
+ fleetFirewallDescribeInvocation,
98
+ fleetFirewallConvergeInvocation,
99
+ firewallNeedsConverge,
100
+ firewallAdmitsPort,
101
+ } = require("./ingress-invoke");
102
+ // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
103
+ // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
104
+ // the watchdog see it within one poll cycle — without a manual fleet-vms.json
105
+ // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
106
+ // fleet for `reconcile-ingress`.
107
+ const { addToRoster, readRoster } = require("./fleet-roster");
42
108
 
43
109
  // =============================================================================
44
110
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -73,14 +139,62 @@ const KNOWN_FLAGS = [
73
139
  // runs setup by default when the project ships one; pass this for projects
74
140
  // where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
75
141
  "skip-setup",
142
+ // Fleet LB-ingress domain (e.g. fleet.codeyam.com). When set + persisted in
143
+ // cloud.json, Add wires the VM onto the shared Google HTTPS LB and Destroy
144
+ // unwires it; unset = legacy quick-tunnel mode (Add/Destroy unchanged). The
145
+ // Cloud DNS managed-zone name defaults to fleet-zone; override with --dns-zone.
146
+ "fleet-domain",
147
+ "dns-zone",
148
+ // Optional human-readable hostname slug for THIS VM (single VM only). When
149
+ // set on a numbered fleet VM with a fleet domain, its public URLs become
150
+ // `<slug>.editor.<domain>` / `<slug>.preview.<domain>` instead of the
151
+ // number-derived `vm-<N>...`. Persisted in the slug sidecar (NOT cloud.json,
152
+ // like --no-tunnel), so a later bare `cloud:up` doesn't re-apply a stale slug.
153
+ "vm-slug",
154
+ // Per-VM IAP access whitelist: comma-separated principals (user:/group:/domain:,
155
+ // a bare email defaults to user:) granted resource-level access to THIS VM's
156
+ // surfaces on Add. Persisted in the access sidecar (NOT cloud.json, like
157
+ // --vm-slug), so a later bare `cloud:up` doesn't re-apply a stale list. Edited
158
+ // after the fact with `cloud:access`.
159
+ "vm-access",
160
+ // `cloud:access` edit-an-existing-VM flags: add/remove one principal, or list
161
+ // the current whitelist. Single-VM, resolved from --instance-name.
162
+ "add",
163
+ "remove",
164
+ "list",
165
+ // Escape hatch for an intentionally read-only VM: suppress the fail-loud /
166
+ // degraded-badge when no GitHub push token resolves at provision time. The
167
+ // default is loud — a tokenless VM cannot sync plan claims and must never
168
+ // report healthy silently. Use only when push genuinely isn't wanted.
169
+ "allow-no-git-credentials",
170
+ // Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
171
+ // so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
172
+ // cloud:up …`; absent = strict non-admin (the safe default). Survives
173
+ // re-provision because it rides the compose-render substitution, unlike a
174
+ // manual VM compose edit.
175
+ "admin",
176
+ // GCE machine type for the VM (e.g. n2-standard-4, e2-standard-4). Persisted
177
+ // in cloud.json so a later bare `cloud:up` keeps it. Defaults to
178
+ // DEFAULT_FLEET_MACHINE_TYPE. The whole point of exposing this is fleet
179
+ // scale-out: the region's E2_CPUS quota (72 in us-central1) caps the fleet at
180
+ // ~14 e2-standard-4 VMs, while N2_CPUS/N2D_CPUS (200, unused) leave room for
181
+ // 30 — so the fleet defaults to N2 and this flag lets a caller override.
182
+ "machine-type",
76
183
  ];
77
184
 
78
185
  // Flags that take no value (presence = true). parseFlags must not demand a
79
186
  // value for these or `--no-tunnel` would error "requires a value".
80
- const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
187
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
81
188
 
82
189
  const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
83
190
  const INSTANCE_TAG = "codeyam-editor";
191
+ // Default GCE machine type for fleet VMs. N2 (not E2) because the fleet must
192
+ // reach 30 VMs: us-central1 E2_CPUS quota is 72, so 30 × e2-standard-4 (120
193
+ // vCPU) + the operator host exceed it and cap the fleet at ~14 regardless of
194
+ // code — while N2_CPUS/N2D_CPUS are 200 and unused, leaving headroom for 30 ×
195
+ // n2-standard-4. Override per-VM with `--machine-type`. See the vm--scale-
196
+ // fleet-to-30-vms plan for the measured quota rationale.
197
+ const DEFAULT_FLEET_MACHINE_TYPE = "n2-standard-4";
84
198
  const EDITOR_PORT = 14199;
85
199
  const VITE_PORT = 5173;
86
200
 
@@ -320,6 +434,7 @@ function resolveSeedPreviewOrigin(
320
434
  appPort = VITE_PORT,
321
435
  env = process.env,
322
436
  sameOriginSafe = true,
437
+ slug = null,
323
438
  ) {
324
439
  // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
325
440
  // same-origin under the reserved relative path, in every fleet mode. The
@@ -348,15 +463,27 @@ function resolveSeedPreviewOrigin(
348
463
  // client project (vm == null) keeps the localhost seed even with a domain set.
349
464
  if (domain && vm != null) {
350
465
  return {
351
- previewOrigin: vmUrls(vm, domain).preview,
466
+ // A custom slug overrides the leftmost `vm-<N>` label so the iframe origin
467
+ // matches the slug host the LB routes; absent, this is the numeric host.
468
+ previewOrigin: vmUrls(vm, domain, slug).preview,
469
+ _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
470
+ };
471
+ }
472
+ // Quick-tunnel / domain-free mode: support both 'local' (default) and 'tunnel' modes.
473
+ // In 'local' mode, we use http://localhost:<15172 + VM_NUMBER> directly.
474
+ // In 'tunnel' mode, we prefer the operator's published quick-tunnel URL.
475
+ const mode = env.PREVIEW_ORIGIN_MODE || "local";
476
+ if (mode === "local" && vm != null) {
477
+ // http://localhost inside an https:// editor page is allowed.
478
+ // Browsers treat http://localhost as a potentially-trustworthy origin,
479
+ // so the mixed-content blocker does not apply.
480
+ const previewPort = 15172 + vm;
481
+ return {
482
+ previewOrigin: `http://localhost:${previewPort}`,
352
483
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
353
484
  };
354
485
  }
355
- // Quick-tunnel / domain-free mode: prefer the operator's published
356
- // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
357
- // actually load), else the SSH-tunnel bootstrap localhost origin
358
- // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
359
- // the same-origin subpath — that is exactly what recurses for this stack.
486
+ // Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
360
487
  const previewKey = vm != null ? `${vm}-preview` : null;
361
488
  const tunnelUrl =
362
489
  previewKey &&
@@ -411,6 +538,72 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
411
538
  );
412
539
  }
413
540
 
541
+ // A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
542
+ // `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
543
+ // path, or query. These are reachable only from the editor host itself (or a
544
+ // laptop `-L` forward), never from a remote browser — exactly the dead-end the
545
+ // dedicated_preview_origin fallback lands on when the seed was wrong-moded.
546
+ const LOOPBACK_ORIGIN_RE =
547
+ /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
548
+
549
+ /** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
550
+ * previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
551
+ * must be self-healed to a dedicated origin. This is the inverse of
552
+ * `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
553
+ * same-origin-safe default (or a loopback bootstrap) onto a client-routed
554
+ * stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
555
+ * connect`. True iff BOTH hold:
556
+ * - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
557
+ * keeps the `/__codeyam_preview` subpath — never heal it; that is
558
+ * `isRottedManagedPreviewOrigin`'s job in the other direction); and
559
+ * - the `previewOrigin` is the same-origin subpath sentinel, any other
560
+ * relative path, or a loopback/localhost origin — none of which a remote
561
+ * browser can load for a dedicated-origin stack. A real dedicated origin
562
+ * (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
563
+ * left alone, so re-running the heal once a good origin is in place is a
564
+ * no-op.
565
+ * Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
566
+ * `localJson` is a parsed editor.local.json object. */
567
+ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
568
+ if (sameOriginSafe) return false;
569
+ if (!localJson || typeof localJson !== "object") return false;
570
+ const origin = localJson.previewOrigin;
571
+ if (typeof origin !== "string" || origin.length === 0) return false;
572
+ // The reserved same-origin subpath, or any relative path (no scheme): these
573
+ // recurse into the editor SPA for a client-routed stack.
574
+ if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
575
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
576
+ // An absolute loopback/localhost origin: reachable only from the host, dead
577
+ // for a remote browser.
578
+ return LOOPBACK_ORIGIN_RE.test(origin);
579
+ }
580
+
581
+ /** Decide whether a VM is presenting as HEALTHY while its Live Preview is
582
+ * silently dead — the VM-9 failure mode. The heal
583
+ * (`reconcilePreviewOriginToSlugHost`) fixes the mechanism; this predicate is
584
+ * the smoke detector that catches a FUTURE mechanism gap (a new stack shape, a
585
+ * new provisioning path) by observation instead of by a user noticing a blank
586
+ * iframe days later.
587
+ *
588
+ * Input is the editor's own `/api/config` payload — the effective truth table
589
+ * (`previewSameOriginSafe` + the resolved `previewOrigin`), not a
590
+ * re-derivation from stack.json over ssh. True iff the stack is NOT
591
+ * same-origin-safe AND its effective previewOrigin is one a remote browser can
592
+ * never load (the `/__codeyam_preview` sentinel, any relative path, or a
593
+ * loopback origin) — i.e. exactly `isWrongModePreviewOrigin`'s wrong-mode
594
+ * condition, evaluated against the live config rather than editor.local.json.
595
+ * Composing the two keeps ONE definition of "wrong-mode origin" in this file.
596
+ *
597
+ * Deliberately conservative: a missing/unparseable payload, an absent
598
+ * `previewSameOriginSafe`, or a legitimate same-origin-safe stack on the
599
+ * subpath all return false. Absence of evidence is never a false alarm — the
600
+ * caller renders "unknown" instead. Pure. */
601
+ function isSilentlyBrokenPreview(apiConfig) {
602
+ if (!apiConfig || typeof apiConfig !== "object") return false;
603
+ if (apiConfig.previewSameOriginSafe !== false) return false;
604
+ return isWrongModePreviewOrigin(apiConfig, false);
605
+ }
606
+
414
607
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
415
608
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
416
609
  function readCloudflaredUrls() {
@@ -491,9 +684,25 @@ function editorImageBootstrapArgs(cfg) {
491
684
  * so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
492
685
  * — can't override the new intent. Without this, launching `--repo X` after a
493
686
  * greenfield run silently provisions the greenfield project (resolveProject
494
- * prioritizes newProject). Mutates and returns cfg. */
495
- function reconcileProjectExclusivity(cfg, flags = {}) {
496
- if (flags.repo) delete cfg.newProject;
687
+ * prioritizes newProject). Mutates and returns cfg.
688
+ *
689
+ * `prev` is the pre-merge persisted config (default `{}`). When `--repo`
690
+ * SWITCHES to a different repo and no `--branch` was passed this run, the
691
+ * inherited client branch is dropped: a persisted branch belongs to the OLD
692
+ * repo and may not exist on the new one. (The harvardintech VM was torn down
693
+ * because a `--repo harvardintech` re-up reused `editor-improvements-48` — a
694
+ * branch that lives only on the editor repo — and `git clone --branch` failed.)
695
+ * An explicit `--branch` this run always wins (the drop is gated on
696
+ * `!flags.branch`), and a same-repo re-up keeps its persisted branch — that is
697
+ * the legitimate "set the branch once, reuse it" workflow. `sameRepo` is used
698
+ * for the comparison so `.git`/case differences don't trigger a spurious drop. */
699
+ function reconcileProjectExclusivity(cfg, flags = {}, prev = {}) {
700
+ if (flags.repo) {
701
+ delete cfg.newProject;
702
+ if (!flags.branch && prev.repo && !sameRepo(prev.repo, flags.repo)) {
703
+ delete cfg.branch;
704
+ }
705
+ }
497
706
  if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
498
707
  return cfg;
499
708
  }
@@ -546,14 +755,27 @@ function loadCloudConfig(projectDir) {
546
755
  try {
547
756
  return JSON.parse(raw);
548
757
  } catch (e) {
549
- throw new Error(`Failed to parse ${p}: ${e.message}`);
758
+ try {
759
+ raw = fs.readFileSync(p, "utf8");
760
+ return JSON.parse(raw);
761
+ } catch (e2) {
762
+ throw new Error(`Failed to parse ${p}: ${e2.message}`);
763
+ }
550
764
  }
551
765
  }
552
766
 
553
767
  function saveCloudConfig(projectDir, cfg) {
554
768
  const p = cloudConfigPath(projectDir);
555
- fs.mkdirSync(path.dirname(p), { recursive: true });
556
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
769
+ const dir = path.dirname(p);
770
+ fs.mkdirSync(dir, { recursive: true });
771
+ const tmpPath = path.join(dir, `cloud.json.${process.pid}.tmp`);
772
+ try {
773
+ fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + "\n");
774
+ fs.renameSync(tmpPath, p);
775
+ } catch (e) {
776
+ fs.rmSync(tmpPath, { force: true });
777
+ throw e;
778
+ }
557
779
  }
558
780
 
559
781
  /** Build the JSON patch to merge into the VM's `editor.local.json` so the
@@ -579,6 +801,28 @@ function buildEditorLocalEnvPatch(localEnv, agent) {
579
801
  return { provider: agent, env: { [varName]: value } };
580
802
  }
581
803
 
804
+ /** Build the editor.local.json patch that seeds a GREENFIELD project's
805
+ * `projectTitle` so its live identity resolves to the real project name from
806
+ * first boot. A greenfield project (`--new-project <name>`, no clientRepo) has
807
+ * no origin remote and is bind-mounted at /workspace on the VM, so the editor's
808
+ * identity resolver would otherwise fall back to the bare mount basename
809
+ * "workspace" — which the fleet dashboard then reads as a permanent false
810
+ * project-dir-drift. Seeding `projectTitle` (deep-merged over editor.json on the
811
+ * VM) makes `project_identity::read` prefer the real name.
812
+ *
813
+ * Returns `null` for a clone (a repo Add already labels from its origin
814
+ * basename, and re-seeding a clone's title would be wrong) or when no
815
+ * greenfield name is available, so the caller can no-op without an SSH round
816
+ * trip. Pure — mirrors buildEditorLocalEnvPatch so the decision is unit-tested
817
+ * without a live VM. */
818
+ function buildGreenfieldTitlePatch(cfg = {}) {
819
+ const p = resolveProject(cfg);
820
+ if (!p.greenfield) return null;
821
+ const name = typeof p.name === 'string' ? p.name.trim() : '';
822
+ if (!name) return null;
823
+ return { projectTitle: name };
824
+ }
825
+
582
826
  /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
583
827
  * line) and return the editor service's `State` string, or null when the
584
828
  * service is absent / output is unparseable. Two callers shared an identical
@@ -621,7 +865,7 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
621
865
  return [
622
866
  "compute", "instances", "create", instanceName,
623
867
  "--project", project, "--zone", zone,
624
- "--machine-type=e2-standard-4",
868
+ `--machine-type=${cfg.machineType || DEFAULT_FLEET_MACHINE_TYPE}`,
625
869
  "--image-family=debian-12",
626
870
  "--image-project=debian-cloud",
627
871
  "--boot-disk-size=100GB",
@@ -721,11 +965,61 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
721
965
  // real use. Unit tests pin their exports so a refactor cannot drop them.
722
966
  // =============================================================================
723
967
 
968
+ // Per-call wall-clock budget for a single gcloud / SSH invocation. A hung
969
+ // `gcloud describe` (metadata / IAP hiccup) used to block the whole
970
+ // `cloud:reconcile-ingress` sweep forever — the only timeout in the reconcile
971
+ // path was the outer 10-minute exec budget in the dashboard, and that never
972
+ // cascaded to the orphaned descendants. Bounding every per-call spawn well under
973
+ // that outer budget means a single hang trips HERE first: the call returns a
974
+ // timed-out result (formatGcloudFailure renders it), the sweep skips that VM,
975
+ // and no descendant is left hanging to pile up. Shared by runGcloud and the two
976
+ // reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
977
+ // reference one value. 2 min is generous for any single describe/update/SSH probe
978
+ // yet comfortably under the 600000 ms outer reconcile budget.
979
+ const GCLOUD_CALL_TIMEOUT_MS = 120000;
980
+
981
+ // The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
982
+ // the repo root and every `apps[].dir`, each package's `postinstall`, then
983
+ // `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
984
+ // blocking `runGcloud("ssh-script")` call, so it inherited GCLOUD_CALL_TIMEOUT_MS
985
+ // (a budget whose own comment scopes it to "any single describe/update/SSH
986
+ // probe"). That SIGTERMd the gcloud child mid-clone; `spawnSync` then returned
987
+ // `status: null`, which `!== 0`, so the timeout was indistinguishable from a
988
+ // genuine remote failure and the caller's abort path DELETED A COMPLETELY
989
+ // HEALTHY VM.
990
+ //
991
+ // Observed twice: 2026-07-28 on client repo `lets-go-compost-portal` (a root
992
+ // postinstall of `playwright install chromium`, ~293 MB), and 2026-07-29
993
+ // provisioning six self-host `codeyam-editor` VMs, whose clone alone exceeds the
994
+ // probe budget — all six were built, torn down mid-clone, and reported as
995
+ // "scripts/gcp-project-bootstrap.sh failed on the VM".
996
+ //
997
+ // The install is now DETACHED (projectBootstrapLaunchCommand + a re-attaching
998
+ // follow loop, outcome read from VM-side state), so no single call carries the
999
+ // whole install any more. This constant is therefore the OVERALL deadline across
1000
+ // re-attaches — the point at which we stop waiting on a still-healthy VM and say
1001
+ // exactly that. Deliberately well above the outer reconcile budget: a long remote
1002
+ // install is not a step in a sweep, so the sweep-relative ceiling that governs
1003
+ // GCLOUD_CALL_TIMEOUT_MS does not apply to it.
1004
+ const PROJECT_BOOTSTRAP_TIMEOUT_MS = 40 * 60 * 1000;
1005
+
1006
+ /** Whether a `spawnSync` result is a LOCAL timeout kill rather than a remote
1007
+ * failure. `spawnSync` reports a timeout as `status: null` plus an ETIMEDOUT
1008
+ * error, so a bare `status !== 0` check conflates the two — which is what made
1009
+ * a timed-out bootstrap look like a broken one. Pure. */
1010
+ function isSpawnTimeout(result) {
1011
+ if (!result) return false;
1012
+ if (result.status !== null && result.status !== undefined) return false;
1013
+ const code = result.error && result.error.code;
1014
+ return code === "ETIMEDOUT" || result.signal === "SIGTERM";
1015
+ }
1016
+
724
1017
  function runGcloud(args, opts = {}) {
725
1018
  return spawnSync("gcloud", args, {
726
1019
  stdio: opts.stdio || "inherit",
727
1020
  input: opts.input,
728
1021
  encoding: opts.encoding || "utf8",
1022
+ timeout: opts.timeout != null ? opts.timeout : GCLOUD_CALL_TIMEOUT_MS,
729
1023
  });
730
1024
  }
731
1025
 
@@ -835,9 +1129,21 @@ function loadOrInitCloudConfig(projectDir, flags) {
835
1129
  const existing = loadCloudConfig(projectDir);
836
1130
  // noTunnel is a per-invocation behavior, never persisted — else a later manual
837
1131
  // `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
838
- const persist = (c) => { const { noTunnel, ...rest } = c; return rest; };
1132
+ // vmSlug is likewise a per-Add input that lives in the slug sidecar keyed by VM
1133
+ // number, never in this project config — else a later bare `cloud:up` would
1134
+ // re-stamp a stale slug onto the VM. vmAccess is the same shape: a per-Add input
1135
+ // that lives in the access sidecar, never in cloud.json.
1136
+ const persist = (c) => { const { noTunnel, vmSlug, vmAccess, ...rest } = c; return rest; };
839
1137
  if (existing) {
840
- const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
1138
+ const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags, existing);
1139
+ const { isPlaceholderProject } = require("./launch-config");
1140
+ if (isPlaceholderProject(merged.project)) {
1141
+ const configPath = cloudConfigPath(projectDir);
1142
+ throw new Error(
1143
+ `Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
1144
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
1145
+ );
1146
+ }
841
1147
  const toSave = persist(merged);
842
1148
  if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
843
1149
  saveCloudConfig(projectDir, toSave);
@@ -847,6 +1153,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
847
1153
 
848
1154
  const cfg = reconcileProjectExclusivity({ ...flags }, flags);
849
1155
  cfg.project = cfg.project || gcloudGetValue("config-get-project");
1156
+
1157
+ const { isPlaceholderProject } = require("./launch-config");
1158
+ if (isPlaceholderProject(cfg.project)) {
1159
+ throw new Error(
1160
+ `Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
1161
+ `Please configure a real GCP project or set the GCP_PROJECT environment variable.`
1162
+ );
1163
+ }
1164
+
850
1165
  cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
851
1166
  cfg.agent = cfg.agent || "claude";
852
1167
  agentEnvVarName(cfg.agent);
@@ -1190,7 +1505,7 @@ async function ensureBootstrapped(cfg, { sleepFn } = {}) {
1190
1505
  }
1191
1506
  console.error(" streaming scripts/gcp-bootstrap.sh to VM (this may take ~10 minutes)...");
1192
1507
  const scriptPath = path.join(rootDir, "scripts", "gcp-bootstrap.sh");
1193
- const scriptBody = fs.readFileSync(scriptPath, "utf8");
1508
+ const scriptBody = readStreamedScript(scriptPath, cfg);
1194
1509
  // GITHUB_TOKEN is passed through to the bootstrap script so it can clone
1195
1510
  // a private editor repo (the bootstrap clones $REPO_URL to build the
1196
1511
  // codeyam-editor:local image). Without this the git clone hits the
@@ -1307,7 +1622,305 @@ function bootstrapLogTail(cfg, lines = 40) {
1307
1622
  return String(r.stdout || "").trim() || "(log unavailable)";
1308
1623
  }
1309
1624
 
1310
- async function ensureProjectBootstrapped(cfg) {
1625
+ // ─── Detached PROJECT bootstrap ──────────────────────────────────────────────
1626
+ //
1627
+ // The VM bootstrap above solved a problem the PROJECT bootstrap still had: read
1628
+ // success and failure from VM-SIDE STATE, never from the SSH exit code, so a
1629
+ // dropped follow-session over a healthy VM is not misreported as "failed on the
1630
+ // VM". The project bootstrap ran instead as ONE blocking ssh-script call, so a
1631
+ // local budget elapsing (or an SSH pipe breaking) was indistinguishable from a
1632
+ // genuine remote failure and the caller's abort path DELETED A HEALTHY VM.
1633
+ // Raising the budget alone just moves the cliff; these helpers remove it by
1634
+ // mirroring the VM-bootstrap contract — detached launch, re-attaching follow
1635
+ // loop, VM-side outcome probe.
1636
+ //
1637
+ // All VM-side paths are keyed by repo name so two projects on one VM never
1638
+ // collide, and they sit beside the `.done` marker projectBootstrapDoneMarker
1639
+ // already defines. Pure string builders (like their VM-bootstrap counterparts)
1640
+ // so the whole grammar is unit-testable without a live VM.
1641
+ function projectBootstrapScriptPath(repoName) {
1642
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.sh`;
1643
+ }
1644
+ function projectBootstrapRunnerPath(repoName) {
1645
+ return `$HOME/.codeyam-project-bootstrap-${repoName}-run.sh`;
1646
+ }
1647
+ function projectBootstrapLogPath(repoName) {
1648
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.log`;
1649
+ }
1650
+ function projectBootstrapPidPath(repoName) {
1651
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.pid`;
1652
+ }
1653
+ // The runner's recorded EXIT STATUS. Deliberately `.status`, NOT `.done`:
1654
+ // `.done` is already taken by scripts/gcp-project-bootstrap.sh's own terminal
1655
+ // SUCCESS marker (a timestamp, written only after `set -euo pipefail` carried
1656
+ // every step through — see projectBootstrapDoneMarker). Reusing `.done` for an
1657
+ // exit code would overwrite the success signal that both improve40's false-skip
1658
+ // fix and the follow loop below depend on. Two files, two distinct meanings:
1659
+ // `.done` = "the script reached its last line", `.status` = "the runner exited,
1660
+ // here is its code".
1661
+ function projectBootstrapStatusPath(repoName) {
1662
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.status`;
1663
+ }
1664
+ const PROJECT_BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_PROJECT_BOOTSTRAP_LAUNCHED";
1665
+ const PROJECT_BOOTSTRAP_STATUS_MARKER = "CYPROJ";
1666
+ // One follow session caps at 15 min; the overall wait caps at
1667
+ // PROJECT_BOOTSTRAP_TIMEOUT_MS across re-attaches before we bail loudly with
1668
+ // the VM-side log location. Unlike the VM-bootstrap follow loop — whose follow
1669
+ // calls inherit the 120 s probe budget and so re-attach every two minutes over
1670
+ // a ~14-min build — the project follow call below is given an explicit local
1671
+ // budget just above this remote deadline, so a long install streams in one
1672
+ // session instead of churning re-attaches.
1673
+ const PROJECT_BOOTSTRAP_FOLLOW_SECS = 900;
1674
+ const PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS = (PROJECT_BOOTSTRAP_FOLLOW_SECS + 60) * 1000;
1675
+
1676
+ // The tiny VM-side wrapper the detached launch execs: run the real bootstrap,
1677
+ // redirect all output to the per-repo log, and record the exit status in the
1678
+ // `.status` sentinel — the single source of truth for "did the runner exit, and
1679
+ // with what?" once the SSH follow-session is gone. Contains NO secrets (tokens
1680
+ // arrive via the launch env prefix, never written to disk).
1681
+ function projectBootstrapRunnerScript(repoName) {
1682
+ return [
1683
+ "#!/usr/bin/env bash",
1684
+ "# Detached project-bootstrap runner (written by npm/cloud.js",
1685
+ "# ensureProjectBootstrapped). Survives SSH disconnect: the laptop only",
1686
+ "# *follows* the log below; this process keeps running under its own session",
1687
+ "# if the follow drops. Records its own PID so the status probe can check",
1688
+ "# liveness via `kill -0` (a `pgrep` on the script path would also match the",
1689
+ "# probe command itself).",
1690
+ `echo "$$" > "${projectBootstrapPidPath(repoName)}"`,
1691
+ `bash "${projectBootstrapScriptPath(repoName)}" "$@" > "${projectBootstrapLogPath(repoName)}" 2>&1`,
1692
+ "status=$?",
1693
+ `echo "$status" > "${projectBootstrapStatusPath(repoName)}"`,
1694
+ `rm -f "${projectBootstrapPidPath(repoName)}"`,
1695
+ "",
1696
+ ].join("\n");
1697
+ }
1698
+
1699
+ // Build the remote command that (1) materializes the bootstrap script + runner
1700
+ // on the VM, (2) clears the stale sentinels, (3) launches the runner DETACHED
1701
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
1702
+ // session, and (4) prints a launched marker. The detach is the durable fix: the
1703
+ // install no longer dies when the laptop's SSH pipe breaks, and the VM is no
1704
+ // longer torn down because it did.
1705
+ //
1706
+ // BOTH sentinels are cleared, `.status` and `.done`. Clearing `.done` is what
1707
+ // makes the outcome probe honest: after a launch, a present `.done` can only
1708
+ // mean THIS run's script reached its terminal line, never a leftover from a
1709
+ // previous half-wired attempt.
1710
+ function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
1711
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1712
+ const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
1713
+ const argsString = scriptArgs.map(shQuote).join(" ");
1714
+ const envPrefix = envAssignmentPrefix(envVars);
1715
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
1716
+ // aborts before launch; then the runner is detached. The env prefix is on the
1717
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
1718
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1719
+ // launch in the background while still confirming it fired.
1720
+ const writes = [
1721
+ `echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
1722
+ `echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
1723
+ `rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
1724
+ ].join(" && ");
1725
+ const launch =
1726
+ `${envPrefix}nohup setsid bash ${projectBootstrapRunnerPath(repoName)} ${argsString}`.trimEnd() +
1727
+ ` </dev/null >/dev/null 2>&1 &`;
1728
+ return `${writes}; ${launch} echo ${PROJECT_BOOTSTRAP_LAUNCHED_MARKER}`;
1729
+ }
1730
+
1731
+ // Build the remote command that FOLLOWS the detached install: tail the log live
1732
+ // for the operator, polling for the `.status` sentinel, and exit when the runner
1733
+ // finishes OR a per-session deadline elapses. If the SSH session drops
1734
+ // mid-follow this command dies but the detached install keeps going — the caller
1735
+ // then re-attaches.
1736
+ function projectBootstrapFollowCommand(repoName, deadlineSecs = PROJECT_BOOTSTRAP_FOLLOW_SECS) {
1737
+ const log = projectBootstrapLogPath(repoName);
1738
+ const status = projectBootstrapStatusPath(repoName);
1739
+ return [
1740
+ `touch ${log}`,
1741
+ `tail -n +1 -F ${log} 2>/dev/null & TP=$!`,
1742
+ `waited=0`,
1743
+ `while [ ! -f ${status} ]; do ` +
1744
+ `sleep 3; waited=$((waited+3)); ` +
1745
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1746
+ `done`,
1747
+ `sleep 1`,
1748
+ `kill "$TP" 2>/dev/null`,
1749
+ ].join("; ");
1750
+ }
1751
+
1752
+ // Build the remote command that reports terminal VM state in one parseable
1753
+ // line: whether the script's terminal `.done` marker exists, the runner's
1754
+ // recorded exit status (or `none`), and whether the runner is still alive.
1755
+ // Cheap — runs over ssh-probe.
1756
+ function projectBootstrapStatusCommand(repoName) {
1757
+ const pid = projectBootstrapPidPath(repoName);
1758
+ const status = projectBootstrapStatusPath(repoName);
1759
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1760
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1761
+ // match this very probe and always report running. A stale PID file from a
1762
+ // killed runner reads as not-running because `kill -0` fails.
1763
+ return [
1764
+ `done=no; [ -f ${projectBootstrapDoneMarker(repoName)} ] && done=yes`,
1765
+ `st=none; [ -f ${status} ] && st=$(cat ${status} 2>/dev/null)`,
1766
+ `run=no; if [ -f ${pid} ]; then p=$(cat ${pid} 2>/dev/null); ` +
1767
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1768
+ `echo "${PROJECT_BOOTSTRAP_STATUS_MARKER} done=$done status=$st running=$run"`,
1769
+ ].join("; ");
1770
+ }
1771
+
1772
+ // Parse the single status line emitted by projectBootstrapStatusCommand.
1773
+ // Returns `{ done, status, running }` or null when no status line is present
1774
+ // (e.g. the probe ssh itself failed and produced no stdout).
1775
+ function parseProjectBootstrapStatus(stdout) {
1776
+ const m = String(stdout || "").match(
1777
+ new RegExp(`${PROJECT_BOOTSTRAP_STATUS_MARKER} done=(\\S+) status=(\\S+) running=(\\S+)`),
1778
+ );
1779
+ if (!m) return null;
1780
+ return { done: m[1], status: m[2], running: m[3] };
1781
+ }
1782
+
1783
+ // Decide the project-bootstrap outcome from VM-side state alone — never the SSH
1784
+ // exit code. The `.done` marker is the real artifact (the script writes it only
1785
+ // after `set -euo pipefail` carried every step to success), so its presence is
1786
+ // success regardless of how the follow session ended — the same reasoning that
1787
+ // makes the docker image authoritative for the VM bootstrap. A live runner means
1788
+ // "re-attach". A recorded exit status without the marker means the install ran
1789
+ // and failed. Nothing at all means it was never started (or the probe could not
1790
+ // see it).
1791
+ function decideProjectBootstrapOutcome(status) {
1792
+ if (!status) return "absent";
1793
+ const { done, status: exitStatus, running } = status;
1794
+ if (done === "yes") return "success";
1795
+ if (running === "yes") return "running";
1796
+ if (exitStatus && exitStatus !== "none") return "failed";
1797
+ return "absent";
1798
+ }
1799
+
1800
+ // Reduce one follow-loop iteration to an action: "success" | "failed" |
1801
+ // "timeout" | "reattach". Extracted as a pure function because its PRECEDENCE is
1802
+ // load-bearing — it decides whether an operator is told a VM is broken:
1803
+ //
1804
+ // • success and failed outrank the deadline. Otherwise the final poll of a
1805
+ // slow-but-successful bootstrap reports a timeout, and the operator is sent
1806
+ // to reconcile a VM that actually finished.
1807
+ // • a still-running install past the deadline is a TIMEOUT, never a failure.
1808
+ // That is the VM-8 case: healthy VM, still installing, and saying so is what
1809
+ // gets the add classified retryable instead of destroyed.
1810
+ // • an unseen runner ("absent") before the deadline re-attaches rather than
1811
+ // failing — "I could not see it" is not "it broke" — but is still bounded by
1812
+ // the deadline so the loop cannot spin forever.
1813
+ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
1814
+ if (outcome === "success") return "success";
1815
+ if (outcome === "failed") return "failed";
1816
+ if (now >= deadline) return "timeout";
1817
+ return "reattach";
1818
+ }
1819
+
1820
+ // Probe the VM for the detached project bootstrap's terminal state and reduce it
1821
+ // to a single outcome string ("success" | "failed" | "running" | "absent").
1822
+ // Used both for the idempotent re-attach decision and after each follow session.
1823
+ function probeProjectBootstrapOutcome(cfg, repoName) {
1824
+ const r = runGcloud(
1825
+ buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
1826
+ { stdio: ["ignore", "pipe", "pipe"] },
1827
+ );
1828
+ return decideProjectBootstrapOutcome(parseProjectBootstrapStatus(r.stdout));
1829
+ }
1830
+
1831
+ /** Assemble the env forwarded into the project-bootstrap shell on the VM.
1832
+ * Extracted from ensureProjectBootstrapped so its CONDITIONAL keys are testable
1833
+ * without a VM — previously you could not assert "FLEET_DOMAIN is omitted for a
1834
+ * non-fleet project" or "a re-provision without --admin stays non-admin"
1835
+ * without invoking the whole gcloud-touching bootstrap.
1836
+ *
1837
+ * Keys, and why each is here:
1838
+ * GITHUB_TOKEN — clone a private client repo.
1839
+ * CY_GIT_USER_{NAME,EMAIL} — rendered into the compose file, then written
1840
+ * to `git config --global` by the entrypoint so
1841
+ * plan_claim_refs::create_claim's commit-tree
1842
+ * call succeeds. Empty is legitimate (a CI
1843
+ * runner with no author); the entrypoint warns
1844
+ * and proceeds, so the keys stay present.
1845
+ * FLEET_DOMAIN — ONLY for a numbered fleet VM with a domain
1846
+ * configured, so the compose render publishes
1847
+ * on 0.0.0.0 for the LB NEG. Omitted entirely
1848
+ * otherwise, preserving the loopback-only
1849
+ * publish a plain client project expects.
1850
+ * <forwarded launch-form keys> — DATABASE_URL et al, whitelisted by the
1851
+ * dashboard via CY_FORWARD_ENV_KEYS, so the
1852
+ * bootstrap-time `npm run setup` sees them on
1853
+ * the first try. Empty on plain `cloud:up`.
1854
+ * CODEYAM_ADMIN — the Home-tab Setup door. Defaults to the
1855
+ * strict non-admin "".
1856
+ *
1857
+ * Later spreads win, matching the original inline assembly exactly. `deps` lets
1858
+ * a test supply the token and git identity instead of reading the real
1859
+ * environment and shelling out to git. */
1860
+ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIdentity } = {}) {
1861
+ const git = gitIdentity || resolveLaptopGitIdentity();
1862
+ const out = {
1863
+ GITHUB_TOKEN: githubToken !== undefined ? githubToken : resolveGithubToken(),
1864
+ CY_GIT_USER_NAME: git.name,
1865
+ CY_GIT_USER_EMAIL: git.email,
1866
+ };
1867
+ const ingressPlan = ingressWiringPlan(cfg, env);
1868
+ if (ingressPlan.wire) out.FLEET_DOMAIN = ingressPlan.domain;
1869
+ Object.assign(out, resolveSetupForwardEnv(env));
1870
+ Object.assign(out, buildProvisionEnv(env, cfg));
1871
+ return out;
1872
+ }
1873
+
1874
+ // Fetch the tail of the VM-side project-bootstrap log for an actionable failure
1875
+ // message — the operator sees WHY the install failed, not a bare exit code.
1876
+ function projectBootstrapLogTail(cfg, repoName, lines = 40) {
1877
+ const r = runGcloud(
1878
+ buildGcloudArgs("ssh-probe", cfg, {
1879
+ command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
1880
+ }),
1881
+ { stdio: ["ignore", "pipe", "pipe"] },
1882
+ );
1883
+ return String(r.stdout || "").trim() || "(log unavailable)";
1884
+ }
1885
+
1886
+ /** Assemble the extra provision-time env forwarded into the bootstrap shell,
1887
+ * where it feeds the compose-render substitution. Currently just the admin
1888
+ * toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
1889
+ * editor-server process env (handlers.rs::admin_enabled, strict literal "1").
1890
+ * The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
1891
+ * (which lands as cfg.admin); anything else forwards "" so a normal launch —
1892
+ * and a re-provision without the flag — is strict non-admin. Pure over its
1893
+ * inputs so cloud.test.js can pin it without touching gcloud. */
1894
+ function buildProvisionEnv(env = process.env, flags = {}) {
1895
+ const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
1896
+ return { CODEYAM_ADMIN: admin ? "1" : "" };
1897
+ }
1898
+
1899
+ /** Build the one-shot SSH shell command that reconciles a running VM's admin
1900
+ * mode without a full re-provision: rewrite the rendered compose's
1901
+ * CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
1902
+ * VM whose compose predates the placeholder — then recreate the editor service
1903
+ * so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
1904
+ * normalizes), so inlining it into sed is safe. Pure/string-only so
1905
+ * cloud.test.js can assert its shape without SSHing. */
1906
+ function buildReconcileAdminCommand(repoName, value) {
1907
+ const f = "docker-compose.cloud.yml";
1908
+ const entry = ` - CODEYAM_ADMIN=${value}`;
1909
+ // Capture only the leading indentation (\1) and re-emit the literal key, so a
1910
+ // value that starts with a digit (e.g. "1") never lands right after the
1911
+ // backreference — `\11` would be read by sed as group 11, not `\1` + "1".
1912
+ return (
1913
+ `cd ~/projects/${repoName} && ` +
1914
+ `if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
1915
+ `sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
1916
+ `else ` +
1917
+ `sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
1918
+ `fi && ` +
1919
+ `docker compose -f ${f} up -d editor`
1920
+ );
1921
+ }
1922
+
1923
+ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
1311
1924
  const repoName = resolveProject(cfg).name;
1312
1925
  console.error(`Checking project bootstrap state for ${repoName}...`);
1313
1926
  // Gate on the TERMINAL completion marker the script writes as its very last
@@ -1335,35 +1948,104 @@ async function ensureProjectBootstrapped(cfg) {
1335
1948
  }
1336
1949
  console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
1337
1950
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
1338
- const scriptBody = fs.readFileSync(scriptPath, "utf8");
1951
+ const scriptBody = readStreamedScript(scriptPath, cfg);
1339
1952
  const scriptArgs = clientProjectBootstrapArgs(cfg);
1340
- // Forward the laptop's git author identity so the rendered compose file
1341
- // carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
1342
- // `git config --global` → plan_claim_refs::create_claim's commit-tree call
1343
- // succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
1344
- // runner); the entrypoint warns and proceeds.
1345
- const laptopGit = resolveLaptopGitIdentity();
1346
- // Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
1347
- // bootstrap shell so the bootstrap-time `npm run setup` sees them on the
1348
- // first try — without this, Prisma `db:push`/`db:seed` fail for lack of
1349
- // env and the bootstrap's fail-soft branch leaves the app schema-less until
1350
- // the post-add `reapplyVmEnv` re-fires setup. See
1351
- // `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
1352
- // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1353
- // standalone `cloud:up` path, so that flow is unchanged.
1354
- const forwardEnv = resolveSetupForwardEnv();
1355
- const command = sshScriptCommand(scriptBody, scriptArgs, {
1356
- GITHUB_TOKEN: resolveGithubToken(),
1357
- CY_GIT_USER_NAME: laptopGit.name,
1358
- CY_GIT_USER_EMAIL: laptopGit.email,
1359
- ...forwardEnv,
1360
- });
1361
- const result = runGcloud(
1362
- buildGcloudArgs("ssh-script", cfg, { command }),
1363
- { stdio: "inherit" },
1364
- );
1365
- if (result.status !== 0) {
1366
- throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
1953
+ // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
1954
+ // toggle — see projectBootstrapEnv for what each key is for and when it is
1955
+ // omitted. Extracted so those conditionals are testable without a VM.
1956
+ const bootstrapEnv = projectBootstrapEnv(cfg);
1957
+
1958
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may have
1959
+ // left a detached install still running. Converge on it instead of starting a
1960
+ // second `npm install` on top of the first.
1961
+ //
1962
+ // NOTE the deliberate asymmetry with ensureBootstrapped, which DOES return
1963
+ // early on an outcome of "success". There, the done-probe and the outcome probe
1964
+ // read the same artifact (the docker image), so they always agree. Here the
1965
+ // done-probe above is a strictly STRONGER three-way AND (`.done` marker +
1966
+ // ~/projects/<repo>/.codeyam + docker-compose.cloud.yml) than this probe's
1967
+ // `.done`-alone success, and it has already said no. Honoring a bare `.done`
1968
+ // here would re-open the improve40 VM-7 false-skip from the other side, so only
1969
+ // "running" short-circuits the launch.
1970
+ const initial = probeProjectBootstrapOutcome(cfg, repoName);
1971
+ if (initial === "running") {
1972
+ console.error(" ↺ a detached project bootstrap is already in progress — re-attaching to its log...");
1973
+ } else {
1974
+ // "absent" (never started), "failed" (a prior run died), or "success" (a bare
1975
+ // marker the stricter done-probe rejected) → (re)launch a fresh detached
1976
+ // install that will survive an SSH disconnect. Every step of
1977
+ // gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
1978
+ console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
1979
+ const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
1980
+ const launched = await runGcloudSshWithRetry(
1981
+ "ssh-script",
1982
+ cfg,
1983
+ { command: launch },
1984
+ { stdio: ["ignore", "pipe", "pipe"], label: "project bootstrap launch", sleepFn },
1985
+ );
1986
+ if (isSpawnTimeout(launched)) {
1987
+ // The LOCAL budget elapsed and we SIGTERMd the gcloud child. Say so:
1988
+ // the launch is a short write-and-detach, so this is an SSH-reachability
1989
+ // problem, NOT a broken bootstrap script — and the VM may be perfectly
1990
+ // healthy. Conflating the two is what destroyed VM-8.
1991
+ throw new Error(
1992
+ `launching the detached project bootstrap exceeded the local gcloud call budget ` +
1993
+ `and was SIGTERMd locally — the VM may be healthy and simply unreachable over SSH ` +
1994
+ `right now. Re-run to re-attach (each bootstrap step is skip-on-exists).`,
1995
+ );
1996
+ }
1997
+ if (launched.status !== 0) {
1998
+ throw new Error(
1999
+ `failed to launch the detached project bootstrap on the VM (gcloud ssh exit ${launched.status}).\n` +
2000
+ formatGcloudFailure(launched),
2001
+ );
2002
+ }
2003
+ }
2004
+
2005
+ // Follow the live log, re-attaching across any follow-session drops, until the
2006
+ // VM reports a terminal state or the overall deadline elapses. Success and
2007
+ // failure are read from VM state (the `.done` marker + the `.status` sentinel),
2008
+ // never the SSH exit code — so "my laptop's SSH dropped" or "a heavy npm
2009
+ // install outran a local budget" no longer masquerades as a broken bootstrap
2010
+ // and no longer gets a healthy VM deleted.
2011
+ const deadline = Date.now() + PROJECT_BOOTSTRAP_TIMEOUT_MS;
2012
+ for (;;) {
2013
+ runGcloud(
2014
+ buildGcloudArgs("ssh-script", cfg, { command: projectBootstrapFollowCommand(repoName) }),
2015
+ { stdio: "inherit", timeout: PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS },
2016
+ );
2017
+ const outcome = probeProjectBootstrapOutcome(cfg, repoName);
2018
+ const action = decideProjectBootstrapFollowAction({
2019
+ outcome,
2020
+ now: Date.now(),
2021
+ deadline,
2022
+ });
2023
+ if (action === "success") {
2024
+ console.error(` ✓ ${repoName} bootstrap complete`);
2025
+ return;
2026
+ }
2027
+ if (action === "failed") {
2028
+ const tail = projectBootstrapLogTail(cfg, repoName);
2029
+ throw new Error(
2030
+ "scripts/gcp-project-bootstrap.sh failed on the VM. " +
2031
+ `Last lines of ${projectBootstrapLogPath(repoName)}:\n${tail}`,
2032
+ );
2033
+ }
2034
+ if (action === "timeout") {
2035
+ // The install is STILL RUNNING on a healthy VM and we simply stopped
2036
+ // waiting. This message is the signature npm/fleet-add-failure.js keys on
2037
+ // to classify the add as `project-bootstrap-timeout` (retryable) instead of
2038
+ // the non-retryable generic cloud-up-failed that tore down VM-8.
2039
+ throw new Error(
2040
+ `project bootstrap exceeded the local ${Math.round(PROJECT_BOOTSTRAP_TIMEOUT_MS / 60000)}-minute budget ` +
2041
+ `— the VM and its detached bootstrap may be healthy and still running. ` +
2042
+ `Re-run to re-attach (each bootstrap step is skip-on-exists), or inspect ` +
2043
+ `${projectBootstrapLogPath(repoName)} on the VM.`,
2044
+ );
2045
+ }
2046
+ // action === "reattach": the follow session ended but the install is alive
2047
+ // (or the probe could not see it yet). Re-attach to its log.
2048
+ console.error(" ↺ follow session ended but the project bootstrap is still running — re-attaching...");
1367
2049
  }
1368
2050
  }
1369
2051
 
@@ -1382,7 +2064,9 @@ async function ensureEditorContainerUp(cfg) {
1382
2064
  );
1383
2065
  if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
1384
2066
  console.error(" ✓ editor container is already running");
1385
- await ensureAgentCliInstalled(cfg);
2067
+ // containerStarted: the container is confirmed up, so the CLI install runs
2068
+ // its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
2069
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
1386
2070
  return;
1387
2071
  }
1388
2072
  console.error(" starting editor container (docker compose up -d)...");
@@ -1395,7 +2079,10 @@ async function ensureEditorContainerUp(cfg) {
1395
2079
  if (up.status !== 0) {
1396
2080
  throw new Error("docker compose up -d failed on the VM");
1397
2081
  }
1398
- await ensureAgentCliInstalled(cfg);
2082
+ // containerStarted: `up -d` returned 0, so the CLI install is legitimately a
2083
+ // post-start step. A brief wait-for-running here (slow boot settling) is
2084
+ // expected and must NOT warn about mis-ordering.
2085
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
1399
2086
  }
1400
2087
 
1401
2088
  // Hard gate: provisioning success REQUIRES a running editor container. Reuses
@@ -1425,6 +2112,36 @@ async function assertEditorContainerRunning(cfg) {
1425
2112
  }
1426
2113
  }
1427
2114
 
2115
+ // Hard gate: provisioning success ALSO REQUIRES the agent CLI present in the
2116
+ // container. The image deliberately ships without it (Dockerfile
2117
+ // INSTALL_BUILD_AGENT_CLI=none), and the in-container install is best-effort —
2118
+ // if it was skipped (container not up when it first ran), nothing else would
2119
+ // catch the absent binary and the editor's auth probe times out forever, leaving
2120
+ // the Build tab stuck on "Checking your account…" (the 2026-06-30 reprovision
2121
+ // defect). This re-probes for `command -v <binary>`, makes one more install
2122
+ // attempt if it's missing, then throws (loud non-zero exit the dashboard's
2123
+ // fail-fast path acts on) if it STILL can't be installed — never rostering a
2124
+ // CLI-less VM. Runs AFTER assertEditorContainerRunning, so the container is
2125
+ // known up by the time we probe the binary.
2126
+ async function assertAgentCliInstalled(cfg) {
2127
+ const agent = cfg.agent;
2128
+ const container = process.env.CONTAINER || "codeyam-editor-editor-1";
2129
+ if (probeAgentBinaryInstalled(cfg, container, agent)) {
2130
+ return;
2131
+ }
2132
+ console.error(` build-agent CLI '${agent}' not found in ${container}; attempting install before failing...`);
2133
+ // containerStarted: assertAgentCliInstalled runs AFTER assertEditorContainerRunning,
2134
+ // so the container is known up; this install is a legitimate post-start retry.
2135
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
2136
+ if (!probeAgentBinaryInstalled(cfg, container, agent)) {
2137
+ throw new Error(
2138
+ `provisioning incomplete: build-agent CLI '${agent}' is not installed in ${container} on ${cfg.instanceName} ` +
2139
+ `(image ships without it and the in-container install failed). The editor auth probe will hang until it's present. ` +
2140
+ `Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
2141
+ );
2142
+ }
2143
+ }
2144
+
1428
2145
  /** Read the laptop's git author identity (`user.name`, `user.email`) so
1429
2146
  * cloud:up can forward it to the VM's container entrypoint, which writes
1430
2147
  * it to `git config --global`. Without identity in the container,
@@ -1451,13 +2168,52 @@ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
1451
2168
  * intentionally not done: the cost of one `gh` subprocess per cloud:up is
1452
2169
  * trivial and lets a user `gh auth login` between operations. */
1453
2170
  function resolveGithubToken() {
1454
- const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
1455
- if (env) return env;
2171
+ return resolveGithubTokenWithSource().token;
2172
+ }
2173
+
2174
+ /** Like {@link resolveGithubToken} but also reports WHICH channel resolved the
2175
+ * token (`GITHUB_TOKEN` env, `GH_TOKEN` env, or `gh auth token`). The source is
2176
+ * logged on a successful seed so a misconfigured add-environment — e.g. a
2177
+ * launchd dashboard agent whose plist lacks the token env and whose `gh` can't
2178
+ * reach the operator's keychain — is diagnosable from the add log instead of
2179
+ * surfacing days later as a fleet-wide plan-claim-sync outage. `source` is
2180
+ * `null` when no token resolves. */
2181
+ function resolveGithubTokenWithSource() {
2182
+ if (process.env.GITHUB_TOKEN) {
2183
+ return { token: process.env.GITHUB_TOKEN, source: "GITHUB_TOKEN" };
2184
+ }
2185
+ if (process.env.GH_TOKEN) {
2186
+ return { token: process.env.GH_TOKEN, source: "GH_TOKEN" };
2187
+ }
1456
2188
  const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
1457
2189
  if (ghOut.status === 0) {
1458
- return ghOut.stdout.trim();
2190
+ const token = String(ghOut.stdout || "").trim();
2191
+ if (token) return { token, source: "gh auth token" };
2192
+ }
2193
+ return { token: "", source: null };
2194
+ }
2195
+
2196
+ /** Thrown by {@link seedGithubCredentials} when no GitHub token resolves and the
2197
+ * caller has NOT opted into a credential-less VM. The root harm this guards is
2198
+ * a VM that comes up "healthy" yet cannot `git push` its plan-claim refs to
2199
+ * origin, silently stranding fleet-wide claim sync (observed 2026-06-29 across
2200
+ * all 9 VMs). `degradedReason` is the machine-readable `CY_DEGRADED` body the
2201
+ * add path emits so the dashboard card lands amber with a recovery command
2202
+ * instead of a falsely-green healthy card. */
2203
+ class MissingGitPushCredentialsError extends Error {
2204
+ constructor() {
2205
+ super(
2206
+ "No GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
2207
+ "the cloud VM cannot git pull/push, so plan-claim sync will not reach origin.\n" +
2208
+ "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again,\n" +
2209
+ "or re-seed a running VM with `npm run cloud:reconcile-credentials`.\n" +
2210
+ "Pass --allow-no-git-credentials to provision an intentionally read-only VM.",
2211
+ );
2212
+ this.name = "MissingGitPushCredentialsError";
2213
+ this.degradedReason =
2214
+ "no-git-credentials: no GitHub push token resolved — plan-claim sync will not " +
2215
+ "reach origin. Recover: npm run cloud:reconcile-credentials";
1459
2216
  }
1460
- return "";
1461
2217
  }
1462
2218
 
1463
2219
  /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
@@ -1491,6 +2247,230 @@ function resolveGhcrPushToken() {
1491
2247
  return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1492
2248
  }
1493
2249
 
2250
+ // Registry manifest media types we accept when probing image presence over the
2251
+ // GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
2252
+ // multi-arch index answers 200 the same as a single-arch manifest.
2253
+ const MANIFEST_ACCEPT = [
2254
+ "application/vnd.docker.distribution.manifest.v2+json",
2255
+ "application/vnd.docker.distribution.manifest.list.v2+json",
2256
+ "application/vnd.oci.image.manifest.v1+json",
2257
+ "application/vnd.oci.image.index.v1+json",
2258
+ ].join(", ");
2259
+ const GHCR_PROBE_TIMEOUT_MS = 15000;
2260
+
2261
+ /** Default HTTPS client for the registry probe: a GET that resolves to
2262
+ * `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
2263
+ * to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
2264
+ * `httpImpl` option so probeUsableEditorImage is unit-testable with no network
2265
+ * (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
2266
+ * injectable so the response / error / timeout mapping is itself unit-testable
2267
+ * without a real socket. */
2268
+ function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
2269
+ return new Promise((resolve) => {
2270
+ const req = requestImpl(
2271
+ { hostname, path: reqPath, method: "GET", headers },
2272
+ (res) => {
2273
+ res.resume(); // drain the body so the socket is freed
2274
+ res.on("end", () => resolve({ statusCode: res.statusCode }));
2275
+ },
2276
+ );
2277
+ req.on("error", () => resolve({ statusCode: 0 }));
2278
+ req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
2279
+ req.end();
2280
+ });
2281
+ }
2282
+
2283
+ /** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
2284
+ * API — no docker daemon, so a docker-less operator host can still determine
2285
+ * presence. Returns true (200 present), false (404 absent), or null (auth /
2286
+ * network / malformed-ref → can't tell absent from forbidden). */
2287
+ async function probeManifestRef(fullRef, bearer, httpGet) {
2288
+ const colon = fullRef.lastIndexOf(":");
2289
+ if (colon < 0) return null;
2290
+ const ref = fullRef.slice(0, colon); // host/repo
2291
+ const tag = fullRef.slice(colon + 1); // slug-latest
2292
+ const slash = ref.indexOf("/");
2293
+ if (slash < 0) return null;
2294
+ const hostname = ref.slice(0, slash); // ghcr.io
2295
+ const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
2296
+ let res;
2297
+ try {
2298
+ res = await httpGet({
2299
+ hostname,
2300
+ path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
2301
+ headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
2302
+ });
2303
+ } catch (_e) {
2304
+ return null;
2305
+ }
2306
+ const code = res && res.statusCode;
2307
+ if (code === 200) return true;
2308
+ if (code === 404) return false;
2309
+ return null; // 401 / 403 / 5xx / 0 (network) → undetermined
2310
+ }
2311
+
2312
+ /** Read-only probe: does a usable prebuilt editor image already exist on the
2313
+ * registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
2314
+ * add to decide whether to pace one VM ahead of the rest (see
2315
+ * npm/fleet-prewarm.js::decideBatchPrewarm).
2316
+ *
2317
+ * Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
2318
+ * HEAD-equivalent that reads the manifest without pulling the multi-GB image)
2319
+ * instead of `docker login` / `docker manifest inspect`. This is the root fix
2320
+ * for the docker-less GCE operator host: the old docker path errored on the
2321
+ * first `docker login` spawn (the control plane has no docker daemon) and so
2322
+ * returned `null` forever, which decideBatchPrewarm read as "fan out" — the
2323
+ * image was therefore never published and every cold add source-built. Over
2324
+ * HTTP the probe returns a DEFINITIVE verdict with no daemon.
2325
+ *
2326
+ * Returns:
2327
+ * true — at least one of the usable tags exists (a cold add can pull; no pacer)
2328
+ * false — every tag was checked and none exists (known-absent; pacing pays)
2329
+ * null — undetermined: no read token, or an auth/network error that can't be
2330
+ * distinguished from "absent".
2331
+ *
2332
+ * GHCR's private packages require auth; without a read token we can't tell
2333
+ * absent from forbidden, so → null. The read token is sent as the registry
2334
+ * bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
2335
+ * exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
2336
+ * `spawnImpl` option is still accepted (and ignored) for backward-compat — the
2337
+ * HTTP happy path needs no spawn. */
2338
+ async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
2339
+ const fp = require("./fleet-prewarm");
2340
+ const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
2341
+ const readToken = token || resolveGhcrToken();
2342
+ if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
2343
+ const httpGet = httpImpl || httpsManifestGet;
2344
+ // GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
2345
+ // round-trip needed.
2346
+ const bearer = Buffer.from(readToken).toString("base64");
2347
+ const tags = fp.usableImageTags({ registry: reg, branch });
2348
+ let allClean = true;
2349
+ for (const fullRef of tags) {
2350
+ const verdict = await probeManifestRef(fullRef, bearer, httpGet);
2351
+ if (verdict === true) return true; // a usable image exists
2352
+ if (verdict === null) allClean = false; // this tag's probe was inconclusive
2353
+ // verdict === false → this tag is absent; keep checking the rest
2354
+ }
2355
+ // Every tag probed cleanly and none existed → definitively absent. If any probe
2356
+ // was inconclusive we can't claim absence → undetermined.
2357
+ return allClean ? false : null;
2358
+ }
2359
+
2360
+ /** JS mirror of crates/control-api/src/plan_claims_sync.rs::classify_sync_error.
2361
+ * Categorizes a `git ls-remote` / push stderr into the SAME buckets the Rust
2362
+ * sync task uses (`credential` / `git-spawn` / `network` / `other`) so the
2363
+ * credential self-heal only re-seeds on a real credential gap, never a
2364
+ * transient network blip. Deliberate duplication: cloud.js (the fleet tool)
2365
+ * cannot call into the Rust crate, and the category strings are a stable
2366
+ * contract — keep the two lists in sync if either changes. */
2367
+ function classifyGitRemoteError(stderr) {
2368
+ const lower = String(stderr || "").toLowerCase();
2369
+ if (
2370
+ [
2371
+ "could not read username",
2372
+ "terminal prompts disabled",
2373
+ "authentication failed",
2374
+ "device not configured",
2375
+ ].some((s) => lower.includes(s))
2376
+ ) {
2377
+ return "credential";
2378
+ }
2379
+ if (lower.includes("no such file") && lower.includes("git")) {
2380
+ return "git-spawn";
2381
+ }
2382
+ if (
2383
+ [
2384
+ "could not resolve host",
2385
+ "connection refused",
2386
+ "connection timed out",
2387
+ "ssl certificate problem",
2388
+ "tls",
2389
+ ].some((s) => lower.includes(s))
2390
+ ) {
2391
+ return "network";
2392
+ }
2393
+ // A greenfield project (`--new-project` scaffold) has no origin remote and an
2394
+ // unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
2395
+ // git repository". No token would fix this — there is simply nothing to push
2396
+ // yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
2397
+ // match narrow: `repository not found` is a genuine access failure against a
2398
+ // real remote and must NOT be swallowed here.
2399
+ if (lower.includes("does not appear to be a git repository")) {
2400
+ return "no-remote";
2401
+ }
2402
+ return "other";
2403
+ }
2404
+
2405
+ /** Pure decision for the credential self-heal. Given a probe of a running VM —
2406
+ * whether the host staging file (`~/.codeyam-cloud-git-credentials`) is empty,
2407
+ * and the outcome of an in-container `git ls-remote origin` — decide whether to
2408
+ * re-seed credentials in place.
2409
+ *
2410
+ * Re-seed iff the host staging file is empty (so a restart wouldn't recover) OR
2411
+ * `ls-remote` failed with the `credential` signature. A `network` failure must
2412
+ * NOT trigger a re-write (a transient blip is not a credential gap), and a
2413
+ * healthy VM (ls-remote ok AND staging seeded) is a no-op. Returns
2414
+ * `{ heal, category, reason }`. */
2415
+ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
2416
+ if (lsRemoteOk && !stagingEmpty) {
2417
+ return {
2418
+ heal: false,
2419
+ category: "ok",
2420
+ reason: "git ls-remote origin succeeds and the staging file is seeded",
2421
+ };
2422
+ }
2423
+ if (!lsRemoteOk) {
2424
+ const category = classifyGitRemoteError(lsRemoteStderr);
2425
+ if (category === "network") {
2426
+ return {
2427
+ heal: false,
2428
+ category,
2429
+ reason: "ls-remote failed with a network error — not re-seeding (transient)",
2430
+ };
2431
+ }
2432
+ if (category === "git-spawn") {
2433
+ return {
2434
+ heal: false,
2435
+ category,
2436
+ reason: "git binary failed to launch — not a credential gap, not re-seeding",
2437
+ };
2438
+ }
2439
+ if (category === "no-remote") {
2440
+ return {
2441
+ heal: false,
2442
+ category,
2443
+ reason:
2444
+ "no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
2445
+ };
2446
+ }
2447
+ return {
2448
+ heal: true,
2449
+ category,
2450
+ reason: stagingEmpty
2451
+ ? "staging file empty and ls-remote failing — re-seeding"
2452
+ : "ls-remote failed with a credential error — re-seeding",
2453
+ };
2454
+ }
2455
+ // ls-remote ok but the durable host file is empty (live cache seeded
2456
+ // out-of-band): re-seed so the next container restart survives.
2457
+ return {
2458
+ heal: true,
2459
+ category: "credential",
2460
+ reason: "staging file empty (re-seed for restart durability)",
2461
+ };
2462
+ }
2463
+
2464
+ /** The single GitHub credential line format shared by every seed path: the
2465
+ * host staging file, the durable on-disk `store` fallback, and the live
2466
+ * cache-daemon approve all expect `https://x-access-token:<token>@github.com`
2467
+ * (git's credential-URL form, matched by both the entrypoint parser and
2468
+ * git-credential-store). Centralized so the `x-access-token` shape has one
2469
+ * source of truth instead of being re-typed at each call site. */
2470
+ function githubCredentialLine(token) {
2471
+ return `https://x-access-token:${token}@github.com`;
2472
+ }
2473
+
1494
2474
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1495
2475
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1496
2476
  * without this, claude / codex / gemini inside the container can clone the
@@ -1498,24 +2478,47 @@ function resolveGhcrPushToken() {
1498
2478
  * inlined URL) but cannot `git pull` / `git push` afterward. The file is
1499
2479
  * chmod 600 and never committed; if the local environment has no token,
1500
2480
  * the placeholder file from gcp-project-bootstrap.sh stays in place. */
1501
- async function seedGithubCredentials(cfg) {
1502
- const token = resolveGithubToken();
2481
+ async function seedGithubCredentials(cfg, opts = {}) {
2482
+ const { token, source } = resolveGithubTokenWithSource();
1503
2483
  if (!token) {
1504
- console.error(
1505
- "\nNo GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
1506
- "claude inside the cloud container will not be able to git pull/push.\n" +
1507
- "Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again.\n",
1508
- );
1509
- return;
2484
+ if (opts.allowNoCredentials) {
2485
+ // Explicit opt-out: an intentionally read-only VM. Warn loudly (so it is
2486
+ // never invisible) but do not throw — the caller asked for this.
2487
+ console.error(
2488
+ "\n--allow-no-git-credentials: no GitHub token resolved; provisioning a\n" +
2489
+ "read-only VM. It cannot git push, so plan-claim sync will not reach origin.\n",
2490
+ );
2491
+ return;
2492
+ }
2493
+ // Fail loud: a VM that can't push plan-claim refs is degraded by definition.
2494
+ throw new MissingGitPushCredentialsError();
1510
2495
  }
1511
- console.error("Writing GitHub token into ~/.codeyam-cloud-git-credentials on the VM...");
1512
- const credLine = `https://x-access-token:${token}@github.com`;
2496
+ console.error(
2497
+ `Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
2498
+ );
2499
+ const credLine = githubCredentialLine(token);
1513
2500
  // Quote the token through one shell layer (gcloud ssh --command); the
1514
2501
  // value is base64'd to keep it out of the shell history.
1515
2502
  const b64 = Buffer.from(`${credLine}\n`).toString("base64");
2503
+ // Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
2504
+ // (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
2505
+ // unreadable decode must never clobber an existing good file — the entrypoint
2506
+ // reads this file on every boot, and a silent empty write is exactly how a
2507
+ // reprovision lands a VM that "could not read Username for github.com". The
2508
+ // trailing `echo OK` runs only on success, so an empty write can't mask
2509
+ // success with a `&& echo` (the failure shape seen on the Claude creds sinks).
2510
+ //
2511
+ // Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
2512
+ // bind-mounted read-only into the running container, and `mv` mints a NEW
2513
+ // inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
2514
+ // never reached the running container until a full recreate. Truncating the
2515
+ // existing file in place keeps its inode, so the durable write is visible to
2516
+ // the container immediately. Then remove the temp.
1516
2517
  const remoteCmd =
1517
- `f=$HOME/.codeyam-cloud-git-credentials && ` +
1518
- `echo ${b64} | base64 -d > $f && chmod 600 $f && echo OK`;
2518
+ `f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
2519
+ `echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
2520
+ `if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
2521
+ `else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
1519
2522
  const result = runGcloud(
1520
2523
  buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
1521
2524
  { stdio: ["ignore", "pipe", "inherit"] },
@@ -1525,26 +2528,76 @@ async function seedGithubCredentials(cfg) {
1525
2528
  }
1526
2529
  }
1527
2530
 
2531
+ /** Sentinel the remote read prints when it cannot find an `editor.json` under
2532
+ * any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
2533
+ * project" (a real miss worth warning about) from "the project is scaffolded
2534
+ * but declares no port" (legitimately a `VITE_PORT` default). Without the
2535
+ * distinction both collapse to `{}` and the miss is silent. */
2536
+ const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
2537
+
2538
+ /** Build the remote shell command that locates the project's `.codeyam` dir and
2539
+ * cats its two config files in one round-trip.
2540
+ *
2541
+ * Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
2542
+ * directory is not guaranteed to be named after the repo (a renamed repo, a
2543
+ * manually-cloned dir, a fleet slug), and when the guess missed, the old code
2544
+ * silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
2545
+ * path is still tried FIRST so a multi-project VM resolves deterministically;
2546
+ * the glob is the fallback. When nothing matches, print the sentinel so the
2547
+ * caller can warn instead of guessing. Pure string builder; no I/O. */
2548
+ function buildVmAppPortReadCommand(repoName) {
2549
+ return (
2550
+ `d=""; ` +
2551
+ `for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
2552
+ `if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
2553
+ `if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
2554
+ `cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
2555
+ `echo '<<<LOCAL>>>'; ` +
2556
+ `cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
2557
+ );
2558
+ }
2559
+
1528
2560
  /** Best-effort read of the VM's resolved app dev-server port so cloud:up's
1529
2561
  * laptop `-L` forward, readiness probe, and previewOrigin seed all target the
1530
2562
  * port the app actually serves on instead of a hardcoded 5173. Cats the VM's
1531
2563
  * editor.json and editor.local.json in one SSH round-trip and resolves via
1532
- * `resolveConfiguredAppPort`. Any failure (SSH error, missing/corrupt JSON)
1533
- * falls back to `VITE_PORT` — the same default `resolved_port()` lands on for
1534
- * a portless config, so a 5173/greenfield project sees no behavior change. */
2564
+ * `resolveConfiguredAppPort`.
2565
+ *
2566
+ * Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
2567
+ * wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
2568
+ * and if the preview ends up on a dedicated origin the viewer gets
2569
+ * "localhost refused to connect" with nothing pointing at the cause. So an SSH
2570
+ * failure or a VM with no scaffolded project warns on stderr naming that
2571
+ * symptom. A scaffolded project that simply declares no port is NOT a miss —
2572
+ * `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
2573
+ * on) and stays quiet. */
1535
2574
  async function readVmAppPort(cfg) {
1536
2575
  const repoName = resolveProject(cfg).name;
1537
- const base = `~/projects/${repoName}/.codeyam`;
1538
2576
  const read = runGcloud(
1539
2577
  buildGcloudArgs("ssh-probe", cfg, {
1540
- command:
1541
- `cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
1542
- `echo '<<<LOCAL>>>'; ` +
1543
- `cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
2578
+ command: buildVmAppPortReadCommand(repoName),
1544
2579
  }),
1545
2580
  { stdio: ["ignore", "pipe", "ignore"] },
1546
2581
  );
1547
- if (read.status !== 0 || typeof read.stdout !== "string") return VITE_PORT;
2582
+ if (read.status !== 0 || typeof read.stdout !== "string") {
2583
+ console.error(
2584
+ `[cloud] WARNING: could not read the VM's app port over SSH — ` +
2585
+ `falling back to ${VITE_PORT}. If the app does not serve on ` +
2586
+ `${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
2587
+ `Live Preview will show "localhost refused to connect".`,
2588
+ );
2589
+ return VITE_PORT;
2590
+ }
2591
+ if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
2592
+ console.error(
2593
+ `[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
2594
+ `VM (looked for '${repoName}', then any project dir) — falling back to ` +
2595
+ `${VITE_PORT}. If the project is scaffolded under a different name and ` +
2596
+ `serves on another port, the port-forward targets the wrong port and ` +
2597
+ `the Live Preview will show "localhost refused to connect".`,
2598
+ );
2599
+ return VITE_PORT;
2600
+ }
1548
2601
  const parse = (s) => {
1549
2602
  try {
1550
2603
  const v = JSON.parse(s);
@@ -1642,36 +2695,63 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1642
2695
  sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1643
2696
  }
1644
2697
 
2698
+ // Resolve this VM's custom slug (if any) so a dedicated-origin seed uses the
2699
+ // slug host. The slug rides on cfg at Add time (the --vm-slug flag, validated
2700
+ // early); for any other entry point it's resolved from the sidecar by number.
2701
+ const seedVm = resolveFleetVmNumber(cfg.instanceName);
2702
+ const seedSlug =
2703
+ cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
2704
+ ? cfg.vmSlug
2705
+ : seedVm != null
2706
+ ? slugFor(seedVm)
2707
+ : null;
1645
2708
  const seed = resolveSeedPreviewOrigin(
1646
2709
  cfg.instanceName,
1647
2710
  readCloudflaredUrls(),
1648
2711
  appPort,
1649
2712
  process.env,
1650
2713
  sameOriginSafe,
2714
+ seedSlug,
1651
2715
  );
1652
2716
 
1653
2717
  // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1654
- // is normally a user pin or a good prior seed and is left untouched. The one
1655
- // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1656
- // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1657
- // same-origin migration): that frozen URL points at a dead rotated tunnel
1658
- // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1659
- // instead of preserving it. Because every dashboard action (Reset / Update /
1660
- // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1661
- // every touch-point without a separate per-VM hand-fix.
2718
+ // is normally a user pin or a good prior seed and is left untouched. There are
2719
+ // TWO symmetric heal exceptions, both of which the frozen skip-guard would
2720
+ // otherwise pin forever:
2721
+ // 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
2722
+ // (the VM-1/2/6 leftover from before the improve38 same-origin
2723
+ // migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
2724
+ // blank preview), so we heal it back to the same-origin path; and
2725
+ // 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
2726
+ // the first seed ran before onboarding wrote stack.json, defaulted to
2727
+ // `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
2728
+ // remote browser then loads as `127.0.0.1 refused to connect`). Once
2729
+ // stack.json exists, heal it forward to the dedicated origin.
2730
+ // Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
2731
+ // re-runs this seed over cloud:up, either heal reaches every touch-point the
2732
+ // moment stack.json is present — closing the race without re-ordering
2733
+ // provisioning, and without a separate per-VM hand-fix.
1662
2734
  const originAlreadySet =
1663
2735
  typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1664
2736
  const healing =
1665
- originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
2737
+ originAlreadySet &&
2738
+ (isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
2739
+ isWrongModePreviewOrigin(current, sameOriginSafe));
1666
2740
  if (originAlreadySet && !healing) {
1667
2741
  return;
1668
2742
  }
1669
2743
 
1670
- // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1671
- // deletes any key whose overlay value is null) — the same-origin path is a
1672
- // constant the retired monitor push has nothing to manage, exactly matching
1673
- // the manual `config-override --unset _managedPreviewOrigin` fix.
1674
- const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
2744
+ // On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
2745
+ // own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
2746
+ // stale marker (deepMergeJson deletes a key whose overlay value is null) — the
2747
+ // constant same-origin path has nothing for the monitor to manage, exactly
2748
+ // matching the manual `config-override --unset _managedPreviewOrigin` fix. The
2749
+ // wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
2750
+ // IS managed (the monitor's gated rotation push owns it), so the marker must
2751
+ // survive so the next respawn recognises the value as ours.
2752
+ const overlay = healing
2753
+ ? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
2754
+ : seed;
1675
2755
  console.error(
1676
2756
  healing
1677
2757
  ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
@@ -1691,28 +2771,107 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1691
2771
  }
1692
2772
  }
1693
2773
 
1694
- /** Write the chosen agent provider and local API keys into editor.local.json on
1695
- * the VM BEFORE compose up so the file exists when the bind-mount happens —
1696
- * the provider seed MUST run before ensureEditorContainerUp, because the editor
1697
- * caches config.provider at startup and reports it as currentProvider; a post-start
1698
- * seed leaves the running editor (and therefore the dashboard badge) on the default
1699
- * "claude" until a config reload. */
1700
- async function seedAgentApiKey(cfg) {
1701
- // The provider seed is unconditional — `buildEditorLocalEnvPatch` always
1702
- // returns a patch carrying `provider`, so the chosen agent always lands on
1703
- // the VM. The API-key seed is opportunistic: when the matching key isn't set
1704
- // locally the patch carries `provider` only, and we still print the
1705
- // interactive-auth fallback line so the operator knows to authenticate the
1706
- // agent on the VM later (Gemini's normal path).
1707
- const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
1708
- const hasKey = Boolean(patch.env);
1709
- if (!hasKey) {
1710
- console.error(
1711
- `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
1712
- `Authenticate ${cfg.agent} later with:\n` +
1713
- ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
1714
- `--tunnel-through-iap -- ${cfg.agent} auth login\n`,
1715
- );
2774
+ /** READY-probe auto-heal: after the editor container is verifiably Up, ask the
2775
+ * running editor's own `/api/config` for its stack verdict and effective
2776
+ * preview origin, and heal a wrong-moded seed in place — never failing the add.
2777
+ *
2778
+ * The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
2779
+ * at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
2780
+ * `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
2781
+ * client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
2782
+ * false` but, with only the subpath in editor.local.json, falls its
2783
+ * `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
2784
+ * remote browser renders as "refused to connect". If stack.json appeared since
2785
+ * the first seed (the editor reads it live from disk), we re-run the seed — now
2786
+ * the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
2787
+ * `/api/editor-reload-config` so the running server serves the healed origin
2788
+ * with no restart.
2789
+ *
2790
+ * Best-effort and non-fatal by contract: a probe failure, a still-absent
2791
+ * stack.json (verdict stays `true`), or a heal that can only reach a loopback
2792
+ * bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
2793
+ * leave the add green. The cloudflared-monitor's periodic tick (section 2) is
2794
+ * the durable owner that heals post-add flips — onboarding finishing minutes
2795
+ * after READY. */
2796
+ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
2797
+ const probe = runGcloud(
2798
+ buildGcloudArgs("ssh-probe", cfg, {
2799
+ command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
2800
+ }),
2801
+ { stdio: ["ignore", "pipe", "ignore"] },
2802
+ );
2803
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
2804
+ return;
2805
+ }
2806
+ let config;
2807
+ try {
2808
+ config = JSON.parse(probe.stdout);
2809
+ } catch {
2810
+ return;
2811
+ }
2812
+ if (!config || typeof config !== "object") return;
2813
+
2814
+ // The editor's own verdict (section 3's `/api/config` field) plus the
2815
+ // wrong-mode predicate (a subpath / relative / loopback origin on a
2816
+ // not-same-origin-safe stack) — the exact dead-end the dedicated-origin
2817
+ // fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
2818
+ if (config.previewSameOriginSafe !== false) return;
2819
+ if (!isWrongModePreviewOrigin(config, false)) return;
2820
+
2821
+ console.error(
2822
+ `READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
2823
+ `but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
2824
+ );
2825
+ // Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
2826
+ // the dedicated origin into editor.local.json (a published `<N>-preview`
2827
+ // tunnel URL, else the localhost bootstrap for the window before the monitor's
2828
+ // first tick).
2829
+ await seedPreviewOrigin(cfg, appPort);
2830
+ // Tell the running server to re-read editor.local.json — no restart (the
2831
+ // footgun that killed VM7's live Claude session).
2832
+ const reload = runGcloud(
2833
+ buildGcloudArgs("ssh-probe", cfg, {
2834
+ command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
2835
+ }),
2836
+ { stdio: ["ignore", "inherit", "inherit"] },
2837
+ );
2838
+ if (reload.status !== 0) {
2839
+ console.error(
2840
+ ` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
2841
+ `not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
2842
+ );
2843
+ }
2844
+ console.error(
2845
+ ` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
2846
+ `published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
2847
+ );
2848
+ }
2849
+
2850
+ /** Write the chosen agent provider and local API keys into editor.local.json on
2851
+ * the VM BEFORE compose up so the file exists when the bind-mount happens —
2852
+ * the provider seed MUST run before ensureEditorContainerUp, because the editor
2853
+ * caches config.provider at startup and reports it as currentProvider; a post-start
2854
+ * seed leaves the running editor (and therefore the dashboard badge) on the default
2855
+ * "claude" until a config reload. */
2856
+ async function seedAgentApiKey(cfg) {
2857
+ // The provider seed is unconditional — `buildEditorLocalEnvPatch` always
2858
+ // returns a patch carrying `provider`, so the chosen agent always lands on
2859
+ // the VM. The API-key seed is opportunistic: when the matching key isn't set
2860
+ // locally the patch carries `provider` only, and we still print the
2861
+ // interactive-auth fallback line so the operator knows to authenticate the
2862
+ // agent on the VM later (Gemini's normal path).
2863
+ const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
2864
+ const hasKey = Boolean(patch.env);
2865
+ if (!hasKey) {
2866
+ console.error(
2867
+ `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
2868
+ `Authenticate ${cfg.agent} on the VM by either:\n` +
2869
+ ` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
2870
+ ` • pasting the key into the Build tab's auth gate in the editor.\n` +
2871
+ `Either path now flows the key through to the agent process on the VM.\n` +
2872
+ `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
2873
+ `supported headlessly on the VM.\n`,
2874
+ );
1716
2875
  }
1717
2876
  const repoName = resolveProject(cfg).name;
1718
2877
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
@@ -1752,29 +2911,234 @@ async function seedAgentApiKey(cfg) {
1752
2911
  if (write.status !== 0) {
1753
2912
  throw new Error(`Failed to write ${remotePath} on VM`);
1754
2913
  }
2914
+ // NOTE: seedAgentApiKey deliberately does NOT install the build-agent CLI.
2915
+ // The CLI install is a POST-container-start step owned solely by
2916
+ // ensureEditorContainerUp (which runs `docker compose up -d` first). Seeding
2917
+ // the API key runs BEFORE the container is up, so a CLI install here would
2918
+ // enter ensureAgentCliInstalled's wait-for-container branch before any
2919
+ // `up -d` had happened — burning the whole wait window and firing a spurious
2920
+ // `CY_DEGRADED cli-deferred` on every add. That mis-ordering is the bug
2921
+ // vm--no-cli-install-before-container-started removed; do not re-add it.
2922
+ }
1755
2923
 
1756
- await ensureAgentCliInstalled(cfg);
2924
+ /** Pure: deep-merge `patch` over the VM's current editor.local.json content and
2925
+ * return the base64 of the pretty-printed result, ready for
2926
+ * buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
2927
+ * remote file (may be undefined, empty, or corrupt) — anything that doesn't
2928
+ * parse to a plain object is treated as `{}`, because overwriting a corrupt
2929
+ * override file with a known-good one is safer than refusing to seed. Extracted
2930
+ * so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
2931
+ * parse+merge+encode is unit-tested without a live VM. */
2932
+ function mergeEditorLocalPatchB64(currentRaw, patch) {
2933
+ let current = {};
2934
+ try {
2935
+ const parsed = JSON.parse(currentRaw);
2936
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2937
+ current = parsed;
2938
+ }
2939
+ } catch {
2940
+ // Unparseable / missing remote content → start from {}.
2941
+ }
2942
+ const merged = deepMergeJson(current, patch);
2943
+ return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
1757
2944
  }
1758
2945
 
1759
- /** Install the selected agent's CLI inside the editor container on the live VM */
1760
- async function ensureAgentCliInstalled(cfg) {
1761
- const agent = cfg.agent;
1762
- const pkg = agentNpmPackage(agent);
1763
- const container = process.env.CONTAINER || "codeyam-editor-editor-1";
2946
+ /** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
2947
+ * BEFORE compose up, so the editor caches the real project name at startup and
2948
+ * the fleet dashboard renders the correct card label with no false
2949
+ * project-dir-drift. Like seedAgentApiKey, this MUST run before
2950
+ * ensureEditorContainerUp — the editor reads its config once at startup.
2951
+ *
2952
+ * No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
2953
+ * from its origin basename, so there is nothing to seed and no SSH round-trip
2954
+ * is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
2955
+ * converging a VM provisioned before this fix onto the correct identity without
2956
+ * a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
2957
+ * mergeEditorLocalPatchB64 helper. */
2958
+ async function seedGreenfieldProjectTitle(cfg) {
2959
+ const patch = buildGreenfieldTitlePatch(cfg);
2960
+ if (!patch) return; // clone (or no greenfield name) — nothing to seed
2961
+
2962
+ const repoName = resolveProject(cfg).name;
2963
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
2964
+ console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
1764
2965
 
1765
- // First, check if the container is running on the VM host.
1766
- // If the container is not running, we do nothing and return.
2966
+ const read = runGcloud(
2967
+ buildGcloudArgs("ssh-probe", cfg, {
2968
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
2969
+ }),
2970
+ { stdio: ["ignore", "pipe", "ignore"] },
2971
+ );
2972
+ const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
2973
+ const write = runGcloud(
2974
+ buildGcloudArgs("ssh-probe", cfg, {
2975
+ command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
2976
+ }),
2977
+ { stdio: ["ignore", "inherit", "inherit"] },
2978
+ );
2979
+ if (write.status !== 0) {
2980
+ throw new Error(`Failed to write ${remotePath} on VM`);
2981
+ }
2982
+ }
2983
+
2984
+ /** Tri-state probe of the editor container's running state on the VM host.
2985
+ * One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
2986
+ *
2987
+ * The distinction matters: `probeContainerState` runs `docker ps` over
2988
+ * gcloud/IAP-SSH, and a NON-zero exit means the probe itself couldn't run — a
2989
+ * transient operator-auth flap (`[operator-auth] DEGRADED: could not resolve
2990
+ * an account`, observed in the 2026-06-30 incident) or an SSH/transport blip —
2991
+ * NOT proof the container is down. That is `"unknown"` and must never count
2992
+ * toward a give-up/teardown decision. Only a clean `docker ps` (exit 0) is
2993
+ * authoritative: a non-empty line is `"running"`, an empty result is
2994
+ * `"not-running"`.
2995
+ *
2996
+ * The format field is `{{.Names}}` (plural). `docker ps` has NO `.Name` field
2997
+ * (that belongs to `docker inspect`); `'{{.Name}}'` makes `docker ps` exit
2998
+ * non-zero with `can't evaluate field Name in type *formatter.ContainerContext`,
2999
+ * which — pre-fix — made every probe report a non-running container regardless
3000
+ * of its real state and took the whole fleet to zero on 2026-06-30. */
3001
+ function probeContainerState(cfg, container) {
1767
3002
  const checkProbe = runGcloud(
1768
3003
  buildGcloudArgs("ssh-probe", cfg, {
1769
- command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
3004
+ command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Names}}'`,
1770
3005
  }),
1771
3006
  { stdio: ["ignore", "pipe", "ignore"] },
1772
3007
  );
3008
+ if (checkProbe.status !== 0) {
3009
+ return "unknown";
3010
+ }
3011
+ return checkProbe.stdout.trim() !== "" ? "running" : "not-running";
3012
+ }
1773
3013
 
1774
- const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
1775
- if (!isRunning) {
1776
- console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
1777
- return;
3014
+ /** Boolean convenience wrapper over {@link probeContainerState}: true ONLY when
3015
+ * the container is definitively running. An `"unknown"` probe (auth/SSH error)
3016
+ * is not "running", so this returns false — callers that must distinguish a
3017
+ * probe failure from a down container use `probeContainerState` directly. */
3018
+ function probeContainerRunning(cfg, container) {
3019
+ return probeContainerState(cfg, container) === "running";
3020
+ }
3021
+
3022
+ /** Probe whether `binary` resolves on PATH inside the running container. */
3023
+ function probeAgentBinaryInstalled(cfg, container, binary) {
3024
+ const probe = runGcloud(
3025
+ buildGcloudArgs("ssh-probe", cfg, {
3026
+ command: `docker exec ${container} bash -lc 'command -v ${binary}'`,
3027
+ }),
3028
+ { stdio: ["ignore", "pipe", "ignore"] },
3029
+ );
3030
+ return probe.status === 0 && probe.stdout.trim() !== "";
3031
+ }
3032
+
3033
+ /** Poll the container's running state, returning true as soon as it's running.
3034
+ * The accept-on-first-success shape means the common already-running case
3035
+ * returns instantly with zero added latency. Parameterized so a unit test can
3036
+ * drive it with `sleepFn` = a no-op.
3037
+ *
3038
+ * `attempts` (default 48) bounds only DEFINITIVE `"not-running"` probes — at
3039
+ * the capped-backoff cadence below that is a multi-minute budget (~4 min),
3040
+ * generous enough for a slow first-boot editor container under fleet load
3041
+ * (30s was far too short and guillotined healthy slow boots). An `"unknown"`
3042
+ * probe (auth/SSH transport failure) may clear, so it does NOT consume the
3043
+ * budget; a hard iteration cap (2× attempts) still prevents an infinite loop
3044
+ * when the probe is permanently unreachable. Backoff (delayMs → maxDelayMs)
3045
+ * keeps the poll cheap early and patient late. */
3046
+ async function waitForContainerRunning(
3047
+ cfg,
3048
+ container,
3049
+ { attempts = 48, delayMs = 5000, maxDelayMs = 15000, sleepFn } = {},
3050
+ ) {
3051
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
3052
+ let notRunning = 0;
3053
+ const maxIterations = attempts * 2;
3054
+ for (let i = 0; i < maxIterations && notRunning < attempts; i++) {
3055
+ const state = probeContainerState(cfg, container);
3056
+ if (state === "running") {
3057
+ return true;
3058
+ }
3059
+ if (state === "not-running") {
3060
+ notRunning += 1;
3061
+ console.error(` waiting for container ${container} to reach running (${notRunning}/${attempts})...`);
3062
+ } else {
3063
+ // unknown: a probe-transport failure (auth/SSH), not proof the container
3064
+ // is down — retry without counting toward the give-up budget.
3065
+ console.error(` container ${container} probe inconclusive (auth/SSH?); retrying without counting toward give-up...`);
3066
+ }
3067
+ if (notRunning >= attempts) {
3068
+ break;
3069
+ }
3070
+ await sleep(Math.min(maxDelayMs, delayMs * 2 ** Math.min(i, 3)));
3071
+ }
3072
+ return probeContainerRunning(cfg, container);
3073
+ }
3074
+
3075
+ /** Install the selected agent's CLI inside the editor container on the live VM.
3076
+ *
3077
+ * PRECONDITION: the caller must have already STARTED the container (via
3078
+ * ensureEditorContainerUp's `docker compose up -d`, or after confirming it is
3079
+ * already running). This is a post-start install helper, NOT a container
3080
+ * starter — its wait-for-container branch only exists to absorb a slow boot
3081
+ * *after* `up -d`. Legitimate callers signal this with `waitOpts.containerStarted`;
3082
+ * entering the wait branch WITHOUT that signal is almost certainly a call-ordering
3083
+ * regression (the mis-ordering that made every add burn the full wait window and
3084
+ * emit a spurious `CY_DEGRADED cli-deferred` — see
3085
+ * vm--no-cli-install-before-container-started) and is warned about loudly.
3086
+ *
3087
+ * `waitOpts` is forwarded to {@link waitForContainerRunning} (a unit test
3088
+ * injects `{ attempts, delayMs, sleepFn }` so the defer path resolves without
3089
+ * the multi-minute real-timer wait); `waitOpts.containerStarted` is the
3090
+ * precondition acknowledgement described above; production start-path calls
3091
+ * pass `{ containerStarted: true }`. */
3092
+ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
3093
+ const agent = cfg.agent;
3094
+ const pkg = agentNpmPackage(agent);
3095
+ const container = process.env.CONTAINER || "codeyam-editor-editor-1";
3096
+
3097
+ // The container may not be up the instant this runs — a slow `docker compose
3098
+ // up -d` or a recreate still settling. This used to log "Skipping … for now"
3099
+ // and RETURN, but nothing ever re-asserted it: because the image ships WITHOUT
3100
+ // the agent CLI (Dockerfile INSTALL_BUILD_AGENT_CLI=none), a skipped install
3101
+ // left the VM with no `claude` anywhere for its whole lifetime and stranded the
3102
+ // Build-tab auth probe (the 2026-06-30 reprovision defect). Poll for the
3103
+ // container, then DEGRADE LOUD (never throw) if it still isn't up.
3104
+ if (!probeContainerRunning(cfg, container)) {
3105
+ if (!waitOpts.containerStarted) {
3106
+ // Precondition breach: we are about to wait for a container that no
3107
+ // caller has started. On a healthy add this branch is unreachable
3108
+ // (ensureEditorContainerUp starts the container, then calls us with
3109
+ // `containerStarted: true`). Reaching it with no start recorded means an
3110
+ // install was ordered before `docker compose up -d` — the exact
3111
+ // mis-ordering that cost ~4 minutes and a spurious CY_DEGRADED on every
3112
+ // add. Warn loudly so a future regression is caught at the log instead of
3113
+ // silently burning the wait window.
3114
+ console.error(
3115
+ ` ⚠ build-agent CLI install ordered before any editor container start ` +
3116
+ `(${container} is not running and no container-start was signalled). ` +
3117
+ `The CLI install is a POST-start step and must run AFTER \`docker compose up -d\` ` +
3118
+ `in ensureEditorContainerUp — this is very likely a call-ordering regression.`,
3119
+ );
3120
+ }
3121
+ console.error(` Container ${container} is not running yet; waiting before installing build-agent CLI...`);
3122
+ const up = await waitForContainerRunning(cfg, container, waitOpts);
3123
+ if (!up) {
3124
+ // DEFENSE-IN-DEPTH: a CLI install that can't confirm the container — a
3125
+ // genuinely slow boot, a transient operator-auth/SSH blip, or a future
3126
+ // probe bug like the {{.Name}}→{{.Names}} regression that took the fleet
3127
+ // to zero on 2026-06-30 — must DEFER, never throw. cloud:up's abort path
3128
+ // catches a throw here and DELETES the instance ("no orphan"); a healthy
3129
+ // VM with a deferred CLI install is fully recoverable (readiness flags it
3130
+ // provision-incomplete / cli-missing; the dashboard's one-click finish-wire
3131
+ // re-runs cloud:up, which re-asserts this install), but a deleted VM is not.
3132
+ // Loud signal (no silent skip — the VM5 CLI-less defect stays fixed), no
3133
+ // teardown. The `CY_DEGRADED` prefix is what the dashboard's add-job scanner
3134
+ // keys off to land an amber card.
3135
+ console.error(
3136
+ ` CY_DEGRADED cli-deferred: container ${container} never reached status=running on ${cfg.instanceName} ` +
3137
+ `within the wait window — DEFERRING build-agent CLI install (VM kept). ` +
3138
+ `Readiness will flag cli-missing; re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
3139
+ );
3140
+ return;
3141
+ }
1778
3142
  }
1779
3143
 
1780
3144
  console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
@@ -1787,22 +3151,18 @@ async function ensureAgentCliInstalled(cfg) {
1787
3151
  `AGENT="${agent}"`,
1788
3152
  "",
1789
3153
  "if command -v \"$BINARY\" >/dev/null 2>&1; then",
1790
- " if [ \"$AGENT\" = \"claude\" ]; then",
1791
- " if [ -x /usr/local/bin/claude ]; then",
1792
- " echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
1793
- " exit 0",
1794
- " fi",
1795
- " else",
1796
- " echo \" ✓ $BINARY already installed on PATH\"",
1797
- " exit 0",
1798
- " fi",
3154
+ " echo \" ✓ $BINARY already installed on PATH\"",
3155
+ " exit 0",
1799
3156
  "fi",
1800
3157
  "",
1801
3158
  "echo \"Installing $PACKAGE globally in container...\"",
1802
3159
  "if [ \"$AGENT\" = \"claude\" ]; then",
1803
3160
  " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
1804
3161
  " npm install -g \"$PACKAGE\"",
1805
- " ln -sf /usr/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude /usr/local/bin/claude",
3162
+ " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
3163
+ " if [ -n \"$RESOLVED\" ]; then",
3164
+ " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
3165
+ " fi",
1806
3166
  "else",
1807
3167
  " npm install -g \"$PACKAGE\"",
1808
3168
  "fi",
@@ -1864,19 +3224,800 @@ function promptForInstanceName(expected) {
1864
3224
  });
1865
3225
  }
1866
3226
 
3227
+ const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
3228
+
3229
+ /** The configured editor branch to compare/stream streamed scripts against.
3230
+ * Resolved from config (`resolveEditorConfig` → `editorBranch`, itself derived
3231
+ * from `cfg.editorBranch`, or the client branch when self-hosting; the dashboard
3232
+ * feeds this from `FLEET_BRANCH` / the legacy `BRANCH` alias). Never a hardcoded
3233
+ * branch literal — the dashboard tracks different branches over time. */
3234
+ function streamedScriptEditorBranch(cfg) {
3235
+ const { editorBranch = "main" } = resolveEditorConfig(cfg);
3236
+ return editorBranch;
3237
+ }
3238
+
3239
+ /** Merge base between HEAD and the remote tip of `editorBranch`, or null when it
3240
+ * can't be resolved (no remote ref, detached, etc.). */
3241
+ function streamedScriptMergeBase(editorBranch) {
3242
+ const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
3243
+ return mbResult.status === 0 ? mbResult.stdout.trim() : null;
3244
+ }
3245
+
3246
+ /** Fetch `editorBranch` from origin into a REAL remote-tracking ref
3247
+ * (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
3248
+ *
3249
+ * Operator hosts are commonly shallow, single-branch clones that track only
3250
+ * `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
3251
+ * every `git show origin/<branch>:<path>` against it fails. Fetching through an
3252
+ * explicit refspec creates the ref, which keeps the drift verdict and the bytes
3253
+ * actually streamed derived from the same object. Falls back to a plain fetch
3254
+ * (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
3255
+ * can still read through FETCH_HEAD in that case. */
3256
+ function fetchStreamedScriptRef(editorBranch) {
3257
+ const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
3258
+ const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
3259
+ if (withRef.status === 0) return { ok: true, stderr: "" };
3260
+
3261
+ const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
3262
+ if (plain.status === 0) return { ok: true, stderr: "" };
3263
+ return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
3264
+ }
3265
+
3266
+ /** Resolve one revision of a streamed script from the fetched remote, trying the
3267
+ * remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
3268
+ * (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
3269
+ * streamed bytes resolve through the SAME ref-preference order — a verdict of
3270
+ * "stream remote" can therefore never disagree with what actually got streamed.
3271
+ * Returns `{ ok: false }` only when the blob is unreadable through both refs. */
3272
+ function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
3273
+ for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
3274
+ const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
3275
+ if (result.status === 0 && typeof result.stdout === "string") {
3276
+ return { ok: true, stdout: result.stdout, ref };
3277
+ }
3278
+ }
3279
+ return { ok: false, stdout: "", ref: null };
3280
+ }
3281
+
3282
+ /** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
3283
+ * its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
3284
+ * hash, and whether the working tree is dirty. Any git failure yields a null hash,
3285
+ * treated downstream as "unknown" → fail-soft (never a false STALE). Shared by the
3286
+ * drift guard (`checkScriptDrift`) and the streaming-source decision
3287
+ * (`readStreamedScript`) so both judge identical facts. `relPath` is repo-relative
3288
+ * (the STREAMED_SCRIPTS form) — git is invoked from the repo root. */
3289
+ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
3290
+ const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
3291
+ const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
3292
+
3293
+ const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
3294
+ const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
3295
+
3296
+ let baseHash = null;
3297
+ if (mergeBase) {
3298
+ const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${relPath}`], { encoding: "utf8" });
3299
+ if (baseHashResult.status === 0) {
3300
+ baseHash = baseHashResult.stdout.trim();
3301
+ }
3302
+ }
3303
+
3304
+ const statusResult = spawnSync("git", ["status", "--porcelain", relPath], { encoding: "utf8" });
3305
+ const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
3306
+
3307
+ return { path: relPath, localHash, remoteHash, baseHash, dirty };
3308
+ }
3309
+
3310
+ /** Read the canonical bytes of a streamed provisioning script for sending to a VM.
3311
+ * Prefers the remote tip (`git show origin/<editorBranch>:<path>`) so a checkout
3312
+ * merely *behind* origin streams exactly the reviewed bytes the drift guard
3313
+ * expects — the primary fix for the "fleet just landed a script commit → every
3314
+ * un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
3315
+ * when the operator authored local content the remote tip lacks (dirty edit or a
3316
+ * committed local-ahead change — developer mode) or when the remote is unreachable
3317
+ * (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
3318
+ *
3319
+ * A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
3320
+ * checkout the on-disk bytes are precisely the stale bytes the drift guard exists
3321
+ * to keep off the VM, so substituting them silently defeats the guard: the verdict
3322
+ * said "stream the reviewed remote tip" and the VM would instead receive the old
3323
+ * script, with only a `[Warning]` to show for it. When the remote blob cannot be
3324
+ * read through either ref this throws, aborting the add.
3325
+ * `absScriptPath` is an absolute path under the repo root. */
3326
+ function readStreamedScript(absScriptPath, cfg = {}) {
3327
+ const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
3328
+ const editorBranch = streamedScriptEditorBranch(cfg);
3329
+ const mergeBase = streamedScriptMergeBase(editorBranch);
3330
+ const facts = gatherScriptDriftFacts(relPath, editorBranch, mergeBase);
3331
+
3332
+ const { streamedScriptSource } = require("./script-drift");
3333
+ if (streamedScriptSource(facts) === "remote") {
3334
+ const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
3335
+ if (remote.ok) {
3336
+ console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
3337
+ return remote.stdout;
3338
+ }
3339
+ // The facts said "the remote tip is the canonical source" but its bytes are
3340
+ // unreadable. Streaming the on-disk copy here would provision unreviewed stale
3341
+ // bytes under the banner of a successful add — fail loud instead.
3342
+ throw new Error(
3343
+ `could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
3344
+ `refusing to stream a possibly-stale on-disk copy. ` +
3345
+ `Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
3346
+ );
3347
+ }
3348
+ return fs.readFileSync(absScriptPath, "utf8");
3349
+ }
3350
+
3351
+ /** Check if the streamed bootstrap scripts have drifted from the remote repository
3352
+ * and abort provisioning when the local copy carries unreviewed content, to prevent
3353
+ * silent container/VM failures. A checkout that is merely *behind* origin is benign:
3354
+ * the add streams the canonical remote-tip bytes (see `readStreamedScript`), so
3355
+ * "behind" no longer blocks. The guard still fires for content the remote tip does
3356
+ * not have — an uncommitted (dirty) local edit or a genuine divergence. */
3357
+ function checkScriptDrift(cfg) {
3358
+ if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
3359
+ console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
3360
+ return;
3361
+ }
3362
+
3363
+ const editorBranch = streamedScriptEditorBranch(cfg);
3364
+
3365
+ console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
3366
+
3367
+ // 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
3368
+ // single-branch clone that tracks only +refs/heads/main.
3369
+ const fetchResult = fetchStreamedScriptRef(editorBranch);
3370
+ if (!fetchResult.ok) {
3371
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
3372
+ return;
3373
+ }
3374
+
3375
+ // 2. Find the merge base between HEAD and origin/<editorBranch>
3376
+ const mergeBase = streamedScriptMergeBase(editorBranch);
3377
+
3378
+ const filesData = [];
3379
+ for (const scriptPath of STREAMED_SCRIPTS) {
3380
+ const facts = gatherScriptDriftFacts(scriptPath, editorBranch, mergeBase);
3381
+ if (facts.localHash === null) {
3382
+ console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
3383
+ return;
3384
+ }
3385
+ filesData.push(facts);
3386
+ }
3387
+
3388
+ const { classifyScriptDrift, scriptDriftGuardAction } = require("./script-drift");
3389
+ const verdictResult = classifyScriptDrift({ files: filesData });
3390
+ const dirtyFiles = filesData.filter((f) => f.dirty).map((f) => f.path);
3391
+ const action = scriptDriftGuardAction({
3392
+ verdict: verdictResult.verdict,
3393
+ staleFiles: verdictResult.staleFiles,
3394
+ dirtyFiles,
3395
+ });
3396
+
3397
+ if (action.block) {
3398
+ const label = action.reason === "diverged" ? "DIVERGED" : "UNCOMMITTED LOCAL EDITS";
3399
+ console.error("");
3400
+ console.error("================================================================================");
3401
+ console.error(`✗ ERROR: Streamed scripts carry unreviewed local content (origin/${editorBranch})!`);
3402
+ console.error(` Verdict: ${label}`);
3403
+ console.error(" The following files differ locally from the reviewed remote tip:");
3404
+ for (const f of action.blockedFiles) {
3405
+ console.error(` - ${f}`);
3406
+ }
3407
+ console.error("");
3408
+ console.error(" Commit and push these changes, or discard them, before provisioning:");
3409
+ console.error(` git checkout -- <file> # discard local edits`);
3410
+ console.error(` git pull --no-rebase origin ${editorBranch}`);
3411
+ console.error("");
3412
+ console.error(" If you must bypass this check (emergency provisioning), set:");
3413
+ console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
3414
+ console.error("================================================================================");
3415
+ console.error("");
3416
+ process.exit(1);
3417
+ } else if (action.reason === "behind") {
3418
+ console.error(" ✓ Checkout is behind origin; streaming canonical remote-tip scripts (no pull needed).");
3419
+ } else if (action.reason === "local-ahead") {
3420
+ console.error(" [Warning] Streaming locally-committed scripts to VM (developer mode).");
3421
+ } else {
3422
+ console.error(" ✓ Streamed scripts are in sync with remote.");
3423
+ }
3424
+ }
3425
+
1867
3426
  // =============================================================================
1868
3427
  // Subcommand handlers
1869
3428
  // =============================================================================
1870
3429
 
1871
- async function cmdUp(args) {
3430
+ /** Decide whether a VM's Add/Destroy should wire LB ingress, and with what
3431
+ * coordinates. Wiring fires iff a fleet domain is configured (cloud.json
3432
+ * `fleetDomain` or FLEET_DOMAIN env) AND the instance is a numbered fleet VM
3433
+ * (`resolveFleetVmNumber` → non-null). A non-fleet client project, or no domain,
3434
+ * returns `{ wire: false }` so the legacy quick-tunnel path is untouched. A
3435
+ * malformed domain throws (via resolveIngressDomain) — a typo must fail loud.
3436
+ * `env` is injectable for tests. Pure decision; the spawn lives in runVmIngress. */
3437
+ function ingressWiringPlan(cfg = {}, env = process.env) {
3438
+ const domain = resolveIngressDomain(cfg, env);
3439
+ if (!domain) return { wire: false, reason: "no-domain" };
3440
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
3441
+ if (vmNumber == null) return { wire: false, reason: "not-fleet-vm", domain };
3442
+ // Resolve the custom hostname slug, if any: the Add-time --vm-slug flag on cfg
3443
+ // wins (validated/normalized in cmdUp), else the persisted sidecar entry (the
3444
+ // Destroy path, where cfg carries no slug). null ⇒ the numeric vm-<N> host.
3445
+ const slug =
3446
+ cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
3447
+ ? normalizeVmSlug(cfg.vmSlug)
3448
+ : slugFor(vmNumber);
3449
+ return { wire: true, domain, vmNumber, slug };
3450
+ }
3451
+
3452
+ /** The fleet instance-name prefix vm-ingress.sh expects (`<base>-`, e.g.
3453
+ * `codeyam-editor-codeyam-editor-`). Derived from the canonical editor repo so
3454
+ * it stays in lockstep with `resolveFleetVmNumber`'s base, rather than relying
3455
+ * on the script's hard-coded default. */
3456
+ function fleetInstancePrefix() {
3457
+ return `${deriveInstanceName(CANONICAL_EDITOR_REPO)}-`;
3458
+ }
3459
+
3460
+ /** Wire (`up`) or unwire (`down`) one VM's LB backends by spawning
3461
+ * scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
3462
+ * result so callers decide fatality — Add treats a non-zero as a loud warning
3463
+ * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
3464
+ * teardown still removes the instance. */
3465
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
3466
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3467
+ const inv = vmIngressInvocation({
3468
+ scriptPath,
3469
+ vmNumber,
3470
+ action,
3471
+ domain,
3472
+ project: cfg.project,
3473
+ zone: cfg.zone,
3474
+ appPort,
3475
+ instancePrefix: fleetInstancePrefix(),
3476
+ slug,
3477
+ });
3478
+ return spawnSync(inv.command, inv.args, {
3479
+ stdio: "inherit",
3480
+ env: { ...process.env, ...inv.env },
3481
+ });
3482
+ }
3483
+
3484
+ /** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
3485
+ * `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
3486
+ * `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
3487
+ * Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
3488
+ * VM whose backends already exist still (re-)grants the automation SA the IAP
3489
+ * accessor. Returns the spawnSync result so the caller logs granted vs failed;
3490
+ * best-effort + loud, never fatal. */
3491
+ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
3492
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3493
+ const inv = vmIngressGrantAccessInvocation({
3494
+ scriptPath,
3495
+ vmNumber,
3496
+ domain,
3497
+ project: cfg.project,
3498
+ zone: cfg.zone,
3499
+ slug,
3500
+ });
3501
+ return spawnSync(inv.command, inv.args, {
3502
+ stdio: "inherit",
3503
+ env: { ...process.env, ...inv.env },
3504
+ });
3505
+ }
3506
+
3507
+ /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
3508
+ * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
3509
+ * (the VM is reachable operator-side over SSH regardless), so on failure it
3510
+ * prints the exact re-run command rather than throwing. No-op (and silent) in
3511
+ * legacy quick-tunnel mode or for a non-fleet client project. */
3512
+ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
3513
+ const plan = ingressWiringPlan(cfg, env);
3514
+ if (!plan.wire) return;
3515
+ const label = plan.slug || `vm-${plan.vmNumber}`;
3516
+ console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
3517
+ const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
3518
+ if (res.status === 0) {
3519
+ console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3520
+ } else {
3521
+ const rewire =
3522
+ `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
3523
+ `bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} up`;
3524
+ console.error(
3525
+ ` ⚠ LB ingress wiring for VM-${plan.vmNumber} did not complete cleanly. ` +
3526
+ `The VM is provisioned; re-wire with:\n ${rewire}`,
3527
+ );
3528
+ // Machine-readable degraded marker so the unwired VM lands an amber card with
3529
+ // its remediation instead of a falsely-green "+ wired" (the VM7-10 bug). The
3530
+ // dashboard's add-job scanner (server.js) keys off `CY_DEGRADED <reason>`,
3531
+ // exactly as it does for a failed creds copy / `npm run setup`. Best-effort:
3532
+ // the Add stays green (the VM is reachable operator-side) — the marker makes
3533
+ // the missing public ingress VISIBLE, it doesn't abort provisioning.
3534
+ console.log(
3535
+ `CY_DEGRADED ingress-not-wired: VM-${plan.vmNumber} has no public LB ingress ` +
3536
+ `(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
3537
+ );
3538
+ }
3539
+ }
3540
+
3541
+ /** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
3542
+ * mirroring the destroy-time quick-tunnel cleanup so no orphaned NEG / backend
3543
+ * service / host rule is left behind. Best-effort + loud, and wrapped so a
3544
+ * malformed-domain throw can't block the instance deletion the operator asked
3545
+ * for. No-op in legacy mode / for a non-fleet project. Purely LB ingress
3546
+ * teardown — slug cleanup is NOT done here (it was, but that coupled slug
3547
+ * release to `plan.wire` and stranded non-wired VMs' slugs); `cmdDestroy` now
3548
+ * owns the unconditional `removeSlug`. */
3549
+ function unwireVmIngressOnDestroy(cfg, env = process.env) {
3550
+ let plan;
3551
+ try {
3552
+ plan = ingressWiringPlan(cfg, env);
3553
+ } catch (e) {
3554
+ console.error(` Warning: skipped LB ingress teardown (${e.message}).`);
3555
+ return;
3556
+ }
3557
+ if (!plan.wire) return;
3558
+ console.error(` unwiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
3559
+ // Pass the SAME slug the host rule was wired with so the slug host (not a stale
3560
+ // vm-<N> one) is removed; resolved from the sidecar by ingressWiringPlan.
3561
+ const res = runVmIngress(cfg, "down", plan.vmNumber, plan.domain, undefined, plan.slug);
3562
+ if (res.status !== 0) {
3563
+ console.error(
3564
+ ` Warning: LB ingress teardown for VM-${plan.vmNumber} did not complete; ` +
3565
+ `orphaned LB resources may remain. Re-run: ` +
3566
+ `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} down`,
3567
+ );
3568
+ }
3569
+ }
3570
+
3571
+ /** Grant or revoke per-VM IAP access by spawning scripts/gcp/iap-access.sh with
3572
+ * the resolved coordinates. Returns the spawnSync result so callers decide
3573
+ * fatality — Add treats a non-zero as a loud warning (the VM is provisioned;
3574
+ * access can be re-applied), Destroy likewise warns so a teardown still removes
3575
+ * the instance. `principals` is the validated list to grant/revoke. */
3576
+ function runIapAccess(cfg, vmNumber, action, principals) {
3577
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "iap-access.sh");
3578
+ const inv = iapAccessInvocation({
3579
+ scriptPath,
3580
+ vmNumber,
3581
+ action,
3582
+ principals,
3583
+ project: cfg.project,
3584
+ zone: cfg.zone,
3585
+ });
3586
+ return spawnSync(inv.command, inv.args, {
3587
+ stdio: "inherit",
3588
+ env: { ...process.env, ...inv.env },
3589
+ });
3590
+ }
3591
+
3592
+ /** Add-time hook: reconcile VM-N's access whitelist to *exactly* the requested
3593
+ * list, making provision the authoritative clean-slate point so a freed-and-
3594
+ * reused VM number never inherits the previous tenant's grants. Computes the
3595
+ * principals the prior tenant had that this Add does not want (`staleOnly`) and
3596
+ * revokes them from the per-VM backend services, grants the requested list, then
3597
+ * rewrites the sidecar to exactly the desired list (clearing the entry when the
3598
+ * list is empty). The gcloud revoke/grant only fire when the VM is wired onto the
3599
+ * LB (a fleet domain is set) — the backend services the bindings target exist
3600
+ * only then — but the sidecar is reconciled regardless, so a domain-less project
3601
+ * still starts clean and a later domain'd re-add inherits nothing stale. Add no
3602
+ * longer depends on Destroy having cleaned up (a dashboard destroy bypasses the
3603
+ * CLI revoke path). Best-effort + loud, the same posture as wireVmIngressOnAdd:
3604
+ * a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
3605
+ * command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
3606
+ * array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
3607
+ * list (the clean-slate case). */
3608
+ function applyVmAccessOnAdd(cfg, env = process.env) {
3609
+ const desired = normalizeAccessList(cfg.vmAccess || []);
3610
+ let plan;
3611
+ try {
3612
+ plan = ingressWiringPlan(cfg, env);
3613
+ } catch {
3614
+ // A malformed domain must not block an otherwise-green Add; fall back to the
3615
+ // name-derived number so the sidecar still reconciles to a clean slate.
3616
+ plan = { wire: false };
3617
+ }
3618
+ const vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
3619
+ if (vmNumber == null) return; // not a fleet VM — nothing to reconcile
3620
+ // The list we GRANT always unions in the configured default operator, so an
3621
+ // empty `vmAccess` still yields access for the operator — an IAP-enabled VM is
3622
+ // never inaccessible-by-default (the Error-51 footgun). The default operator is
3623
+ // NOT persisted to the sidecar (it's a standing grant, not a per-VM
3624
+ // collaborator), so `desired` alone drives the sidecar + stale-revoke below.
3625
+ const toGrant = withDefaultOperator(desired, resolveDefaultOperator({ env }));
3626
+ const stale = accessFor(vmNumber);
3627
+ // Never revoke a principal we're about to (re-)grant — a prior tenant who was
3628
+ // also the default operator must not be revoked-then-regranted for nothing.
3629
+ const staleOnly = stale.filter((p) => !toGrant.includes(p));
3630
+
3631
+ if (plan.wire) {
3632
+ if (staleOnly.length > 0) {
3633
+ console.error(` revoking ${staleOnly.length} stale IAP principal(s) from VM-${vmNumber} (prior tenant)...`);
3634
+ const res = runIapAccess(cfg, vmNumber, "revoke", staleOnly);
3635
+ if (res.status !== 0) {
3636
+ console.error(
3637
+ ` ⚠ IAP access revoke for VM-${vmNumber} did not complete cleanly. ` +
3638
+ `The sidecar is reconciled; re-revoke with:\n` +
3639
+ ` VM_ACCESS=${staleOnly.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
3640
+ );
3641
+ }
3642
+ }
3643
+ if (toGrant.length > 0) {
3644
+ console.error(` granting IAP access to VM-${vmNumber} for ${toGrant.length} principal(s)...`);
3645
+ const res = runIapAccess(cfg, vmNumber, "grant", toGrant);
3646
+ if (res.status === 0) {
3647
+ console.error(` ✓ IAP access granted: ${toGrant.join(", ")}`);
3648
+ } else {
3649
+ console.error(
3650
+ ` ⚠ IAP access grant for VM-${vmNumber} did not complete cleanly. ` +
3651
+ `The VM is provisioned; re-grant with:\n` +
3652
+ ` VM_ACCESS=${toGrant.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} grant`,
3653
+ );
3654
+ }
3655
+ }
3656
+ } else if (toGrant.length > 0) {
3657
+ console.error(
3658
+ ` (no fleet domain / not a fleet VM — IAP access for ${toGrant.length} principal(s) not applied; ` +
3659
+ `set a fleet domain and re-run, or use cloud:access)`,
3660
+ );
3661
+ }
3662
+
3663
+ // Sidecar reconcile (always, for a fleet VM): record the explicit collaborator
3664
+ // list (`desired`) when wired, or empty when not (nothing was applied without
3665
+ // backend services). The default operator is deliberately excluded — it is a
3666
+ // standing grant re-applied on every wire, not a per-VM collaborator, so the
3667
+ // sidecar display + a reused number's stale-revoke stay about collaborators
3668
+ // only. setAccess deletes the entry on empty, so a reused number never carries a
3669
+ // prior tenant's stale entry forward into the dashboard display or a re-add.
3670
+ const granted = plan.wire ? desired : [];
3671
+ try {
3672
+ setAccess(vmNumber, granted);
3673
+ } catch (e) {
3674
+ console.error(` ⚠ could not persist VM-${vmNumber} access list (${e.message}).`);
3675
+ }
3676
+ }
3677
+
3678
+ // The directory the add-tail in-flight marker lives in — `os.tmpdir()` (not a
3679
+ // hard-coded `/tmp`) so the windows-portability static check stays happy and the
3680
+ // dashboard reads the same path via the shared `ingressInflightMarkerPath` builder.
3681
+ const INGRESS_INFLIGHT_DIR = os.tmpdir();
3682
+
3683
+ // Add-time ingress CONVERGE loop bounds (vm--add-time-url-wiring-converges-
3684
+ // reliably). The detached add-tail re-runs `vm-ingress.sh up`+`status` on its own
3685
+ // TIGHT cadence (seconds, not the ~15-min INGRESS_RECONCILE_MS sweep) until the
3686
+ // verdict is `wired` or the deadline elapses — so a fresh VM's public URL is live
3687
+ // in ~a minute rather than whenever the next roster-wide sweep lands. Overridable
3688
+ // via env for tests / operator tuning; the sweep stays the long-run safety net.
3689
+ const INGRESS_CONVERGE_DEADLINE_MS = 8 * 60 * 1000;
3690
+ const INGRESS_CONVERGE_BASE_MS = 5000;
3691
+ const INGRESS_CONVERGE_MAX_MS = 30000;
3692
+
3693
+ /** Read a positive-integer env override, falling back to `fallback` on absent /
3694
+ * non-numeric / non-positive input. Keeps the converge bounds tunable without
3695
+ * scattering `Number(...)||default` guards through the loop. */
3696
+ function envPosInt(env, key, fallback) {
3697
+ const v = Number(env && env[key]);
3698
+ return Number.isInteger(v) && v > 0 ? v : fallback;
3699
+ }
3700
+
3701
+ /** Read one VM's LB-ingress `verdict` (`wired`|`partial`|`unwired`|null) by
3702
+ * spawning `vm-ingress.sh <N> status` and parsing its output — the read-only
3703
+ * probe the converge loop polls between `up` attempts. Best-effort: a script
3704
+ * error / missing verdict line returns null (parsed as "keep converging"),
3705
+ * never throws. */
3706
+ function probeVmIngressVerdict(cfg, plan) {
3707
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3708
+ const inv = vmIngressStatusInvocation({
3709
+ scriptPath,
3710
+ vmNumber: plan.vmNumber,
3711
+ domain: plan.domain,
3712
+ project: cfg.project,
3713
+ zone: cfg.zone,
3714
+ slug: plan.slug,
3715
+ });
3716
+ const res = spawnSync(inv.command, inv.args, {
3717
+ env: { ...process.env, ...inv.env },
3718
+ encoding: "utf8",
3719
+ });
3720
+ return parseIngressStatusVerdict(res.stdout || "");
3721
+ }
3722
+
3723
+ /** Drive a just-added VM's LB-ingress wiring to `verdict=wired` — the reliable
3724
+ * replacement for the old fire-once-and-trust-the-sweep add path
3725
+ * (vm--add-time-url-wiring-converges-reliably). Re-runs `vm-ingress.sh up`
3726
+ * (idempotent: creates only what's absent, healing a `partial`/orphaned state)
3727
+ * then re-probes `status`, looping with exponential backoff (`ingressConverge-
3728
+ * Decision`) until `wired` or the bounded deadline. On success it unlinks any
3729
+ * stale `wiring-failed` marker and returns `{ wired: true }`; on deadline it
3730
+ * drops the failed marker + a `CY_DEGRADED ingress-wiring-failed` line (a REAL
3731
+ * attention reason with the re-run command) rather than leaving a silent
3732
+ * `partial`. A no-op wire (legacy quick-tunnel / non-fleet) delegates to the
3733
+ * inline `wireVmIngressOnAdd`. All slow I/O (`runUp`, `probeVerdict`, `sleep`,
3734
+ * `now`, marker dir, and the loop bounds) is injectable via `deps` so the loop
3735
+ * is unit-testable without spawning the script or actually sleeping. */
3736
+ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}) {
3737
+ let plan;
3738
+ try {
3739
+ plan = ingressWiringPlan(cfg, env);
3740
+ } catch (e) {
3741
+ console.error(` ⚠ skipped LB ingress converge (${e && e.message}).`);
3742
+ return { wired: false, reason: "plan-error" };
3743
+ }
3744
+ if (!plan.wire) {
3745
+ // Nothing to converge — the no-op wire keeps the clean-slate invariant.
3746
+ wireVmIngressOnAdd(cfg, appPort, env);
3747
+ return { wired: true, reason: "no-wire" };
3748
+ }
3749
+
3750
+ const runUp =
3751
+ deps.runUp || ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug));
3752
+ const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
3753
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
3754
+ const now = deps.now || (() => Date.now());
3755
+ const dir = deps.markerDir || INGRESS_INFLIGHT_DIR;
3756
+ const deadlineMs =
3757
+ deps.deadlineMs != null
3758
+ ? deps.deadlineMs
3759
+ : envPosInt(env, "INGRESS_CONVERGE_DEADLINE_MS", INGRESS_CONVERGE_DEADLINE_MS);
3760
+ const baseDelayMs =
3761
+ deps.baseDelayMs != null
3762
+ ? deps.baseDelayMs
3763
+ : envPosInt(env, "INGRESS_CONVERGE_BASE_MS", INGRESS_CONVERGE_BASE_MS);
3764
+ const maxDelayMs =
3765
+ deps.maxDelayMs != null
3766
+ ? deps.maxDelayMs
3767
+ : envPosInt(env, "INGRESS_CONVERGE_MAX_MS", INGRESS_CONVERGE_MAX_MS);
3768
+
3769
+ const label = plan.slug || `vm-${plan.vmNumber}`;
3770
+ const failedMarker = ingressFailedMarkerPath(dir, plan.vmNumber);
3771
+ // Clear any failed marker a prior add/self-heal left behind before we re-attempt,
3772
+ // so a converge that now succeeds doesn't read as still-failed.
3773
+ try { fs.unlinkSync(failedMarker); } catch { /* not present — fine */ }
3774
+
3775
+ console.error(
3776
+ ` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain} (converge until wired)...`,
3777
+ );
3778
+ const start = now();
3779
+ let attempt = 0;
3780
+ for (;;) {
3781
+ runUp(plan);
3782
+ const verdict = probeVerdict(plan);
3783
+ const decision = ingressConvergeDecision({
3784
+ verdict,
3785
+ elapsedMs: now() - start,
3786
+ attempt,
3787
+ deadlineMs,
3788
+ baseDelayMs,
3789
+ maxDelayMs,
3790
+ });
3791
+ if (decision.done) {
3792
+ if (decision.wired) {
3793
+ console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3794
+ return { wired: true, reason: "wired", attempts: attempt + 1 };
3795
+ }
3796
+ const rewire =
3797
+ `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
3798
+ `bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} up`;
3799
+ try { fs.writeFileSync(failedMarker, String(now())); } catch { /* best-effort */ }
3800
+ console.error(
3801
+ ` ⚠ LB ingress for VM-${plan.vmNumber} did not converge to wired within the ` +
3802
+ `add window (last verdict=${verdict || "unknown"}). Re-wire:\n ${rewire}`,
3803
+ );
3804
+ // Machine-readable marker so the card lands a genuine `wiring-failed`
3805
+ // attention state with its remediation, not a silent partial the sweep may
3806
+ // never reach. Keyed the same way as the other CY_DEGRADED add markers.
3807
+ console.log(
3808
+ `CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
3809
+ `within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
3810
+ );
3811
+ return { wired: false, reason: "deadline", attempts: attempt + 1 };
3812
+ }
3813
+ attempt += 1;
3814
+ await sleep(decision.delayMs);
3815
+ }
3816
+ }
3817
+
3818
+ /** Start the slow LB-ingress wiring (+ IAP access grant) for a just-added VM as a
3819
+ * DETACHED background child and return immediately, so the Add completes at
3820
+ * container-healthy instead of blocking ~10 min on the wiring tail. The detached
3821
+ * child re-execs THIS cloud.js as `wire-add-tail` in its OWN session
3822
+ * (`detached: true` + `unref`), so when the dashboard reaps `cloud:up` the wiring
3823
+ * — and any in-flight `backend-services create` — is never SIGTERMed (the add-time
3824
+ * 503 strand). cmdUp writes the in-flight marker BEFORE the spawn so it exists the
3825
+ * instant this returns (the dashboard auto-rewire gather then skips this VM until
3826
+ * its first wiring finishes); the child unlinks it on completion. A no-op when the
3827
+ * add wouldn't wire anyway (legacy quick-tunnel / non-fleet) — there the cheap
3828
+ * sidecar reconcile runs inline. Best-effort + loud: a spawn failure falls back to
3829
+ * running the tail inline rather than dropping the wiring on the floor. */
3830
+ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3831
+ let plan;
3832
+ try {
3833
+ plan = ingressWiringPlan(cfg, env);
3834
+ } catch {
3835
+ plan = { wire: false };
3836
+ }
3837
+ if (!plan.wire) {
3838
+ // Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
3839
+ // sidecar write. Run them inline so the clean-slate invariant still holds.
3840
+ wireVmIngressOnAdd(cfg, appPort, env);
3841
+ applyVmAccessOnAdd(cfg, env);
3842
+ return;
3843
+ }
3844
+ const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
3845
+ const logPath = path.join(INGRESS_INFLIGHT_DIR, `cy-wire-add-vm-${plan.vmNumber}.log`);
3846
+ try {
3847
+ fs.writeFileSync(marker, String(Date.now()));
3848
+ const out = fs.openSync(logPath, "a");
3849
+ const inv = detachedAddTailInvocation({
3850
+ nodePath: process.execPath,
3851
+ scriptPath: __filename,
3852
+ cfg,
3853
+ appPort,
3854
+ });
3855
+ const child = spawn(inv.command, inv.args, {
3856
+ detached: true,
3857
+ stdio: ["ignore", out, out],
3858
+ env: { ...process.env, ...inv.env },
3859
+ });
3860
+ child.unref();
3861
+ fs.closeSync(out);
3862
+ console.error(
3863
+ ` LB ingress wiring for VM-${plan.vmNumber} on ${plan.domain} continues in the ` +
3864
+ `background (pid ${child.pid}; log ${logPath}). The Add is complete at ` +
3865
+ `container-healthy; the dashboard reconcile loop converges the public URL.`,
3866
+ );
3867
+ } catch (e) {
3868
+ console.error(
3869
+ ` ⚠ could not detach LB ingress wiring for VM-${plan.vmNumber} (${e && e.message}); ` +
3870
+ `running it inline instead.`,
3871
+ );
3872
+ try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
3873
+ wireVmIngressOnAdd(cfg, appPort, env);
3874
+ applyVmAccessOnAdd(cfg, env);
3875
+ }
3876
+ }
3877
+
3878
+ /** `wire-add-tail` (internal): the DETACHED background continuation
3879
+ * `startDetachedAddWiring` spawns. Reads the Add's `cfg`/`appPort` back out of the
3880
+ * env (so it wires EXACTLY what the foreground Add resolved), CONVERGES the wiring
3881
+ * to `verdict=wired` (retry-until-wired via convergeVmIngressOnAdd, not a lossy
3882
+ * single-shot) then runs the IAP access grant in the SAME order cmdUp ran them
3883
+ * inline, and ALWAYS unlinks the in-flight marker on the way out — even on a
3884
+ * wiring failure — so the dashboard's auto-rewire backstop takes over instead of
3885
+ * being pinned off. Not a user-facing subcommand; invoked only via re-exec. */
3886
+ async function cmdWireAddTail() {
3887
+ let cfg = {};
3888
+ try {
3889
+ cfg = JSON.parse(process.env.CY_WIRE_ADD_CFG || "{}");
3890
+ } catch (e) {
3891
+ console.error(`wire-add-tail: could not parse CY_WIRE_ADD_CFG (${e && e.message}).`);
3892
+ return;
3893
+ }
3894
+ const appPort = process.env.CY_WIRE_ADD_PORT || undefined;
3895
+ let marker = null;
3896
+ try {
3897
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
3898
+ if (vmNumber != null) {
3899
+ marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, vmNumber);
3900
+ }
3901
+ } catch { /* fall through — marker cleanup is best-effort */ }
3902
+ try {
3903
+ // Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
3904
+ // `up` left to the slow reconcile sweep — then the IAP access grant, in the
3905
+ // SAME order cmdUp ran them inline.
3906
+ await convergeVmIngressOnAdd(cfg, appPort);
3907
+ applyVmAccessOnAdd(cfg);
3908
+ } finally {
3909
+ if (marker) {
3910
+ try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
3911
+ }
3912
+ }
3913
+ }
3914
+
3915
+ /** Destroy-time hook: revoke VM-N's per-backend IAP bindings and clear its access
3916
+ * sidecar entry so a freed-and-reused VM number doesn't inherit a stale
3917
+ * whitelist. Runs BEFORE unwireVmIngressOnDestroy (which deletes the backend
3918
+ * services the bindings live on) so the revoke targets resources that still
3919
+ * exist. Best-effort + loud; the sidecar is always cleared even if the gcloud
3920
+ * revoke warns. No-op for a non-fleet instance or an empty whitelist. */
3921
+ function revokeVmAccessOnDestroy(cfg, env = process.env) {
3922
+ let vmNumber;
3923
+ try {
3924
+ const plan = ingressWiringPlan(cfg, env);
3925
+ vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
3926
+ } catch {
3927
+ // A malformed domain must not block teardown — fall back to the name-derived
3928
+ // number so the sidecar still gets cleared.
3929
+ vmNumber = resolveFleetVmNumber(cfg.instanceName);
3930
+ }
3931
+ if (vmNumber == null) return;
3932
+ const list = accessFor(vmNumber);
3933
+ if (list.length > 0) {
3934
+ console.error(` revoking IAP access for VM-${vmNumber} (${list.length} principal(s))...`);
3935
+ const res = runIapAccess(cfg, vmNumber, "revoke", list);
3936
+ if (res.status !== 0) {
3937
+ console.error(
3938
+ ` Warning: IAP access revoke for VM-${vmNumber} did not complete; ` +
3939
+ `bindings are removed when the backend service is deleted, but verify with: ` +
3940
+ `VM_ACCESS=${list.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
3941
+ );
3942
+ }
3943
+ }
3944
+ try {
3945
+ clearAccess(vmNumber);
3946
+ } catch (e) {
3947
+ console.error(` Warning: could not clear access list for VM-${vmNumber} (${e.message}).`);
3948
+ }
3949
+ }
3950
+
3951
+ /** Destroy-time hook: free the VM's custom slug so a freed-and-reused VM number
3952
+ * doesn't inherit a stale slug — and, critically, so the name is re-Addable.
3953
+ * Resolved purely from the instance name (NOT the ingress plan) so a non-wired /
3954
+ * legacy / quick-tunnel VM still releases its slug; coupling this to
3955
+ * `plan.wire` was the bug that stranded VM-7's `harvardintech`. Best-effort +
3956
+ * loud so a sidecar write hiccup can't block the teardown. No-op for a
3957
+ * non-fleet instance. Returns the released VM number, or null when none. */
3958
+ function releaseVmSlugOnDestroy(cfg) {
3959
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
3960
+ if (vmNumber == null) return null;
3961
+ try {
3962
+ removeSlug(vmNumber);
3963
+ } catch (e) {
3964
+ console.error(` Warning: could not clear slug for VM-${vmNumber} (${e.message}).`);
3965
+ }
3966
+ return vmNumber;
3967
+ }
3968
+
3969
+ async function cmdUp(args, projectDir = rootDir) {
1872
3970
  ensureGcloud();
1873
3971
  const flags = parseFlags(args);
1874
- const cfg = loadOrInitCloudConfig(rootDir, flags);
3972
+ const cfg = loadOrInitCloudConfig(projectDir, flags);
1875
3973
  // Fail loud BEFORE creating a GCE instance if an unprovisionable editor
1876
3974
  // variant was requested — otherwise we'd spin up a VM, build a dev image,
1877
3975
  // and silently ignore the operator's staging/production intent.
1878
3976
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1879
3977
 
3978
+ // Validate a custom --vm-slug BEFORE creating any GCE instance so a bad or
3979
+ // duplicate name fails loud at the boundary, never after a VM is provisioned.
3980
+ // A slug is only meaningful for a numbered fleet VM; on a non-fleet client
3981
+ // project it's dropped (with a note) so the legacy path is untouched.
3982
+ if (cfg.vmSlug != null && String(cfg.vmSlug).trim() !== "") {
3983
+ const slug = normalizeVmSlug(cfg.vmSlug); // throws on bad format/reserved/vm-N
3984
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
3985
+ if (vmNumber == null) {
3986
+ console.error(
3987
+ ` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-slug ${slug})`,
3988
+ );
3989
+ delete cfg.vmSlug;
3990
+ } else {
3991
+ const owner = vmForSlug(slug);
3992
+ if (owner != null && owner !== vmNumber) {
3993
+ throw new Error(
3994
+ `VM slug "${slug}" is already assigned to VM-${owner} — pick another name`,
3995
+ );
3996
+ }
3997
+ cfg.vmSlug = slug; // carry the normalized form for seeding + wiring
3998
+ }
3999
+ }
4000
+
4001
+ // Validate a custom --vm-access BEFORE creating any GCE instance so a malformed
4002
+ // principal fails loud at the boundary, never after a VM is provisioned (and
4003
+ // never reaches gcloud). Like --vm-slug it's only meaningful for a numbered
4004
+ // fleet VM; on a non-fleet client project it's dropped with a note. The
4005
+ // normalized array is carried on cfg for the post-provision grant.
4006
+ if (cfg.vmAccess != null && String(cfg.vmAccess).trim() !== "") {
4007
+ const principals = normalizeAccessList(cfg.vmAccess); // throws on malformed
4008
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName);
4009
+ if (vmNumber == null) {
4010
+ console.error(
4011
+ ` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-access)`,
4012
+ );
4013
+ delete cfg.vmAccess;
4014
+ } else {
4015
+ cfg.vmAccess = principals; // carry the normalized list for the grant hook
4016
+ }
4017
+ }
4018
+
4019
+ checkScriptDrift(cfg);
4020
+
1880
4021
  await ensureFirewallRule(cfg);
1881
4022
  const { createdThisRun } = await ensureInstance(cfg);
1882
4023
 
@@ -1890,25 +4031,111 @@ async function cmdUp(args) {
1890
4031
  // Write the GitHub token BEFORE compose up so the credentials file exists
1891
4032
  // when the bind-mount happens — bind-mounting a missing file would silently
1892
4033
  // create a directory instead and the agent's first `git pull` would fail.
1893
- await seedGithubCredentials(cfg);
4034
+ try {
4035
+ await seedGithubCredentials(cfg, { allowNoCredentials: cfg.allowNoGitCredentials });
4036
+ } catch (credErr) {
4037
+ if (credErr instanceof MissingGitPushCredentialsError) {
4038
+ // Never silently healthy: land an amber `CY_DEGRADED` card with the
4039
+ // recovery command (same machine-readable channel the dashboard scanner
4040
+ // reads for ingress-not-wired / failed setup) instead of aborting the
4041
+ // whole add. The VM is otherwise reachable + the Claude session works;
4042
+ // `cloud:reconcile-credentials` heals the push gap in place.
4043
+ console.log(`CY_DEGRADED ${credErr.degradedReason}`);
4044
+ console.error(credErr.message);
4045
+ } else {
4046
+ throw credErr;
4047
+ }
4048
+ }
1894
4049
  await seedAgentApiKey(cfg);
4050
+ // Greenfield only (no-op for a clone): seed projectTitle so the editor
4051
+ // resolves its identity to the real project name instead of the bare
4052
+ // /workspace mount basename — the source of the false project-dir-drift.
4053
+ await seedGreenfieldProjectTitle(cfg);
1895
4054
  await ensureEditorContainerUp(cfg);
4055
+ // Provision contract: a just-provisioned VM must actually reach GitHub.
4056
+ // Verify in-container git auth now that the container (and its credential
4057
+ // cache) is up; re-seed-then-re-assert on a stale cache, and land a loud
4058
+ // CY_DEGRADED on a persistent gap rather than a silently-green VM that
4059
+ // cannot push (VM5's 2026-06-30 could-not-read-Username symptom). Skipped
4060
+ // for an intentionally read-only VM (--allow-no-git-credentials).
4061
+ if (!cfg.allowNoGitCredentials) {
4062
+ await assertGitPushCredentials(cfg);
4063
+ }
1896
4064
  // Roster the VM into the fleet dashboard NOW that it's provisioned and the
1897
4065
  // editor container is up. Runs BEFORE the --no-tunnel early-return so the
1898
4066
  // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1899
4067
  // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1900
4068
  rosterProvisionedVm(cfg.instanceName);
4069
+ // Persist this VM's custom slug into the slug sidecar so the dashboard, the
4070
+ // ingress wiring below, and a later Destroy all resolve the same hostname.
4071
+ // Best-effort + loud (matching the roster-write posture): a sidecar-write
4072
+ // failure warns but never aborts an otherwise-green Add — the URLs simply
4073
+ // fall back to the numeric vm-<N> scheme. Only fires for a slug supplied on
4074
+ // a numbered fleet VM (the early validation already dropped a non-fleet one).
4075
+ if (cfg.vmSlug) {
4076
+ const slugVm = resolveFleetVmNumber(cfg.instanceName);
4077
+ try {
4078
+ setSlug(slugVm, cfg.vmSlug);
4079
+ console.error(` ✓ assigned slug "${cfg.vmSlug}" to VM-${slugVm}`);
4080
+ } catch (e) {
4081
+ console.error(
4082
+ ` ⚠ failed to persist slug "${cfg.vmSlug}" for VM-${slugVm} (${e && e.message}); ` +
4083
+ `URLs fall back to vm-${slugVm}.`,
4084
+ );
4085
+ delete cfg.vmSlug; // keep wiring + display consistent with what was persisted
4086
+ }
4087
+ }
1901
4088
  // Terminal gate: provisioning is NOT "done" until the editor container is
1902
4089
  // verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
1903
4090
  // own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
1904
4091
  // us — a run reported success with no container). A failure here is a loud
1905
4092
  // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
1906
4093
  await assertEditorContainerRunning(cfg);
4094
+ // Terminal gate #2: the editor container is up, but the agent CLI is what the
4095
+ // Build-tab auth probe actually needs. Assert it's present (install + re-check
4096
+ // if not), failing loud rather than rostering a CLI-less VM.
4097
+ await assertAgentCliInstalled(cfg);
4098
+ // Wire this VM onto the shared Google HTTPS LB when a fleet domain is
4099
+ // configured — the stable-URL replacement for the quick-tunnel mint. Runs
4100
+ // AFTER the terminal container gate so the editor/preview ports the NEG
4101
+ // targets are live, and is best-effort (the VM is reachable over SSH
4102
+ // regardless). The wiring (+ the IAP access grant that depends on it) is kicked
4103
+ // off DETACHED so the Add returns at container-healthy instead of blocking
4104
+ // ~10 min on the EXTERNAL_MANAGED backend-services create + propagation tail;
4105
+ // the detached child outlives this process so the dashboard's reap of cloud:up
4106
+ // can't SIGTERM an in-flight `backend-services create` (the add-time 503
4107
+ // strand), and the dashboard's auto-rewire gather converges the public URL in
4108
+ // the background. A no-op in legacy quick-tunnel mode / for a non-fleet project
4109
+ // (there the cheap sidecar reconcile runs inline). See
4110
+ // startDetachedAddWiring → wire-add-tail.
4111
+ startDetachedAddWiring(cfg, appPort);
4112
+ // Now that the editor answers on its own surface, ask it whether the
4113
+ // pre-container seed wrong-moded the previewOrigin (the stack.json race) and
4114
+ // heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
4115
+ // can't abort an otherwise-green add.
4116
+ try {
4117
+ await healPreviewOriginAtReady(cfg, appPort);
4118
+ } catch (healErr) {
4119
+ console.error(
4120
+ ` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
4121
+ `the cloudflared-monitor tick will reconcile.`,
4122
+ );
4123
+ }
1907
4124
  } catch (err) {
1908
4125
  if (createdThisRun) {
1909
4126
  console.error(
1910
4127
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
1911
4128
  );
4129
+ // Drop the slug + access sidecar entries for this brand-new VM so a
4130
+ // torn-down number doesn't leave a dangling slug or whitelist behind
4131
+ // (best-effort — never block teardown).
4132
+ const torndownVm = resolveFleetVmNumber(cfg.instanceName);
4133
+ if (cfg.vmSlug && torndownVm != null) {
4134
+ try { removeSlug(torndownVm); } catch { /* best-effort cleanup */ }
4135
+ }
4136
+ if (cfg.vmAccess && torndownVm != null) {
4137
+ try { clearAccess(torndownVm); } catch { /* best-effort cleanup */ }
4138
+ }
1912
4139
  try {
1913
4140
  console.error(`Deleting instance ${cfg.instanceName}...`);
1914
4141
  const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
@@ -1940,10 +4167,10 @@ async function cmdUp(args) {
1940
4167
  attachKeepAliveAndTeardown(tunnelChild);
1941
4168
  }
1942
4169
 
1943
- async function cmdTunnel(args) {
4170
+ async function cmdTunnel(args, projectDir = rootDir) {
1944
4171
  ensureGcloud();
1945
4172
  const flags = parseFlags(args);
1946
- const existing = loadCloudConfig(rootDir);
4173
+ const existing = loadCloudConfig(projectDir);
1947
4174
  if (!existing) {
1948
4175
  throw new Error(
1949
4176
  "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
@@ -1971,10 +4198,10 @@ async function cmdTunnel(args) {
1971
4198
  attachKeepAliveAndTeardown(tunnelChild);
1972
4199
  }
1973
4200
 
1974
- async function cmdStop(args) {
4201
+ async function cmdStop(args, projectDir = rootDir) {
1975
4202
  ensureGcloud();
1976
4203
  parseFlags(args);
1977
- const cfg = loadCloudConfig(rootDir);
4204
+ const cfg = loadCloudConfig(projectDir);
1978
4205
  if (!cfg) {
1979
4206
  throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
1980
4207
  }
@@ -1990,10 +4217,10 @@ async function cmdStop(args) {
1990
4217
  );
1991
4218
  }
1992
4219
 
1993
- async function cmdDestroy(args) {
4220
+ async function cmdDestroy(args, projectDir = rootDir) {
1994
4221
  ensureGcloud();
1995
4222
  parseFlags(args);
1996
- const cfg = loadCloudConfig(rootDir);
4223
+ const cfg = loadCloudConfig(projectDir);
1997
4224
  if (!cfg) {
1998
4225
  throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
1999
4226
  }
@@ -2004,12 +4231,28 @@ async function cmdDestroy(args) {
2004
4231
  return;
2005
4232
  }
2006
4233
 
4234
+ // Revoke the VM's per-backend IAP access + clear its whitelist sidecar BEFORE
4235
+ // unwiring, so the revoke targets backend services that still exist (unwire
4236
+ // deletes them). No-op for a non-fleet instance / empty whitelist.
4237
+ revokeVmAccessOnDestroy(cfg);
4238
+
4239
+ // Unwire the VM's LB backends BEFORE deleting the instance (when a fleet
4240
+ // domain is set) so no orphaned NEG / backend service / host rule survives —
4241
+ // mirrors the destroy-time quick-tunnel cleanup. No-op in legacy mode.
4242
+ unwireVmIngressOnDestroy(cfg);
4243
+
2007
4244
  console.error(`Deleting instance ${cfg.instanceName}...`);
2008
4245
  const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
2009
4246
  if (del.status !== 0) {
2010
4247
  throw new Error(`Failed to delete instance ${cfg.instanceName}`);
2011
4248
  }
2012
4249
 
4250
+ // Free the VM's custom slug now that the instance is gone, so the name is
4251
+ // re-Addable. Decoupled from unwireVmIngressOnDestroy (whose `plan.wire` gate
4252
+ // used to strand non-wired VMs' slugs); a sibling destroy-time hook like the
4253
+ // access/ingress teardown above.
4254
+ releaseVmSlugOnDestroy(cfg);
4255
+
2013
4256
  const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
2014
4257
  stdio: ["ignore", "pipe", "ignore"],
2015
4258
  });
@@ -2027,17 +4270,17 @@ async function cmdDestroy(args) {
2027
4270
  }
2028
4271
 
2029
4272
  try {
2030
- fs.unlinkSync(cloudConfigPath(rootDir));
4273
+ fs.unlinkSync(cloudConfigPath(projectDir));
2031
4274
  console.error("Deleted .codeyam/cloud.json");
2032
4275
  } catch (e) {
2033
4276
  if (e.code !== "ENOENT") throw e;
2034
4277
  }
2035
4278
  }
2036
4279
 
2037
- async function cmdStatus(args) {
4280
+ async function cmdStatus(args, projectDir = rootDir) {
2038
4281
  ensureGcloud();
2039
4282
  parseFlags(args);
2040
- const cfg = loadCloudConfig(rootDir);
4283
+ const cfg = loadCloudConfig(projectDir);
2041
4284
  if (!cfg) {
2042
4285
  console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
2043
4286
  return;
@@ -2079,12 +4322,710 @@ async function cmdStatus(args) {
2079
4322
  );
2080
4323
  }
2081
4324
 
4325
+ /** `cloud:ingress-up` — provision the SHARED, VM-independent LB infra (static
4326
+ * IP, wildcard managed cert, Cloud DNS records, URL map, IAP brand) once for the
4327
+ * fleet by spawning scripts/gcp/google-ingress.sh. Idempotent: re-running against
4328
+ * already-provisioned infra is a safe no-op. Requires a fleet domain (cloud.json
4329
+ * `fleetDomain` or FLEET_DOMAIN env); the per-VM backends are wired by cloud:up's
4330
+ * Add path. Run this before the first fleetDomain Add (or after flipping it on). */
4331
+ async function cmdIngressUp(args, projectDir = rootDir) {
4332
+ ensureGcloud();
4333
+ parseFlags(args);
4334
+ const cfg = loadCloudConfig(projectDir);
4335
+ if (!cfg) {
4336
+ throw new Error(
4337
+ "No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
4338
+ );
4339
+ }
4340
+ const domain = resolveIngressDomain(cfg, process.env);
4341
+ if (!domain) {
4342
+ throw new Error(
4343
+ "No fleet domain configured. Set `fleetDomain` in .codeyam/cloud.json " +
4344
+ "(e.g. via `cloud:up -- --fleet-domain fleet.example.com`) or export FLEET_DOMAIN.",
4345
+ );
4346
+ }
4347
+ const dnsZone = resolveDnsZone(cfg, process.env);
4348
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "google-ingress.sh");
4349
+ const inv = sharedIngressInvocation({
4350
+ scriptPath,
4351
+ domain,
4352
+ project: cfg.project,
4353
+ zone: cfg.zone,
4354
+ dnsZone,
4355
+ region: regionFromZone(cfg.zone),
4356
+ });
4357
+ console.error(
4358
+ `Provisioning shared LB ingress for ${domain} (project ${cfg.project}, DNS zone ${dnsZone})...`,
4359
+ );
4360
+ const res = spawnSync(inv.command, inv.args, {
4361
+ stdio: "inherit",
4362
+ env: { ...process.env, ...inv.env },
4363
+ });
4364
+ if (res.status !== 0) {
4365
+ throw new Error("scripts/gcp/google-ingress.sh failed; see output above.");
4366
+ }
4367
+ console.error(
4368
+ "✓ shared LB ingress provisioned. Per-VM backends are wired automatically by " +
4369
+ "`cloud:up` (Add) once `fleetDomain` is set in cloud.json.",
4370
+ );
4371
+ }
4372
+
4373
+ /** Detect unwired / half-wired rostered VMs and re-wire them idempotently.
4374
+ * Enumerates the fleet roster (the same source the dashboard reads), runs the
4375
+ * read-only `vm-ingress.sh <N> status` probe per VM, and for any reporting
4376
+ * `unwired` or `partial` re-runs `vm-ingress.sh <N> up` — which skips the
4377
+ * resources already present and creates only the missing backend / host rule.
4378
+ * A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
4379
+ * automatically at dashboard boot so a fleet that lost ingress self-heals. */
4380
+ async function cmdReconcileIngress(args, projectDir = rootDir) {
4381
+ ensureGcloud();
4382
+ parseFlags(args);
4383
+ const cfg = loadCloudConfig(projectDir) || {};
4384
+ const domain = resolveIngressDomain(cfg, process.env);
4385
+ if (!domain) {
4386
+ console.error(
4387
+ "No fleet domain configured — ingress reconcile is a no-op (legacy quick-tunnel mode).",
4388
+ );
4389
+ return;
4390
+ }
4391
+ const roster = readRoster() || [];
4392
+ if (!roster.length) {
4393
+ console.error("Fleet roster is empty — nothing to reconcile.");
4394
+ return;
4395
+ }
4396
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
4397
+ console.error(`Reconciling LB ingress for ${roster.length} rostered VM(s) on ${domain}...`);
4398
+ const statuses = roster.map((n) => {
4399
+ const slug = slugFor(n);
4400
+ const inv = vmIngressStatusInvocation({
4401
+ scriptPath,
4402
+ vmNumber: n,
4403
+ domain,
4404
+ project: cfg.project,
4405
+ zone: cfg.zone,
4406
+ slug,
4407
+ });
4408
+ // Bound the per-VM status probe: a hung `bash vm-ingress.sh <N> status`
4409
+ // (its gcloud describes stalling on an IAP/metadata hiccup) must not stall the
4410
+ // whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
4411
+ // returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
4412
+ // this VM and the sweep continues to the next one.
4413
+ const res = spawnSync(inv.command, inv.args, {
4414
+ env: { ...process.env, ...inv.env },
4415
+ encoding: "utf8",
4416
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
4417
+ });
4418
+ return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
4419
+ });
4420
+ const targets = selectIngressReconcileTargets(statuses);
4421
+ const targetNums = new Set(targets.map((t) => t.vmNumber));
4422
+ // The complement of the re-wire loop: `wired` VMs whose backends already exist
4423
+ // so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
4424
+ // `up` is the only path that grants it, and it never runs for a wired VM. This
4425
+ // closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
4426
+ // fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
4427
+ const grantOnlyNums = new Set(
4428
+ selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
4429
+ );
4430
+ for (const s of statuses) {
4431
+ if (targetNums.has(s.vmNumber)) continue;
4432
+ const label = s.slug || `vm-${s.vmNumber}`;
4433
+ if (grantOnlyNums.has(s.vmNumber)) {
4434
+ // Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
4435
+ // NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
4436
+ // bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
4437
+ const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
4438
+ console.error(
4439
+ ` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
4440
+ );
4441
+ } else {
4442
+ console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
4443
+ }
4444
+ }
4445
+ for (const t of targets) {
4446
+ const label = t.slug || `vm-${t.vmNumber}`;
4447
+ // Resolve the VM's real dev-server port so a re-created preview NEG targets
4448
+ // the right port (not the 5173 bootstrap fallback). Best-effort — readVmAppPort
4449
+ // itself falls back to VITE_PORT on any SSH/parse failure.
4450
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
4451
+ const appPort = await readVmAppPort(perVmCfg);
4452
+ const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug);
4453
+ console.error(
4454
+ ` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
4455
+ );
4456
+ }
4457
+
4458
+ // Converge each rostered backend-service's timeout to 86400 (24h). A fully-wired
4459
+ // BES still at GCP's 30s default reports `wired` above and is skipped by the
4460
+ // re-wire loop, yet on an External HTTPS LB that 30s is the MAX WebSocket lifetime
4461
+ // — the editor's /ws/terminal socket is guillotined every 30s (the Build-tab
4462
+ // "Reconnecting…" loop). Assert the timeout per rostered BES so the running fleet
4463
+ // self-heals without a manual sweep — idempotent (a no-op when already 86400). The
4464
+ // durable per-`up` converge lives in vm-ingress.sh; this catches already-wired VMs
4465
+ // the periodic reconcile never re-`up`s. See plan vm--lb-backend-service-websocket-timeout.
4466
+ const gi = require("./google-ingress.js");
4467
+ for (const n of roster) {
4468
+ for (const surface of gi.SURFACES) {
4469
+ const bes = gi.backendServiceName(n, surface);
4470
+ const inv = backendServiceTimeoutInvocation({ bes, project: cfg.project });
4471
+ // Bound the per-BES converge too: a hung `gcloud backend-services update`
4472
+ // returns a non-zero (timed-out) result, so it falls into the skip branch
4473
+ // below and the sweep moves on to the next BES rather than stalling.
4474
+ const res = spawnSync(inv.command, inv.args, {
4475
+ encoding: "utf8",
4476
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
4477
+ });
4478
+ if (res.status === 0) {
4479
+ console.error(` ${bes}: timeout → 86400`);
4480
+ } else {
4481
+ const tail = (res.stderr || "").trim().split("\n").pop() || "error";
4482
+ console.error(` ${bes}: timeout converge skipped (${tail})`);
4483
+ }
4484
+ }
4485
+ }
4486
+
4487
+ // Converge the shared fleet firewall, then surface any app port it still fails to
4488
+ // admit. Both ride this same boot + periodic cadence so the running fleet
4489
+ // self-heals without a hand-run bootstrap.
4490
+ const firewallState = convergeFleetFirewall({ cfg });
4491
+ await reportFirewallPortGaps({ cfg, roster, firewallState });
4492
+
4493
+ // Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
4494
+ // backend service whose VM number is gone from the roster is a destroyed VM's
4495
+ // leftover — cleaned here automatically with the verified `down`, so the
4496
+ // operator never sees a manual `vm-ingress.sh <N> down` command. Rides the same
4497
+ // boot + periodic cadence as the wiring reconcile above.
4498
+ sweepOrphanIngress({ cfg, domain, roster, projectDir });
4499
+ }
4500
+
4501
+ /** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
4502
+ * resources whose owning VM number is gone from the roster (a destroyed VM) and
4503
+ * has no in-flight add. Enumerates the live backend services AND zonal NEGs,
4504
+ * UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
4505
+ * backend service — the VM-5/6 case — still surfaces its VM as a candidate),
4506
+ * maps each name back to its VM number, subtracts the roster + in-flight adds +
4507
+ * live instances (the false-orphan guards), and runs the VERIFIED
4508
+ * `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
4509
+ * each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
4510
+ * never a silent leak and never a manual command. Emits a machine-readable
4511
+ * `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
4512
+ * orphan-attention banner; the summary carries `stillPresent` (VM numbers that
4513
+ * still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
4514
+ * banner for a VM that is now back in the roster or has no residual resources.
4515
+ * Best-effort per VM: one `down` failing never aborts the rest of the sweep.
4516
+ * Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
4517
+ * this pass (a live-but-unrostered VM must never be torn down) while the banner
4518
+ * reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
4519
+ * summary (null when the backend-service enumeration itself failed) — the same
4520
+ * shape it prints — so the flow is directly assertable. `deps` is injectable so
4521
+ * the enumerate → select → down flow is testable without gcloud. */
4522
+ /** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
4523
+ * port a project's app actually serves.
4524
+ *
4525
+ * A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
4526
+ * LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
4527
+ * outage — while the preview backend service still reads HEALTHY, because its
4528
+ * health check runs on the always-admitted editor port. The failure is therefore
4529
+ * invisible until someone opens the preview. Converging here (not only in the
4530
+ * hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
4531
+ * the running fleet.
4532
+ *
4533
+ * All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
4534
+ * to Google's LB + health-check source ranges and the fleet instance tag either
4535
+ * way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
4536
+ * without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
4537
+ * converged: describing only one silently no-ops on a fleet wired under the other.
4538
+ *
4539
+ * Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
4540
+ * protocols/ports, never a rule's source ranges or target tags, so this can never
4541
+ * change who the rule applies to. Best-effort throughout: a describe/update
4542
+ * failure logs and continues, never aborting the rest of the reconcile.
4543
+ *
4544
+ * Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
4545
+ * is the post-converge allowed value — so the flow is assertable without gcloud.
4546
+ * `deps.spawnSync` is injectable for the same reason.
4547
+ * See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
4548
+ function convergeFleetFirewall({ cfg } = {}, deps = {}) {
4549
+ const run = deps.spawnSync || spawnSync;
4550
+ const project = cfg && cfg.project;
4551
+ const state = [];
4552
+ for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
4553
+ const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
4554
+ const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
4555
+ // A non-zero describe means no rule under this name — expected, not an error:
4556
+ // only one of the candidate names is deployed on any given fleet.
4557
+ if (!dres || dres.status !== 0) continue;
4558
+ const current = ((dres.stdout || "") + "").trim();
4559
+ if (!firewallNeedsConverge(current)) {
4560
+ console.error(` firewall ${ruleName}: already all-tcp → ok`);
4561
+ state.push({ ruleName, rules: "tcp", converged: false });
4562
+ continue;
4563
+ }
4564
+ const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
4565
+ const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
4566
+ if (cres && cres.status === 0) {
4567
+ console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
4568
+ state.push({ ruleName, rules: "tcp", converged: true });
4569
+ } else {
4570
+ const tail = (
4571
+ (cres && (cres.stderr || (cres.error && cres.error.message))) || ""
4572
+ ).toString().trim().split("\n").pop() || "error";
4573
+ console.error(` firewall ${ruleName}: converge skipped (${tail})`);
4574
+ state.push({ ruleName, rules: current, converged: false });
4575
+ }
4576
+ }
4577
+ if (!state.length) {
4578
+ console.error(
4579
+ ` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
4580
+ " LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
4581
+ );
4582
+ }
4583
+ return state;
4584
+ }
4585
+
4586
+ /** Surface any rostered VM whose app port the fleet firewall does not admit — the
4587
+ * tripwire for a masked "HEALTHY but unreachable" preview.
4588
+ *
4589
+ * The preview backend service health-checks the fixed editor port, so a rule that
4590
+ * admits that port but not the app's real one reads HEALTHY while every real
4591
+ * request is dropped. That masking is what made the VM-6 outage invisible. Emits a
4592
+ * machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
4593
+ * `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
4594
+ *
4595
+ * Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
4596
+ * port, so the healthy path cannot produce a gap and skipping it avoids an SSH
4597
+ * round-trip per rostered VM for a check that cannot fail. In practice this fires
4598
+ * only if the converge itself failed (e.g. a permissions regression).
4599
+ *
4600
+ * Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
4601
+ * shape it prints — so the marker contract is directly assertable.
4602
+ * `deps.readVmAppPort` is injectable so tests never reach for SSH. */
4603
+ async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
4604
+ const readPort = deps.readVmAppPort || readVmAppPort;
4605
+ const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
4606
+ const gaps = [];
4607
+ if (!pinned.length) return gaps;
4608
+ for (const n of roster || []) {
4609
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
4610
+ const appPort = await readPort(perVmCfg);
4611
+ for (const f of pinned) {
4612
+ if (firewallAdmitsPort(f.rules, appPort)) continue;
4613
+ const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
4614
+ console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
4615
+ console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
4616
+ gaps.push(gap);
4617
+ }
4618
+ }
4619
+ return gaps;
4620
+ }
4621
+
4622
+ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
4623
+ const gi = deps.googleIngress || require("./google-ingress.js");
4624
+ const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
4625
+ const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
4626
+ const run = deps.spawnSync || spawnSync;
4627
+
4628
+ const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
4629
+ const listRes = run(listInv.command, listInv.args, { encoding: "utf8" });
4630
+ if (!listRes || listRes.status !== 0) {
4631
+ const tail = (
4632
+ (listRes && (listRes.stderr || (listRes.error && listRes.error.message))) || ""
4633
+ ).toString().trim().split("\n").pop() || "error";
4634
+ console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
4635
+ return null;
4636
+ }
4637
+ const besVmNumbers = String(listRes.stdout || "")
4638
+ .split("\n")
4639
+ .map((line) => gi.vmNumberFromResourceName(line.trim()))
4640
+ .filter((n) => n != null);
4641
+
4642
+ // Also enumerate zonal NEGs and union their owners in: a partial teardown can
4643
+ // leave a `vm-<N>-<surface>-neg` behind after its backend service is already
4644
+ // gone, so a backend-service-only candidate set misses that residue. Best-effort
4645
+ // — a NEG-list failure never aborts the backend-service-driven sweep.
4646
+ let negVmNumbers = [];
4647
+ const negInv = negListInvocation({ project: cfg && cfg.project });
4648
+ const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
4649
+ if (negRes && negRes.status === 0) {
4650
+ negVmNumbers = String(negRes.stdout || "")
4651
+ .split("\n")
4652
+ .map((line) => gi.vmNumberFromResourceName(line.trim()))
4653
+ .filter((n) => n != null);
4654
+ } else {
4655
+ const tail = (
4656
+ (negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
4657
+ ).toString().trim().split("\n").pop() || "error";
4658
+ console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
4659
+ }
4660
+ const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
4661
+
4662
+ // A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
4663
+ // not yet rostered), is live — never an orphan.
4664
+ const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
4665
+ const inFlightAdds = fcl
4666
+ .listPendingJobs(pendingDir)
4667
+ .filter((j) => j.action === "add")
4668
+ .map((j) => j.n);
4669
+
4670
+ // Defensive live-instance guard: never tear down a VM whose GCP instance is
4671
+ // actually running (a roster drift could leave a live VM unrostered; a teardown
4672
+ // that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
4673
+ // means the list couldn't be fetched — fail safe by skipping teardown this pass
4674
+ // (below) rather than risk a false teardown. The banner reconcile still runs.
4675
+ let liveVmNumbers = null;
4676
+ const instInv = instancesListInvocation({ project: cfg && cfg.project });
4677
+ const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
4678
+ if (instRes && instRes.status === 0) {
4679
+ liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
4680
+ } else {
4681
+ const tail = (
4682
+ (instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
4683
+ ).toString().trim().split("\n").pop() || "error";
4684
+ console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
4685
+ }
4686
+
4687
+ const orphans = liveVmNumbers === null
4688
+ ? []
4689
+ : selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
4690
+
4691
+ // stillPresent = every VM that owned an LB resource going in, minus the ones we
4692
+ // verifiably clean below. The dashboard clears a banner for any orphan VM not in
4693
+ // this set (or now back in the roster). Computed up front so the no-orphans and
4694
+ // fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
4695
+ // (re-rostered, so never a sweep target) with no teardown at all.
4696
+ const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
4697
+ if (!orphans.length) {
4698
+ console.error(" orphan sweep: no orphaned LB ingress found.");
4699
+ console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
4700
+ return summary;
4701
+ }
4702
+
4703
+ console.error(` orphan sweep: auto-cleaning ${orphans.length} orphaned VM ingress (${orphans.join(", ")})...`);
4704
+ const scriptPath = path.join(projectDir, "scripts", "gcp", "vm-ingress.sh");
4705
+ for (const n of orphans) {
4706
+ // A destroyed VM's slug is normally already released, so slugFor(n) is null and
4707
+ // the numeric vm-<N> host rules are torn down; if a destroy left the slug behind,
4708
+ // slugFor(n) resolves it so the <slug>.<surface> host rules go too.
4709
+ const slug = slugFor(n) || undefined;
4710
+ const inv = vmIngressInvocation({
4711
+ scriptPath,
4712
+ vmNumber: n,
4713
+ action: "down",
4714
+ domain,
4715
+ project: cfg && cfg.project,
4716
+ zone: cfg && cfg.zone,
4717
+ instancePrefix: fleetInstancePrefix(),
4718
+ slug,
4719
+ });
4720
+ const res = run(inv.command, inv.args, { encoding: "utf8", env: { ...process.env, ...inv.env } });
4721
+ const verdict = parseIngressTeardownVerdict((res && res.stdout) || "");
4722
+ // Pass `signal` too: a teardown the caller's group-kill reaped has a NULL
4723
+ // status, which the classifier would otherwise report as `spawn-failed`
4724
+ // rather than the truthful `killed=SIGKILL`.
4725
+ const outcome = classifyOrphanDownResult({
4726
+ verdict,
4727
+ status: res ? res.status : null,
4728
+ signal: res ? res.signal : null,
4729
+ });
4730
+ if (outcome.cleaned) {
4731
+ summary.cleaned.push(n);
4732
+ console.error(` VM-${n}: orphaned ingress → auto-cleaned (verified clean)`);
4733
+ } else {
4734
+ const tail = `${(res && res.stdout) || ""}${(res && res.stderr) || ""}`
4735
+ .trim().split("\n").pop() || "no verdict";
4736
+ summary.failed.push({ n, cause: outcome.cause });
4737
+ console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
4738
+ }
4739
+ }
4740
+ // A verifiably-cleaned VM no longer owns any LB resource — drop it from
4741
+ // stillPresent so the dashboard clears its banner (failed VMs stay, keeping
4742
+ // their real attention item).
4743
+ const cleanedSet = new Set(summary.cleaned);
4744
+ summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
4745
+ console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
4746
+ return summary;
4747
+ }
4748
+
4749
+ /** `cloud:reconcile-credentials` — self-heal a running VM that came up (or
4750
+ * drifted) without git push credentials, WITHOUT a container restart so a live
4751
+ * Claude session survives. Probes the host staging file + an in-container
4752
+ * `git ls-remote origin`, classifies any failure via {@link decideCredentialHeal}
4753
+ * (so a transient network blip never triggers a needless re-write), and on a
4754
+ * credential gap re-seeds BOTH the durable host file (survives restart via the
4755
+ * entrypoint) and the live in-memory credential-cache daemon (immediate, no
4756
+ * restart). This codifies the manual recovery run on all 9 fleet VMs on
4757
+ * 2026-06-29. */
4758
+ /** One SSH round-trip probe of a running VM's git push credentials: reports
4759
+ * whether the host staging file (`~/.codeyam-cloud-git-credentials`) is empty
4760
+ * and the in-container `git ls-remote origin` outcome, parsed into the exact
4761
+ * shape {@link decideCredentialHeal} consumes (`{ stagingEmpty, lsRemoteOk,
4762
+ * lsRemoteStderr }`). Shared by {@link cmdReconcileCredentials} (the manual
4763
+ * self-heal verb) and {@link assertGitPushCredentials} (the provision-time
4764
+ * guarantee) so the two never drift on probe wire-format. Throws on an SSH
4765
+ * error — the caller decides whether that is fatal or best-effort. */
4766
+ async function probeGitCredentials(cfg) {
4767
+ const repoName = resolveProject(cfg).name;
4768
+ const projectCd = `cd ~/projects/${repoName}`;
4769
+ const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
4770
+ const probeCmd =
4771
+ `f=$HOME/.codeyam-cloud-git-credentials; ` +
4772
+ `if [ -s "$f" ]; then echo STAGING=nonempty; else echo STAGING=empty; fi; ` +
4773
+ `${projectCd} && if ${composeExec} git -C /workspace ls-remote origin -h HEAD ` +
4774
+ `>/dev/null 2>/tmp/cy-lsr.err; then echo LSREMOTE=ok; ` +
4775
+ `else echo LSREMOTE=fail; sed 's/^/LSRERR:/' /tmp/cy-lsr.err; fi`;
4776
+ const probe = runGcloud(
4777
+ buildGcloudArgs("ssh-probe", cfg, { command: probeCmd }),
4778
+ { stdio: ["ignore", "pipe", "ignore"] },
4779
+ );
4780
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
4781
+ throw new Error(`Failed to probe credentials on ${cfg.instanceName} (SSH error).`);
4782
+ }
4783
+ const out = probe.stdout;
4784
+ return {
4785
+ stagingEmpty: /STAGING=empty/.test(out),
4786
+ lsRemoteOk: /LSREMOTE=ok/.test(out),
4787
+ lsRemoteStderr: out
4788
+ .split("\n")
4789
+ .filter((l) => l.startsWith("LSRERR:"))
4790
+ .map((l) => l.slice("LSRERR:".length))
4791
+ .join("\n"),
4792
+ };
4793
+ }
4794
+
4795
+ /** Seed the LIVE in-memory git credential-cache daemon over its socket — the
4796
+ * same `git credential approve` shape the entrypoint uses
4797
+ * (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
4798
+ * container restart. ALSO writes the container-local on-disk `store` fallback
4799
+ * file (the fill-only second helper baked into the image) directly, so a
4800
+ * reconcile leaves the VM durable against the cache daemon dying mid-life, not
4801
+ * just healthy for the moment. base64 keeps the token out of the shell history
4802
+ * across the gcloud-ssh → docker layers, and the store write is an in-place
4803
+ * `cat > file` truncate (chmod 600), never touched by git's approve path.
4804
+ * Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
4805
+ * {@link assertGitPushCredentials}. */
4806
+ async function seedLiveCredentialCache(cfg, token) {
4807
+ const repoName = resolveProject(cfg).name;
4808
+ const projectCd = `cd ~/projects/${repoName}`;
4809
+ const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
4810
+ const socket = "/run/codeyam/git-credential-cache.socket";
4811
+ const store = "/run/codeyam/git-credentials-store";
4812
+ const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
4813
+ const b64 = Buffer.from(approveStdin).toString("base64");
4814
+ // The store-file line is the same one-URL-per-line format as the host staging
4815
+ // file; the image's fill-only `store` helper reads it on a cache miss.
4816
+ const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
4817
+ const seedCmd =
4818
+ `${projectCd} && ` +
4819
+ `echo ${b64} | base64 -d | ${composeExec} ` +
4820
+ `git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
4821
+ `credential approve && ` +
4822
+ `echo ${storeB64} | base64 -d | ${composeExec} ` +
4823
+ `bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
4824
+ `echo APPROVED`;
4825
+ const seed = runGcloud(
4826
+ buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
4827
+ { stdio: ["ignore", "pipe", "inherit"] },
4828
+ );
4829
+ if (seed.status !== 0 || !/APPROVED/.test(String(seed.stdout || ""))) {
4830
+ throw new Error(
4831
+ `Failed to seed the live credential cache on ${cfg.instanceName}.`,
4832
+ );
4833
+ }
4834
+ }
4835
+
4836
+ async function cmdReconcileCredentials(args, projectDir = rootDir) {
4837
+ ensureGcloud();
4838
+ parseFlags(args);
4839
+ const cfg = loadCloudConfig(projectDir);
4840
+ if (!cfg) {
4841
+ throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
4842
+ }
4843
+
4844
+ console.error(`Probing git push credentials on ${cfg.instanceName}...`);
4845
+ const decision = decideCredentialHeal(await probeGitCredentials(cfg));
4846
+ console.error(` category=${decision.category}: ${decision.reason}`);
4847
+ if (!decision.heal) {
4848
+ console.error(" ✓ no re-seed needed.");
4849
+ return;
4850
+ }
4851
+
4852
+ // Re-seed needs a token. Reuse seedGithubCredentials for the durable host-file
4853
+ // write (it throws MissingGitPushCredentialsError when none resolves — the
4854
+ // honest "can't heal without a token" outcome), then seed the live cache.
4855
+ await seedGithubCredentials(cfg);
4856
+ await seedLiveCredentialCache(cfg, resolveGithubTokenWithSource().token);
4857
+ console.error(
4858
+ ` ✓ re-seeded host file + live credential cache on ${cfg.instanceName}; ` +
4859
+ `plan-claim sync will recover on the next tick.`,
4860
+ );
4861
+ }
4862
+
4863
+ /** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
4864
+ * WITHOUT a full re-provision. Resolves the desired value from this invocation
4865
+ * (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
4866
+ * CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
4867
+ * compose edit + `docker compose up -d editor` does today, so the durable admin
4868
+ * setting no longer requires a full re-provision to change. */
4869
+ async function cmdReconcileAdmin(args, projectDir = rootDir) {
4870
+ ensureGcloud();
4871
+ const flags = parseFlags(args);
4872
+ const cfg = loadCloudConfig(projectDir);
4873
+ if (!cfg) {
4874
+ throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
4875
+ }
4876
+ const desired =
4877
+ buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
4878
+ const repoName = resolveProject(cfg).name;
4879
+ console.error(
4880
+ `Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
4881
+ );
4882
+ const result = runGcloud(
4883
+ buildGcloudArgs("ssh-probe", cfg, {
4884
+ command: buildReconcileAdminCommand(repoName, desired),
4885
+ }),
4886
+ { stdio: "inherit" },
4887
+ );
4888
+ if (result.status !== 0) {
4889
+ throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
4890
+ }
4891
+ console.error(
4892
+ ` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
4893
+ );
4894
+ }
4895
+
4896
+ /** Provision-time guarantee that a just-provisioned VM can actually reach
4897
+ * GitHub. Probes the staging file + in-container `git ls-remote origin` and
4898
+ * routes the outcome through the SAME {@link decideCredentialHeal} classifier
4899
+ * the manual `cloud:reconcile-credentials` verb uses (no parallel logic). On a
4900
+ * credential gap it re-seeds the durable host file + live cache daemon and
4901
+ * re-asserts ONCE. A failure that persists after the re-seed — or no token to
4902
+ * seed with — ends in a loud `CY_DEGRADED git push credentials missing` marker
4903
+ * (the dashboard's amber-card channel), never a silently-green VM that cannot
4904
+ * push (VM5's 2026-06-30 "could not read Username for github.com" symptom).
4905
+ * Best-effort on infra errors: an SSH-probe failure is logged and skipped
4906
+ * rather than aborting an otherwise-green Add, matching seedGithubCredentials'
4907
+ * degraded posture. */
4908
+ async function assertGitPushCredentials(cfg) {
4909
+ let probe;
4910
+ try {
4911
+ probe = await probeGitCredentials(cfg);
4912
+ } catch (err) {
4913
+ // Probe itself failed (SSH error) — can't classify; log loud, don't abort.
4914
+ console.error(` (could not verify git push credentials: ${err.message})`);
4915
+ return;
4916
+ }
4917
+ const decision = decideCredentialHeal(probe);
4918
+ console.error(` git push credentials: category=${decision.category}: ${decision.reason}`);
4919
+ if (!decision.heal) {
4920
+ // Healthy, or a transient network blip that re-seeding wouldn't fix anyway.
4921
+ return;
4922
+ }
4923
+
4924
+ // A re-seed needs a token. Without one we cannot heal — land the loud marker
4925
+ // instead of throwing inside seedGithubCredentials (which would abort the Add).
4926
+ const { token } = resolveGithubTokenWithSource();
4927
+ if (!token) {
4928
+ console.log(
4929
+ "CY_DEGRADED git push credentials missing: no token resolved to seed the VM. " +
4930
+ "Recover: npm run cloud:reconcile-credentials",
4931
+ );
4932
+ return;
4933
+ }
4934
+
4935
+ // Best-effort heal: the container is already up and the VM is otherwise
4936
+ // reachable, so a re-seed/approve/probe error must DEGRADE (loud CY_DEGRADED),
4937
+ // never throw out of cmdUp and abort an otherwise-green Add. Mirrors the
4938
+ // seedGithubCredentials catch in cmdUp.
4939
+ try {
4940
+ await seedGithubCredentials(cfg);
4941
+ await seedLiveCredentialCache(cfg, token);
4942
+
4943
+ // Re-assert once: a stale/incomplete cache should now ls-remote cleanly.
4944
+ const reprobe = await probeGitCredentials(cfg);
4945
+ if (!reprobe.lsRemoteOk) {
4946
+ console.log(
4947
+ "CY_DEGRADED git push credentials missing: git ls-remote still fails after re-seed. " +
4948
+ "Recover: npm run cloud:reconcile-credentials",
4949
+ );
4950
+ return;
4951
+ }
4952
+ console.error(` ✓ git push credentials verified after re-seed on ${cfg.instanceName}.`);
4953
+ } catch (err) {
4954
+ console.log(
4955
+ `CY_DEGRADED git push credentials missing: re-seed failed (${err.message}). ` +
4956
+ "Recover: npm run cloud:reconcile-credentials",
4957
+ );
4958
+ }
4959
+ }
4960
+
4961
+ /** `cloud:access` — edit an existing VM's IAP access whitelist without
4962
+ * re-provisioning. Resolves the VM number from --instance-name (or cloud.json),
4963
+ * mutates the access sidecar, and applies just the delta against the live
4964
+ * backend services via scripts/gcp/iap-access.sh:
4965
+ * --add <principal> grant one principal (idempotent)
4966
+ * --remove <principal> revoke one principal
4967
+ * --list print the current whitelist (no gcloud call)
4968
+ * Exactly one of the three is required. */
4969
+ async function cmdAccess(args, projectDir = rootDir) {
4970
+ const flags = parseFlags(args);
4971
+ const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
4972
+ const instanceName = cfg.instanceName;
4973
+ if (!instanceName) {
4974
+ throw new Error(
4975
+ "cloud:access requires --instance-name <name> (or a .codeyam/cloud.json with one).",
4976
+ );
4977
+ }
4978
+ const vmNumber = resolveFleetVmNumber(instanceName);
4979
+ if (vmNumber == null) {
4980
+ throw new Error(`'${instanceName}' is not a numbered fleet VM — per-VM access does not apply.`);
4981
+ }
4982
+
4983
+ if (flags.list) {
4984
+ const list = accessFor(vmNumber);
4985
+ console.error(`VM-${vmNumber} IAP access whitelist (${list.length}):`);
4986
+ if (list.length === 0) console.error(" (none — only the owner's project-wide grant applies)");
4987
+ for (const p of list) console.error(` ${p}`);
4988
+ return;
4989
+ }
4990
+
4991
+ const adding = flags.add != null && String(flags.add).trim() !== "";
4992
+ const removing = flags.remove != null && String(flags.remove).trim() !== "";
4993
+ if (adding === removing) {
4994
+ throw new Error("cloud:access requires exactly one of --add <principal> / --remove <principal> / --list.");
4995
+ }
4996
+
4997
+ ensureGcloud();
4998
+ const principal = normalizePrincipal(adding ? flags.add : flags.remove); // throws on malformed
4999
+ const action = adding ? "grant" : "revoke";
5000
+ // Mutate the sidecar first so the source of truth reflects intent even if the
5001
+ // gcloud apply later warns; both calls are idempotent.
5002
+ if (adding) addAccess(vmNumber, principal);
5003
+ else removeAccess(vmNumber, principal);
5004
+
5005
+ console.error(`${action === "grant" ? "Granting" : "Revoking"} IAP access for VM-${vmNumber}: ${principal}`);
5006
+ const res = runIapAccess(cfg, vmNumber, action, [principal]);
5007
+ if (res.status === 0) {
5008
+ console.error(` ✓ ${action}ed. Current whitelist: ${accessFor(vmNumber).join(", ") || "(none)"}`);
5009
+ } else {
5010
+ console.error(
5011
+ ` ⚠ gcloud ${action} did not complete cleanly; the sidecar was updated. Re-run:\n` +
5012
+ ` VM_ACCESS=${principal} bash scripts/gcp/iap-access.sh ${vmNumber} ${action}`,
5013
+ );
5014
+ }
5015
+ }
5016
+
2082
5017
  const HANDLERS = {
2083
5018
  up: cmdUp,
2084
5019
  tunnel: cmdTunnel,
2085
5020
  stop: cmdStop,
2086
5021
  destroy: cmdDestroy,
2087
5022
  status: cmdStatus,
5023
+ "ingress-up": cmdIngressUp,
5024
+ "reconcile-ingress": cmdReconcileIngress,
5025
+ "wire-add-tail": cmdWireAddTail,
5026
+ "reconcile-credentials": cmdReconcileCredentials,
5027
+ "reconcile-admin": cmdReconcileAdmin,
5028
+ access: cmdAccess,
2088
5029
  };
2089
5030
 
2090
5031
  async function cloudMain() {
@@ -2106,14 +5047,23 @@ async function cloudMain() {
2106
5047
 
2107
5048
  module.exports = {
2108
5049
  // pure helpers
5050
+ isSpawnTimeout,
2109
5051
  agentEnvVarName,
2110
5052
  agentNpmPackage,
2111
5053
  AGENT_ENV_VARS,
2112
5054
  AGENT_NPM_PACKAGES,
2113
5055
  ensureAgentCliInstalled,
5056
+ ensureEditorContainerUp,
5057
+ seedAgentApiKey,
5058
+ assertAgentCliInstalled,
5059
+ probeContainerRunning,
5060
+ probeContainerState,
5061
+ probeAgentBinaryInstalled,
5062
+ waitForContainerRunning,
2114
5063
  KNOWN_FLAGS,
2115
5064
  FIREWALL_RULE_NAME,
2116
5065
  INSTANCE_TAG,
5066
+ DEFAULT_FLEET_MACHINE_TYPE,
2117
5067
  EDITOR_PORT,
2118
5068
  VITE_PORT,
2119
5069
  parseFlags,
@@ -2131,6 +5081,8 @@ module.exports = {
2131
5081
  resolveSeedPreviewOrigin,
2132
5082
  previewSameOriginSafeFromStack,
2133
5083
  isRottedManagedPreviewOrigin,
5084
+ isWrongModePreviewOrigin,
5085
+ isSilentlyBrokenPreview,
2134
5086
  resolveEditorConfig,
2135
5087
  validateEditorVariant,
2136
5088
  assertVariantProvisionable,
@@ -2138,14 +5090,23 @@ module.exports = {
2138
5090
  reconcileProjectExclusivity,
2139
5091
  resolveGhcrToken,
2140
5092
  resolveGhcrPushToken,
5093
+ probeUsableEditorImage,
5094
+ httpsManifestGet,
2141
5095
  resolveProject,
2142
5096
  clientProjectBootstrapArgs,
2143
5097
  cloudConfigPath,
2144
5098
  loadCloudConfig,
2145
5099
  saveCloudConfig,
5100
+ loadOrInitCloudConfig,
5101
+ buildProvisionEnv,
5102
+ buildReconcileAdminCommand,
5103
+ cmdReconcileAdmin,
2146
5104
  buildEditorLocalEnvPatch,
5105
+ buildGreenfieldTitlePatch,
5106
+ mergeEditorLocalPatchB64,
2147
5107
  buildEditorLocalSeedWriteCommand,
2148
5108
  seedPreviewOrigin,
5109
+ healPreviewOriginAtReady,
2149
5110
  buildGcloudArgs,
2150
5111
  sshScriptCommand,
2151
5112
  bootstrapRunnerScript,
@@ -2156,6 +5117,22 @@ module.exports = {
2156
5117
  decideBootstrapOutcome,
2157
5118
  projectBootstrapDoneMarker,
2158
5119
  projectBootstrapDoneProbeCommand,
5120
+ projectBootstrapScriptPath,
5121
+ projectBootstrapRunnerPath,
5122
+ projectBootstrapLogPath,
5123
+ projectBootstrapPidPath,
5124
+ projectBootstrapStatusPath,
5125
+ projectBootstrapRunnerScript,
5126
+ projectBootstrapLaunchCommand,
5127
+ projectBootstrapFollowCommand,
5128
+ projectBootstrapStatusCommand,
5129
+ parseProjectBootstrapStatus,
5130
+ decideProjectBootstrapOutcome,
5131
+ decideProjectBootstrapFollowAction,
5132
+ projectBootstrapEnv,
5133
+ PROJECT_BOOTSTRAP_LAUNCHED_MARKER,
5134
+ PROJECT_BOOTSTRAP_FOLLOW_SECS,
5135
+ ensureProjectBootstrapped,
2159
5136
  SSH_KEEPALIVE_FLAGS,
2160
5137
  resolveSetupForwardEnv,
2161
5138
  FORWARD_ENV_KEYS_ENV,
@@ -2167,15 +5144,54 @@ module.exports = {
2167
5144
  ensureInstance,
2168
5145
  ensureBootstrapped,
2169
5146
  assertEditorContainerRunning,
5147
+ checkScriptDrift,
5148
+ streamedScriptEditorBranch,
5149
+ streamedScriptMergeBase,
5150
+ fetchStreamedScriptRef,
5151
+ resolveRemoteScriptRev,
5152
+ gatherScriptDriftFacts,
5153
+ readStreamedScript,
5154
+ STREAMED_SCRIPTS,
5155
+ // ingress wiring (pure decision + spawn wrappers)
5156
+ envPosInt,
5157
+ ingressWiringPlan,
5158
+ fleetInstancePrefix,
5159
+ runVmIngress,
5160
+ wireVmIngressOnAdd,
5161
+ probeVmIngressVerdict,
5162
+ convergeVmIngressOnAdd,
5163
+ unwireVmIngressOnDestroy,
5164
+ // per-VM IAP access (spawn wrapper + Add/Destroy hooks)
5165
+ runIapAccess,
5166
+ applyVmAccessOnAdd,
5167
+ revokeVmAccessOnDestroy,
5168
+ releaseVmSlugOnDestroy,
2170
5169
  // side-effecting handlers
2171
5170
  cmdUp,
2172
5171
  cmdTunnel,
2173
5172
  cmdStop,
2174
5173
  cmdDestroy,
2175
5174
  cmdStatus,
5175
+ cmdIngressUp,
5176
+ cmdReconcileIngress,
5177
+ sweepOrphanIngress,
5178
+ convergeFleetFirewall,
5179
+ reportFirewallPortGaps,
5180
+ cmdReconcileCredentials,
5181
+ cmdAccess,
2176
5182
  cloudMain,
5183
+ probeGitCredentials,
5184
+ seedLiveCredentialCache,
5185
+ assertGitPushCredentials,
2177
5186
  seedGithubCredentials,
5187
+ githubCredentialLine,
5188
+ resolveGithubToken,
5189
+ resolveGithubTokenWithSource,
5190
+ MissingGitPushCredentialsError,
5191
+ classifyGitRemoteError,
5192
+ decideCredentialHeal,
2178
5193
  readVmAppPort,
5194
+ buildVmAppPortReadCommand,
2179
5195
  resolveLaptopGitIdentity,
2180
5196
  };
2181
5197