@codeyam-editor/codeyam-editor 0.1.0-staging.g388918e → 0.1.0-staging.g52ee0eb

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 (79) hide show
  1. package/npm/cloud.js +1551 -175
  2. package/npm/codesign-plan.js +68 -0
  3. package/npm/compose-conflict.js +59 -0
  4. package/npm/editor-dev.js +24 -2
  5. package/npm/editor.js +15 -5
  6. package/npm/empty-project-dir.js +92 -7
  7. package/npm/fleet-add-failure.js +622 -0
  8. package/npm/fleet-auth-health.js +121 -5
  9. package/npm/fleet-branch.js +124 -1
  10. package/npm/fleet-build-data.js +62 -4
  11. package/npm/fleet-cleanup.js +302 -1
  12. package/npm/fleet-datastore-health.js +107 -0
  13. package/npm/fleet-destroy-safety.js +230 -13
  14. package/npm/fleet-early-roster.js +87 -0
  15. package/npm/fleet-effect-poll.js +110 -0
  16. package/npm/fleet-env.js +86 -0
  17. package/npm/fleet-ghcr-secret.js +129 -0
  18. package/npm/fleet-git-maintenance.js +142 -0
  19. package/npm/fleet-host-op-limiter.js +68 -0
  20. package/npm/fleet-iap-token.js +165 -0
  21. package/npm/fleet-job-badge.js +28 -12
  22. package/npm/fleet-memory.js +306 -8
  23. package/npm/fleet-plans.js +81 -3
  24. package/npm/fleet-pool.js +75 -16
  25. package/npm/fleet-prewarm.js +87 -11
  26. package/npm/fleet-probe-transport.js +155 -0
  27. package/npm/fleet-project-drift.js +58 -0
  28. package/npm/fleet-public-url.js +127 -7
  29. package/npm/fleet-queue.js +199 -1
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-revision.js +212 -0
  32. package/npm/fleet-run-setup.js +66 -0
  33. package/npm/fleet-running-plan.js +37 -7
  34. package/npm/fleet-session-activity.js +162 -0
  35. package/npm/fleet-session-token-cache.js +65 -0
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-vm-access.js +276 -7
  39. package/npm/fleet-vm-probe-target.js +282 -0
  40. package/npm/fleet-vm-readiness.js +78 -20
  41. package/npm/ingress-invoke.js +356 -23
  42. package/npm/launch-config.js +404 -8
  43. package/npm/scenario-check.js +924 -33
  44. package/npm/scenario-handlers.js +103 -27
  45. package/npm/scenario-interactivity.js +602 -19
  46. package/npm/scenario-issues.js +32 -1
  47. package/npm/scenario-mocks.js +340 -31
  48. package/npm/scenario-playwright.js +461 -42
  49. package/npm/utils.js +298 -24
  50. package/package.json +5 -5
  51. package/ui/dist/assets/{ContainerInspector-B3vO6op1.js → ContainerInspector-B_1ncmBb.js} +1 -1
  52. package/ui/dist/assets/EditorShell-BscDOu9z.js +326 -0
  53. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  54. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  55. package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
  56. package/ui/dist/assets/ScenarioDataPanel-BJd8d23d.js +15 -0
  57. package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-tiaa3re-.js} +1 -1
  58. package/ui/dist/assets/ScenarioRenderer-Dptkb4Di.js +21 -0
  59. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  60. package/ui/dist/assets/index-BG9MM5pc.js +27 -0
  61. package/ui/dist/assets/index-DAvNv22V.css +1 -0
  62. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  63. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  64. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  65. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  66. package/ui/dist/index.html +4 -4
  67. package/ui/dist/open-source/codeyam-counter.png +0 -0
  68. package/ui/dist/open-source/el-carot.png +0 -0
  69. package/ui/dist/open-source/tabcommand.png +0 -0
  70. package/ui/dist/preview-subdir-demo/index.html +62 -0
  71. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  72. package/ui/dist/assets/EditorShell-CndrQhoL.js +0 -191
  73. package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
  74. package/ui/dist/assets/ProjectLauncherScreen-Bq8CJo9M.js +0 -1
  75. package/ui/dist/assets/ScenarioDataPanel-DIAw2DpS.js +0 -5
  76. package/ui/dist/assets/ScenarioRenderer-rbCbaiLe.js +0 -21
  77. package/ui/dist/assets/index-B14nyUpe.js +0 -27
  78. package/ui/dist/assets/index-DK-ZOfVw.css +0 -1
  79. package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/cloud.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  const { spawn, spawnSync } = require("child_process");
13
13
  const fs = require("fs");
14
+ const https = require("https");
14
15
  const os = require("os");
15
16
  const path = require("path");
16
17
  const readline = require("readline");
@@ -58,6 +59,7 @@ const {
58
59
  normalizePrincipal,
59
60
  resolveDefaultOperator,
60
61
  withDefaultOperator,
62
+ unauthorizableAddRefusal,
61
63
  accessFor,
62
64
  setAccess,
63
65
  addAccess,
@@ -75,12 +77,16 @@ const {
75
77
  regionFromZone,
76
78
  vmIngressInvocation,
77
79
  vmIngressStatusInvocation,
80
+ vmIngressGrantAccessInvocation,
78
81
  parseIngressStatusVerdict,
79
82
  parseIngressTeardownVerdict,
80
83
  selectIngressReconcileTargets,
84
+ selectIngressGrantOnlyTargets,
81
85
  selectOrphanIngressTargets,
82
86
  classifyOrphanDownResult,
83
87
  backendServicesListInvocation,
88
+ negListInvocation,
89
+ instancesListInvocation,
84
90
  ingressConvergeDecision,
85
91
  detachedAddTailInvocation,
86
92
  ingressInflightMarkerPath,
@@ -88,6 +94,11 @@ const {
88
94
  iapAccessInvocation,
89
95
  sharedIngressInvocation,
90
96
  backendServiceTimeoutInvocation,
97
+ FLEET_FIREWALL_RULE_NAMES,
98
+ fleetFirewallDescribeInvocation,
99
+ fleetFirewallConvergeInvocation,
100
+ firewallNeedsConverge,
101
+ firewallAdmitsPort,
91
102
  } = require("./ingress-invoke");
92
103
  // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
93
104
  // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
@@ -95,6 +106,10 @@ const {
95
106
  // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
96
107
  // fleet for `reconcile-ingress`.
97
108
  const { addToRoster, readRoster } = require("./fleet-roster");
109
+ // Tells a docker container-name conflict (a concurrent run already started this
110
+ // container) apart from a real `docker compose up -d` failure, so the abort path
111
+ // can converge instead of escalating to instance deletion.
112
+ const { classifyComposeUpFailure } = require("./compose-conflict");
98
113
 
99
114
  // =============================================================================
100
115
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -157,14 +172,34 @@ const KNOWN_FLAGS = [
157
172
  // default is loud — a tokenless VM cannot sync plan claims and must never
158
173
  // report healthy silently. Use only when push genuinely isn't wanted.
159
174
  "allow-no-git-credentials",
175
+ // Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
176
+ // so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
177
+ // cloud:up …`; absent = strict non-admin (the safe default). Survives
178
+ // re-provision because it rides the compose-render substitution, unlike a
179
+ // manual VM compose edit.
180
+ "admin",
181
+ // GCE machine type for the VM (e.g. n2-standard-4, e2-standard-4). Persisted
182
+ // in cloud.json so a later bare `cloud:up` keeps it. Defaults to
183
+ // DEFAULT_FLEET_MACHINE_TYPE. The whole point of exposing this is fleet
184
+ // scale-out: the region's E2_CPUS quota (72 in us-central1) caps the fleet at
185
+ // ~14 e2-standard-4 VMs, while N2_CPUS/N2D_CPUS (200, unused) leave room for
186
+ // 30 — so the fleet defaults to N2 and this flag lets a caller override.
187
+ "machine-type",
160
188
  ];
161
189
 
162
190
  // Flags that take no value (presence = true). parseFlags must not demand a
163
191
  // value for these or `--no-tunnel` would error "requires a value".
164
- const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
192
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
165
193
 
166
194
  const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
167
195
  const INSTANCE_TAG = "codeyam-editor";
196
+ // Default GCE machine type for fleet VMs. N2 (not E2) because the fleet must
197
+ // reach 30 VMs: us-central1 E2_CPUS quota is 72, so 30 × e2-standard-4 (120
198
+ // vCPU) + the operator host exceed it and cap the fleet at ~14 regardless of
199
+ // code — while N2_CPUS/N2D_CPUS are 200 and unused, leaving headroom for 30 ×
200
+ // n2-standard-4. Override per-VM with `--machine-type`. See the vm--scale-
201
+ // fleet-to-30-vms plan for the measured quota rationale.
202
+ const DEFAULT_FLEET_MACHINE_TYPE = "n2-standard-4";
168
203
  const EDITOR_PORT = 14199;
169
204
  const VITE_PORT = 5173;
170
205
 
@@ -548,6 +583,32 @@ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
548
583
  return LOOPBACK_ORIGIN_RE.test(origin);
549
584
  }
550
585
 
586
+ /** Decide whether a VM is presenting as HEALTHY while its Live Preview is
587
+ * silently dead — the VM-9 failure mode. The heal
588
+ * (`reconcilePreviewOriginToSlugHost`) fixes the mechanism; this predicate is
589
+ * the smoke detector that catches a FUTURE mechanism gap (a new stack shape, a
590
+ * new provisioning path) by observation instead of by a user noticing a blank
591
+ * iframe days later.
592
+ *
593
+ * Input is the editor's own `/api/config` payload — the effective truth table
594
+ * (`previewSameOriginSafe` + the resolved `previewOrigin`), not a
595
+ * re-derivation from stack.json over ssh. True iff the stack is NOT
596
+ * same-origin-safe AND its effective previewOrigin is one a remote browser can
597
+ * never load (the `/__codeyam_preview` sentinel, any relative path, or a
598
+ * loopback origin) — i.e. exactly `isWrongModePreviewOrigin`'s wrong-mode
599
+ * condition, evaluated against the live config rather than editor.local.json.
600
+ * Composing the two keeps ONE definition of "wrong-mode origin" in this file.
601
+ *
602
+ * Deliberately conservative: a missing/unparseable payload, an absent
603
+ * `previewSameOriginSafe`, or a legitimate same-origin-safe stack on the
604
+ * subpath all return false. Absence of evidence is never a false alarm — the
605
+ * caller renders "unknown" instead. Pure. */
606
+ function isSilentlyBrokenPreview(apiConfig) {
607
+ if (!apiConfig || typeof apiConfig !== "object") return false;
608
+ if (apiConfig.previewSameOriginSafe !== false) return false;
609
+ return isWrongModePreviewOrigin(apiConfig, false);
610
+ }
611
+
551
612
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
552
613
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
553
614
  function readCloudflaredUrls() {
@@ -745,6 +806,28 @@ function buildEditorLocalEnvPatch(localEnv, agent) {
745
806
  return { provider: agent, env: { [varName]: value } };
746
807
  }
747
808
 
809
+ /** Build the editor.local.json patch that seeds a GREENFIELD project's
810
+ * `projectTitle` so its live identity resolves to the real project name from
811
+ * first boot. A greenfield project (`--new-project <name>`, no clientRepo) has
812
+ * no origin remote and is bind-mounted at /workspace on the VM, so the editor's
813
+ * identity resolver would otherwise fall back to the bare mount basename
814
+ * "workspace" — which the fleet dashboard then reads as a permanent false
815
+ * project-dir-drift. Seeding `projectTitle` (deep-merged over editor.json on the
816
+ * VM) makes `project_identity::read` prefer the real name.
817
+ *
818
+ * Returns `null` for a clone (a repo Add already labels from its origin
819
+ * basename, and re-seeding a clone's title would be wrong) or when no
820
+ * greenfield name is available, so the caller can no-op without an SSH round
821
+ * trip. Pure — mirrors buildEditorLocalEnvPatch so the decision is unit-tested
822
+ * without a live VM. */
823
+ function buildGreenfieldTitlePatch(cfg = {}) {
824
+ const p = resolveProject(cfg);
825
+ if (!p.greenfield) return null;
826
+ const name = typeof p.name === 'string' ? p.name.trim() : '';
827
+ if (!name) return null;
828
+ return { projectTitle: name };
829
+ }
830
+
748
831
  /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
749
832
  * line) and return the editor service's `State` string, or null when the
750
833
  * service is absent / output is unparseable. Two callers shared an identical
@@ -767,6 +850,79 @@ function parseEditorComposeState(stdout) {
767
850
  return null;
768
851
  }
769
852
 
853
+ /** Decide whether `cloud:up`'s abort handler may delete the instance it is
854
+ * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
855
+ * → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
856
+ * `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
857
+ * exhaustively testable without a VM.
858
+ *
859
+ * `createdThisRun` stays a NECESSARY condition — never delete an instance this
860
+ * run did not create — but 2026-08-07 proved it is not SUFFICIENT. It answers
861
+ * "did I create this?", not "is it safe to destroy?", and in a concurrent
862
+ * provision race the losing run legitimately owns the flag while the winner has
863
+ * already brought the VM up and rostered it. Deleting there destroyed VM-4 and
864
+ * VM-5. So authorization also requires CURRENT-STATE evidence that the VM is
865
+ * out of service:
866
+ *
867
+ * - `containerUp` — the editor container is running (per the compose-ps probe).
868
+ * A VM serving an editor is in use by definition.
869
+ * - `rostered` — the VM is in the fleet roster, the operator-visible source of
870
+ * truth. Note this is true whether the winning run or THIS run rostered it;
871
+ * either way the VM is visible in the dashboard and removable in one command,
872
+ * so it is not the silent orphan teardown exists to prevent.
873
+ * - `probeInconclusive` — liveness could not be determined. This must fail
874
+ * SAFE: a failing probe is how we reach the abort handler in the first place,
875
+ * so it can never count as evidence the VM is dead. An orphan costs one
876
+ * `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
877
+ * any unpushed work on the VM. */
878
+ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
879
+ if (!createdThisRun) return "keep";
880
+ if (probeInconclusive) return "keep";
881
+ if (containerUp) return "keep";
882
+ if (rostered) return "keep";
883
+ return "delete";
884
+ }
885
+
886
+ /** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
887
+ * abort handler reads gather → decide → act instead of inlining probes among
888
+ * the delete calls.
889
+ *
890
+ * Only runs the probes when `createdThisRun` is true — the decision is `keep`
891
+ * regardless otherwise, and an abort path should not spend SSH round trips
892
+ * proving something it already knows. In that case it returns the
893
+ * probe-inconclusive shape, which is the safe default at every layer.
894
+ *
895
+ * Both lookups are wrapped: a throwing probe or an unreadable roster must fail
896
+ * SAFE (toward `keep`), never take a second exception path into the delete.
897
+ * `deps` is injectable so the matrix is unit-tested without a VM. */
898
+ function probeAbortTeardownState(cfg, createdThisRun, deps = {}) {
899
+ if (!createdThisRun) {
900
+ return { containerUp: false, rostered: false, probeInconclusive: true };
901
+ }
902
+ const probe = deps.probeEditorComposeState || probeEditorComposeState;
903
+ const roster = deps.readRoster || readRoster;
904
+ const resolveVm = deps.resolveFleetVmNumber || resolveFleetVmNumber;
905
+ const log = deps.log || ((m) => console.error(m));
906
+
907
+ let liveness = { state: null, inconclusive: true };
908
+ try {
909
+ liveness = probe(cfg);
910
+ } catch (e) {
911
+ log(` (liveness probe failed: ${e && e.message})`);
912
+ }
913
+ let rostered = false;
914
+ try {
915
+ const vm = resolveVm(cfg.instanceName);
916
+ rostered = vm != null && (roster() || []).includes(vm);
917
+ } catch { /* unreadable roster — treated as not-rostered; liveness still guards */ }
918
+
919
+ return {
920
+ containerUp: liveness.state === "running",
921
+ rostered,
922
+ probeInconclusive: liveness.inconclusive,
923
+ };
924
+ }
925
+
770
926
  /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
771
927
  * switch's job is to keep every per-verb command shape in one reviewable
772
928
  * place — tests assert the arrays without ever spawning a process. */
@@ -787,7 +943,7 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
787
943
  return [
788
944
  "compute", "instances", "create", instanceName,
789
945
  "--project", project, "--zone", zone,
790
- "--machine-type=e2-standard-4",
946
+ `--machine-type=${cfg.machineType || DEFAULT_FLEET_MACHINE_TYPE}`,
791
947
  "--image-family=debian-12",
792
948
  "--image-project=debian-cloud",
793
949
  "--boot-disk-size=100GB",
@@ -887,11 +1043,61 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
887
1043
  // real use. Unit tests pin their exports so a refactor cannot drop them.
888
1044
  // =============================================================================
889
1045
 
1046
+ // Per-call wall-clock budget for a single gcloud / SSH invocation. A hung
1047
+ // `gcloud describe` (metadata / IAP hiccup) used to block the whole
1048
+ // `cloud:reconcile-ingress` sweep forever — the only timeout in the reconcile
1049
+ // path was the outer 10-minute exec budget in the dashboard, and that never
1050
+ // cascaded to the orphaned descendants. Bounding every per-call spawn well under
1051
+ // that outer budget means a single hang trips HERE first: the call returns a
1052
+ // timed-out result (formatGcloudFailure renders it), the sweep skips that VM,
1053
+ // and no descendant is left hanging to pile up. Shared by runGcloud and the two
1054
+ // reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
1055
+ // reference one value. 2 min is generous for any single describe/update/SSH probe
1056
+ // yet comfortably under the 600000 ms outer reconcile budget.
1057
+ const GCLOUD_CALL_TIMEOUT_MS = 120000;
1058
+
1059
+ // The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
1060
+ // the repo root and every `apps[].dir`, each package's `postinstall`, then
1061
+ // `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
1062
+ // blocking `runGcloud("ssh-script")` call, so it inherited GCLOUD_CALL_TIMEOUT_MS
1063
+ // (a budget whose own comment scopes it to "any single describe/update/SSH
1064
+ // probe"). That SIGTERMd the gcloud child mid-clone; `spawnSync` then returned
1065
+ // `status: null`, which `!== 0`, so the timeout was indistinguishable from a
1066
+ // genuine remote failure and the caller's abort path DELETED A COMPLETELY
1067
+ // HEALTHY VM.
1068
+ //
1069
+ // Observed twice: 2026-07-28 on client repo `lets-go-compost-portal` (a root
1070
+ // postinstall of `playwright install chromium`, ~293 MB), and 2026-07-29
1071
+ // provisioning six self-host `codeyam-editor` VMs, whose clone alone exceeds the
1072
+ // probe budget — all six were built, torn down mid-clone, and reported as
1073
+ // "scripts/gcp-project-bootstrap.sh failed on the VM".
1074
+ //
1075
+ // The install is now DETACHED (projectBootstrapLaunchCommand + a re-attaching
1076
+ // follow loop, outcome read from VM-side state), so no single call carries the
1077
+ // whole install any more. This constant is therefore the OVERALL deadline across
1078
+ // re-attaches — the point at which we stop waiting on a still-healthy VM and say
1079
+ // exactly that. Deliberately well above the outer reconcile budget: a long remote
1080
+ // install is not a step in a sweep, so the sweep-relative ceiling that governs
1081
+ // GCLOUD_CALL_TIMEOUT_MS does not apply to it.
1082
+ const PROJECT_BOOTSTRAP_TIMEOUT_MS = 40 * 60 * 1000;
1083
+
1084
+ /** Whether a `spawnSync` result is a LOCAL timeout kill rather than a remote
1085
+ * failure. `spawnSync` reports a timeout as `status: null` plus an ETIMEDOUT
1086
+ * error, so a bare `status !== 0` check conflates the two — which is what made
1087
+ * a timed-out bootstrap look like a broken one. Pure. */
1088
+ function isSpawnTimeout(result) {
1089
+ if (!result) return false;
1090
+ if (result.status !== null && result.status !== undefined) return false;
1091
+ const code = result.error && result.error.code;
1092
+ return code === "ETIMEDOUT" || result.signal === "SIGTERM";
1093
+ }
1094
+
890
1095
  function runGcloud(args, opts = {}) {
891
1096
  return spawnSync("gcloud", args, {
892
1097
  stdio: opts.stdio || "inherit",
893
1098
  input: opts.input,
894
1099
  encoding: opts.encoding || "utf8",
1100
+ timeout: opts.timeout != null ? opts.timeout : GCLOUD_CALL_TIMEOUT_MS,
895
1101
  });
896
1102
  }
897
1103
 
@@ -1494,7 +1700,305 @@ function bootstrapLogTail(cfg, lines = 40) {
1494
1700
  return String(r.stdout || "").trim() || "(log unavailable)";
1495
1701
  }
1496
1702
 
1497
- async function ensureProjectBootstrapped(cfg) {
1703
+ // ─── Detached PROJECT bootstrap ──────────────────────────────────────────────
1704
+ //
1705
+ // The VM bootstrap above solved a problem the PROJECT bootstrap still had: read
1706
+ // success and failure from VM-SIDE STATE, never from the SSH exit code, so a
1707
+ // dropped follow-session over a healthy VM is not misreported as "failed on the
1708
+ // VM". The project bootstrap ran instead as ONE blocking ssh-script call, so a
1709
+ // local budget elapsing (or an SSH pipe breaking) was indistinguishable from a
1710
+ // genuine remote failure and the caller's abort path DELETED A HEALTHY VM.
1711
+ // Raising the budget alone just moves the cliff; these helpers remove it by
1712
+ // mirroring the VM-bootstrap contract — detached launch, re-attaching follow
1713
+ // loop, VM-side outcome probe.
1714
+ //
1715
+ // All VM-side paths are keyed by repo name so two projects on one VM never
1716
+ // collide, and they sit beside the `.done` marker projectBootstrapDoneMarker
1717
+ // already defines. Pure string builders (like their VM-bootstrap counterparts)
1718
+ // so the whole grammar is unit-testable without a live VM.
1719
+ function projectBootstrapScriptPath(repoName) {
1720
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.sh`;
1721
+ }
1722
+ function projectBootstrapRunnerPath(repoName) {
1723
+ return `$HOME/.codeyam-project-bootstrap-${repoName}-run.sh`;
1724
+ }
1725
+ function projectBootstrapLogPath(repoName) {
1726
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.log`;
1727
+ }
1728
+ function projectBootstrapPidPath(repoName) {
1729
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.pid`;
1730
+ }
1731
+ // The runner's recorded EXIT STATUS. Deliberately `.status`, NOT `.done`:
1732
+ // `.done` is already taken by scripts/gcp-project-bootstrap.sh's own terminal
1733
+ // SUCCESS marker (a timestamp, written only after `set -euo pipefail` carried
1734
+ // every step through — see projectBootstrapDoneMarker). Reusing `.done` for an
1735
+ // exit code would overwrite the success signal that both improve40's false-skip
1736
+ // fix and the follow loop below depend on. Two files, two distinct meanings:
1737
+ // `.done` = "the script reached its last line", `.status` = "the runner exited,
1738
+ // here is its code".
1739
+ function projectBootstrapStatusPath(repoName) {
1740
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.status`;
1741
+ }
1742
+ const PROJECT_BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_PROJECT_BOOTSTRAP_LAUNCHED";
1743
+ const PROJECT_BOOTSTRAP_STATUS_MARKER = "CYPROJ";
1744
+ // One follow session caps at 15 min; the overall wait caps at
1745
+ // PROJECT_BOOTSTRAP_TIMEOUT_MS across re-attaches before we bail loudly with
1746
+ // the VM-side log location. Unlike the VM-bootstrap follow loop — whose follow
1747
+ // calls inherit the 120 s probe budget and so re-attach every two minutes over
1748
+ // a ~14-min build — the project follow call below is given an explicit local
1749
+ // budget just above this remote deadline, so a long install streams in one
1750
+ // session instead of churning re-attaches.
1751
+ const PROJECT_BOOTSTRAP_FOLLOW_SECS = 900;
1752
+ const PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS = (PROJECT_BOOTSTRAP_FOLLOW_SECS + 60) * 1000;
1753
+
1754
+ // The tiny VM-side wrapper the detached launch execs: run the real bootstrap,
1755
+ // redirect all output to the per-repo log, and record the exit status in the
1756
+ // `.status` sentinel — the single source of truth for "did the runner exit, and
1757
+ // with what?" once the SSH follow-session is gone. Contains NO secrets (tokens
1758
+ // arrive via the launch env prefix, never written to disk).
1759
+ function projectBootstrapRunnerScript(repoName) {
1760
+ return [
1761
+ "#!/usr/bin/env bash",
1762
+ "# Detached project-bootstrap runner (written by npm/cloud.js",
1763
+ "# ensureProjectBootstrapped). Survives SSH disconnect: the laptop only",
1764
+ "# *follows* the log below; this process keeps running under its own session",
1765
+ "# if the follow drops. Records its own PID so the status probe can check",
1766
+ "# liveness via `kill -0` (a `pgrep` on the script path would also match the",
1767
+ "# probe command itself).",
1768
+ `echo "$$" > "${projectBootstrapPidPath(repoName)}"`,
1769
+ `bash "${projectBootstrapScriptPath(repoName)}" "$@" > "${projectBootstrapLogPath(repoName)}" 2>&1`,
1770
+ "status=$?",
1771
+ `echo "$status" > "${projectBootstrapStatusPath(repoName)}"`,
1772
+ `rm -f "${projectBootstrapPidPath(repoName)}"`,
1773
+ "",
1774
+ ].join("\n");
1775
+ }
1776
+
1777
+ // Build the remote command that (1) materializes the bootstrap script + runner
1778
+ // on the VM, (2) clears the stale sentinels, (3) launches the runner DETACHED
1779
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
1780
+ // session, and (4) prints a launched marker. The detach is the durable fix: the
1781
+ // install no longer dies when the laptop's SSH pipe breaks, and the VM is no
1782
+ // longer torn down because it did.
1783
+ //
1784
+ // BOTH sentinels are cleared, `.status` and `.done`. Clearing `.done` is what
1785
+ // makes the outcome probe honest: after a launch, a present `.done` can only
1786
+ // mean THIS run's script reached its terminal line, never a leftover from a
1787
+ // previous half-wired attempt.
1788
+ function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
1789
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1790
+ const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
1791
+ const argsString = scriptArgs.map(shQuote).join(" ");
1792
+ const envPrefix = envAssignmentPrefix(envVars);
1793
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
1794
+ // aborts before launch; then the runner is detached. The env prefix is on the
1795
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
1796
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1797
+ // launch in the background while still confirming it fired.
1798
+ const writes = [
1799
+ `echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
1800
+ `echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
1801
+ `rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
1802
+ ].join(" && ");
1803
+ const launch =
1804
+ `${envPrefix}nohup setsid bash ${projectBootstrapRunnerPath(repoName)} ${argsString}`.trimEnd() +
1805
+ ` </dev/null >/dev/null 2>&1 &`;
1806
+ return `${writes}; ${launch} echo ${PROJECT_BOOTSTRAP_LAUNCHED_MARKER}`;
1807
+ }
1808
+
1809
+ // Build the remote command that FOLLOWS the detached install: tail the log live
1810
+ // for the operator, polling for the `.status` sentinel, and exit when the runner
1811
+ // finishes OR a per-session deadline elapses. If the SSH session drops
1812
+ // mid-follow this command dies but the detached install keeps going — the caller
1813
+ // then re-attaches.
1814
+ function projectBootstrapFollowCommand(repoName, deadlineSecs = PROJECT_BOOTSTRAP_FOLLOW_SECS) {
1815
+ const log = projectBootstrapLogPath(repoName);
1816
+ const status = projectBootstrapStatusPath(repoName);
1817
+ return [
1818
+ `touch ${log}`,
1819
+ `tail -n +1 -F ${log} 2>/dev/null & TP=$!`,
1820
+ `waited=0`,
1821
+ `while [ ! -f ${status} ]; do ` +
1822
+ `sleep 3; waited=$((waited+3)); ` +
1823
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1824
+ `done`,
1825
+ `sleep 1`,
1826
+ `kill "$TP" 2>/dev/null`,
1827
+ ].join("; ");
1828
+ }
1829
+
1830
+ // Build the remote command that reports terminal VM state in one parseable
1831
+ // line: whether the script's terminal `.done` marker exists, the runner's
1832
+ // recorded exit status (or `none`), and whether the runner is still alive.
1833
+ // Cheap — runs over ssh-probe.
1834
+ function projectBootstrapStatusCommand(repoName) {
1835
+ const pid = projectBootstrapPidPath(repoName);
1836
+ const status = projectBootstrapStatusPath(repoName);
1837
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1838
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1839
+ // match this very probe and always report running. A stale PID file from a
1840
+ // killed runner reads as not-running because `kill -0` fails.
1841
+ return [
1842
+ `done=no; [ -f ${projectBootstrapDoneMarker(repoName)} ] && done=yes`,
1843
+ `st=none; [ -f ${status} ] && st=$(cat ${status} 2>/dev/null)`,
1844
+ `run=no; if [ -f ${pid} ]; then p=$(cat ${pid} 2>/dev/null); ` +
1845
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1846
+ `echo "${PROJECT_BOOTSTRAP_STATUS_MARKER} done=$done status=$st running=$run"`,
1847
+ ].join("; ");
1848
+ }
1849
+
1850
+ // Parse the single status line emitted by projectBootstrapStatusCommand.
1851
+ // Returns `{ done, status, running }` or null when no status line is present
1852
+ // (e.g. the probe ssh itself failed and produced no stdout).
1853
+ function parseProjectBootstrapStatus(stdout) {
1854
+ const m = String(stdout || "").match(
1855
+ new RegExp(`${PROJECT_BOOTSTRAP_STATUS_MARKER} done=(\\S+) status=(\\S+) running=(\\S+)`),
1856
+ );
1857
+ if (!m) return null;
1858
+ return { done: m[1], status: m[2], running: m[3] };
1859
+ }
1860
+
1861
+ // Decide the project-bootstrap outcome from VM-side state alone — never the SSH
1862
+ // exit code. The `.done` marker is the real artifact (the script writes it only
1863
+ // after `set -euo pipefail` carried every step to success), so its presence is
1864
+ // success regardless of how the follow session ended — the same reasoning that
1865
+ // makes the docker image authoritative for the VM bootstrap. A live runner means
1866
+ // "re-attach". A recorded exit status without the marker means the install ran
1867
+ // and failed. Nothing at all means it was never started (or the probe could not
1868
+ // see it).
1869
+ function decideProjectBootstrapOutcome(status) {
1870
+ if (!status) return "absent";
1871
+ const { done, status: exitStatus, running } = status;
1872
+ if (done === "yes") return "success";
1873
+ if (running === "yes") return "running";
1874
+ if (exitStatus && exitStatus !== "none") return "failed";
1875
+ return "absent";
1876
+ }
1877
+
1878
+ // Reduce one follow-loop iteration to an action: "success" | "failed" |
1879
+ // "timeout" | "reattach". Extracted as a pure function because its PRECEDENCE is
1880
+ // load-bearing — it decides whether an operator is told a VM is broken:
1881
+ //
1882
+ // • success and failed outrank the deadline. Otherwise the final poll of a
1883
+ // slow-but-successful bootstrap reports a timeout, and the operator is sent
1884
+ // to reconcile a VM that actually finished.
1885
+ // • a still-running install past the deadline is a TIMEOUT, never a failure.
1886
+ // That is the VM-8 case: healthy VM, still installing, and saying so is what
1887
+ // gets the add classified retryable instead of destroyed.
1888
+ // • an unseen runner ("absent") before the deadline re-attaches rather than
1889
+ // failing — "I could not see it" is not "it broke" — but is still bounded by
1890
+ // the deadline so the loop cannot spin forever.
1891
+ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
1892
+ if (outcome === "success") return "success";
1893
+ if (outcome === "failed") return "failed";
1894
+ if (now >= deadline) return "timeout";
1895
+ return "reattach";
1896
+ }
1897
+
1898
+ // Probe the VM for the detached project bootstrap's terminal state and reduce it
1899
+ // to a single outcome string ("success" | "failed" | "running" | "absent").
1900
+ // Used both for the idempotent re-attach decision and after each follow session.
1901
+ function probeProjectBootstrapOutcome(cfg, repoName) {
1902
+ const r = runGcloud(
1903
+ buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
1904
+ { stdio: ["ignore", "pipe", "pipe"] },
1905
+ );
1906
+ return decideProjectBootstrapOutcome(parseProjectBootstrapStatus(r.stdout));
1907
+ }
1908
+
1909
+ /** Assemble the env forwarded into the project-bootstrap shell on the VM.
1910
+ * Extracted from ensureProjectBootstrapped so its CONDITIONAL keys are testable
1911
+ * without a VM — previously you could not assert "FLEET_DOMAIN is omitted for a
1912
+ * non-fleet project" or "a re-provision without --admin stays non-admin"
1913
+ * without invoking the whole gcloud-touching bootstrap.
1914
+ *
1915
+ * Keys, and why each is here:
1916
+ * GITHUB_TOKEN — clone a private client repo.
1917
+ * CY_GIT_USER_{NAME,EMAIL} — rendered into the compose file, then written
1918
+ * to `git config --global` by the entrypoint so
1919
+ * plan_claim_refs::create_claim's commit-tree
1920
+ * call succeeds. Empty is legitimate (a CI
1921
+ * runner with no author); the entrypoint warns
1922
+ * and proceeds, so the keys stay present.
1923
+ * FLEET_DOMAIN — ONLY for a numbered fleet VM with a domain
1924
+ * configured, so the compose render publishes
1925
+ * on 0.0.0.0 for the LB NEG. Omitted entirely
1926
+ * otherwise, preserving the loopback-only
1927
+ * publish a plain client project expects.
1928
+ * <forwarded launch-form keys> — DATABASE_URL et al, whitelisted by the
1929
+ * dashboard via CY_FORWARD_ENV_KEYS, so the
1930
+ * bootstrap-time `npm run setup` sees them on
1931
+ * the first try. Empty on plain `cloud:up`.
1932
+ * CODEYAM_ADMIN — the Home-tab Setup door. Defaults to the
1933
+ * strict non-admin "".
1934
+ *
1935
+ * Later spreads win, matching the original inline assembly exactly. `deps` lets
1936
+ * a test supply the token and git identity instead of reading the real
1937
+ * environment and shelling out to git. */
1938
+ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIdentity } = {}) {
1939
+ const git = gitIdentity || resolveLaptopGitIdentity();
1940
+ const out = {
1941
+ GITHUB_TOKEN: githubToken !== undefined ? githubToken : resolveGithubToken(),
1942
+ CY_GIT_USER_NAME: git.name,
1943
+ CY_GIT_USER_EMAIL: git.email,
1944
+ };
1945
+ const ingressPlan = ingressWiringPlan(cfg, env);
1946
+ if (ingressPlan.wire) out.FLEET_DOMAIN = ingressPlan.domain;
1947
+ Object.assign(out, resolveSetupForwardEnv(env));
1948
+ Object.assign(out, buildProvisionEnv(env, cfg));
1949
+ return out;
1950
+ }
1951
+
1952
+ // Fetch the tail of the VM-side project-bootstrap log for an actionable failure
1953
+ // message — the operator sees WHY the install failed, not a bare exit code.
1954
+ function projectBootstrapLogTail(cfg, repoName, lines = 40) {
1955
+ const r = runGcloud(
1956
+ buildGcloudArgs("ssh-probe", cfg, {
1957
+ command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
1958
+ }),
1959
+ { stdio: ["ignore", "pipe", "pipe"] },
1960
+ );
1961
+ return String(r.stdout || "").trim() || "(log unavailable)";
1962
+ }
1963
+
1964
+ /** Assemble the extra provision-time env forwarded into the bootstrap shell,
1965
+ * where it feeds the compose-render substitution. Currently just the admin
1966
+ * toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
1967
+ * editor-server process env (handlers.rs::admin_enabled, strict literal "1").
1968
+ * The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
1969
+ * (which lands as cfg.admin); anything else forwards "" so a normal launch —
1970
+ * and a re-provision without the flag — is strict non-admin. Pure over its
1971
+ * inputs so cloud.test.js can pin it without touching gcloud. */
1972
+ function buildProvisionEnv(env = process.env, flags = {}) {
1973
+ const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
1974
+ return { CODEYAM_ADMIN: admin ? "1" : "" };
1975
+ }
1976
+
1977
+ /** Build the one-shot SSH shell command that reconciles a running VM's admin
1978
+ * mode without a full re-provision: rewrite the rendered compose's
1979
+ * CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
1980
+ * VM whose compose predates the placeholder — then recreate the editor service
1981
+ * so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
1982
+ * normalizes), so inlining it into sed is safe. Pure/string-only so
1983
+ * cloud.test.js can assert its shape without SSHing. */
1984
+ function buildReconcileAdminCommand(repoName, value) {
1985
+ const f = "docker-compose.cloud.yml";
1986
+ const entry = ` - CODEYAM_ADMIN=${value}`;
1987
+ // Capture only the leading indentation (\1) and re-emit the literal key, so a
1988
+ // value that starts with a digit (e.g. "1") never lands right after the
1989
+ // backreference — `\11` would be read by sed as group 11, not `\1` + "1".
1990
+ return (
1991
+ `cd ~/projects/${repoName} && ` +
1992
+ `if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
1993
+ `sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
1994
+ `else ` +
1995
+ `sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
1996
+ `fi && ` +
1997
+ `docker compose -f ${f} up -d editor`
1998
+ );
1999
+ }
2000
+
2001
+ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
1498
2002
  const repoName = resolveProject(cfg).name;
1499
2003
  console.error(`Checking project bootstrap state for ${repoName}...`);
1500
2004
  // Gate on the TERMINAL completion marker the script writes as its very last
@@ -1524,43 +2028,154 @@ async function ensureProjectBootstrapped(cfg) {
1524
2028
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
1525
2029
  const scriptBody = readStreamedScript(scriptPath, cfg);
1526
2030
  const scriptArgs = clientProjectBootstrapArgs(cfg);
1527
- // Forward the laptop's git author identity so the rendered compose file
1528
- // carries CY_GIT_USER_{NAME,EMAIL} → the entrypoint writes them to
1529
- // `git config --global` → plan_claim_refs::create_claim's commit-tree call
1530
- // succeeds. Empty strings are fine (laptop has no identity, e.g. a CI
1531
- // runner); the entrypoint warns and proceeds.
1532
- const laptopGit = resolveLaptopGitIdentity();
1533
- // Forward the operator's launch-form env keys (DATABASE_URL, etc.) into the
1534
- // bootstrap shell so the bootstrap-time `npm run setup` sees them on the
1535
- // first try — without this, Prisma `db:push`/`db:seed` fail for lack of
1536
- // env and the bootstrap's fail-soft branch leaves the app schema-less until
1537
- // the post-add `reapplyVmEnv` re-fires setup. See
1538
- // `improve37-launch-form-env-injected-into-cloud-up-bootstrap-not-just-applied-after`.
1539
- // Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
1540
- // standalone `cloud:up` path, so that flow is unchanged.
1541
- const forwardEnv = resolveSetupForwardEnv();
1542
- // Forward the fleet domain so the bootstrap's compose render publishes the
1543
- // editor/app ports on 0.0.0.0 (the LB NEG reaches them on the VM's internal
1544
- // IP) instead of loopback-only. Only when a fleet domain is configured AND
1545
- // this is a numbered fleet VM — a non-fleet client project keeps the
1546
- // loopback-only publish (legacy parity). Absent ⇒ key omitted entirely.
1547
- const ingressEnv = {};
1548
- const ingressPlan = ingressWiringPlan(cfg);
1549
- if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
1550
- const command = sshScriptCommand(scriptBody, scriptArgs, {
1551
- GITHUB_TOKEN: resolveGithubToken(),
1552
- CY_GIT_USER_NAME: laptopGit.name,
1553
- CY_GIT_USER_EMAIL: laptopGit.email,
1554
- ...ingressEnv,
1555
- ...forwardEnv,
1556
- });
1557
- const result = runGcloud(
1558
- buildGcloudArgs("ssh-script", cfg, { command }),
1559
- { stdio: "inherit" },
2031
+ // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
2032
+ // toggle — see projectBootstrapEnv for what each key is for and when it is
2033
+ // omitted. Extracted so those conditionals are testable without a VM.
2034
+ const bootstrapEnv = projectBootstrapEnv(cfg);
2035
+
2036
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may have
2037
+ // left a detached install still running. Converge on it instead of starting a
2038
+ // second `npm install` on top of the first.
2039
+ //
2040
+ // NOTE the deliberate asymmetry with ensureBootstrapped, which DOES return
2041
+ // early on an outcome of "success". There, the done-probe and the outcome probe
2042
+ // read the same artifact (the docker image), so they always agree. Here the
2043
+ // done-probe above is a strictly STRONGER three-way AND (`.done` marker +
2044
+ // ~/projects/<repo>/.codeyam + docker-compose.cloud.yml) than this probe's
2045
+ // `.done`-alone success, and it has already said no. Honoring a bare `.done`
2046
+ // here would re-open the improve40 VM-7 false-skip from the other side, so only
2047
+ // "running" short-circuits the launch.
2048
+ const initial = probeProjectBootstrapOutcome(cfg, repoName);
2049
+ if (initial === "running") {
2050
+ console.error(" ↺ a detached project bootstrap is already in progress — re-attaching to its log...");
2051
+ } else {
2052
+ // "absent" (never started), "failed" (a prior run died), or "success" (a bare
2053
+ // marker the stricter done-probe rejected) → (re)launch a fresh detached
2054
+ // install that will survive an SSH disconnect. Every step of
2055
+ // gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
2056
+ console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
2057
+ const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
2058
+ const launched = await runGcloudSshWithRetry(
2059
+ "ssh-script",
2060
+ cfg,
2061
+ { command: launch },
2062
+ { stdio: ["ignore", "pipe", "pipe"], label: "project bootstrap launch", sleepFn },
2063
+ );
2064
+ if (isSpawnTimeout(launched)) {
2065
+ // The LOCAL budget elapsed and we SIGTERMd the gcloud child. Say so:
2066
+ // the launch is a short write-and-detach, so this is an SSH-reachability
2067
+ // problem, NOT a broken bootstrap script — and the VM may be perfectly
2068
+ // healthy. Conflating the two is what destroyed VM-8.
2069
+ throw new Error(
2070
+ `launching the detached project bootstrap exceeded the local gcloud call budget ` +
2071
+ `and was SIGTERMd locally — the VM may be healthy and simply unreachable over SSH ` +
2072
+ `right now. Re-run to re-attach (each bootstrap step is skip-on-exists).`,
2073
+ );
2074
+ }
2075
+ if (launched.status !== 0) {
2076
+ throw new Error(
2077
+ `failed to launch the detached project bootstrap on the VM (gcloud ssh exit ${launched.status}).\n` +
2078
+ formatGcloudFailure(launched),
2079
+ );
2080
+ }
2081
+ }
2082
+
2083
+ // Follow the live log, re-attaching across any follow-session drops, until the
2084
+ // VM reports a terminal state or the overall deadline elapses. Success and
2085
+ // failure are read from VM state (the `.done` marker + the `.status` sentinel),
2086
+ // never the SSH exit code — so "my laptop's SSH dropped" or "a heavy npm
2087
+ // install outran a local budget" no longer masquerades as a broken bootstrap
2088
+ // and no longer gets a healthy VM deleted.
2089
+ const deadline = Date.now() + PROJECT_BOOTSTRAP_TIMEOUT_MS;
2090
+ for (;;) {
2091
+ runGcloud(
2092
+ buildGcloudArgs("ssh-script", cfg, { command: projectBootstrapFollowCommand(repoName) }),
2093
+ { stdio: "inherit", timeout: PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS },
2094
+ );
2095
+ const outcome = probeProjectBootstrapOutcome(cfg, repoName);
2096
+ const action = decideProjectBootstrapFollowAction({
2097
+ outcome,
2098
+ now: Date.now(),
2099
+ deadline,
2100
+ });
2101
+ if (action === "success") {
2102
+ console.error(` ✓ ${repoName} bootstrap complete`);
2103
+ return;
2104
+ }
2105
+ if (action === "failed") {
2106
+ const tail = projectBootstrapLogTail(cfg, repoName);
2107
+ throw new Error(
2108
+ "scripts/gcp-project-bootstrap.sh failed on the VM. " +
2109
+ `Last lines of ${projectBootstrapLogPath(repoName)}:\n${tail}`,
2110
+ );
2111
+ }
2112
+ if (action === "timeout") {
2113
+ // The install is STILL RUNNING on a healthy VM and we simply stopped
2114
+ // waiting. This message is the signature npm/fleet-add-failure.js keys on
2115
+ // to classify the add as `project-bootstrap-timeout` (retryable) instead of
2116
+ // the non-retryable generic cloud-up-failed that tore down VM-8.
2117
+ throw new Error(
2118
+ `project bootstrap exceeded the local ${Math.round(PROJECT_BOOTSTRAP_TIMEOUT_MS / 60000)}-minute budget ` +
2119
+ `— the VM and its detached bootstrap may be healthy and still running. ` +
2120
+ `Re-run to re-attach (each bootstrap step is skip-on-exists), or inspect ` +
2121
+ `${projectBootstrapLogPath(repoName)} on the VM.`,
2122
+ );
2123
+ }
2124
+ // action === "reattach": the follow session ended but the install is alive
2125
+ // (or the probe could not see it yet). Re-attach to its log.
2126
+ console.error(" ↺ follow session ended but the project bootstrap is still running — re-attaching...");
2127
+ }
2128
+ }
2129
+
2130
+ /** Run the compose-ps liveness probe over SSH and report BOTH the editor
2131
+ * service's state and whether the probe itself could be trusted.
2132
+ *
2133
+ * The `|| true` keeps the remote pipeline's exit status 0 whenever SSH itself
2134
+ * worked, so a non-zero `status` means the transport failed (auth/SSH/IAP) —
2135
+ * not that the container is down. Callers that must fail safe (the abort
2136
+ * teardown) need that distinction; `parseEditorComposeState` alone collapses
2137
+ * "no container" and "could not ask" into the same `null`.
2138
+ *
2139
+ * Returns `{ state, inconclusive }` where `state` is the compose `State` string
2140
+ * (`"running"`, `"exited"`, …) or null when the service is absent, and
2141
+ * `inconclusive` is true only for a transport failure. */
2142
+ function probeEditorComposeState(cfg) {
2143
+ const repoName = resolveProject(cfg).name;
2144
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2145
+ const probe = runGcloud(
2146
+ buildGcloudArgs("ssh-probe", cfg, {
2147
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2148
+ }),
2149
+ { stdio: ["ignore", "pipe", "ignore"] },
1560
2150
  );
1561
- if (result.status !== 0) {
1562
- throw new Error("scripts/gcp-project-bootstrap.sh failed on the VM");
2151
+ if (probe.status !== 0) {
2152
+ return { state: null, inconclusive: true };
2153
+ }
2154
+ return { state: parseEditorComposeState(probe.stdout), inconclusive: false };
2155
+ }
2156
+
2157
+ /** Poll {@link probeEditorComposeState} until the editor service reads
2158
+ * `running`, returning true as soon as it does. Used to close the TOCTOU window
2159
+ * after a container-name conflict: the winning run may still be inside
2160
+ * `Creating`/`Created` when the conflict surfaces, so a single immediate probe
2161
+ * can under-read and report a healthy container as absent.
2162
+ *
2163
+ * Deliberately short (default ~30s): this is not the multi-minute first-boot
2164
+ * budget `waitForContainerRunning` grants, it is the tail of a start that
2165
+ * another process has already begun. `sleepFn` and `probeFn` are injectable so
2166
+ * a unit test drives the whole loop with neither wall-clock delay nor a VM. */
2167
+ async function waitForEditorComposeRunning(cfg, { attempts = 6, delayMs = 5000, sleepFn, probeFn } = {}) {
2168
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
2169
+ const probe = probeFn || probeEditorComposeState;
2170
+ for (let i = 0; i < attempts; i++) {
2171
+ if (probe(cfg).state === "running") {
2172
+ return true;
2173
+ }
2174
+ if (i < attempts - 1) {
2175
+ await sleep(delayMs);
2176
+ }
1563
2177
  }
2178
+ return false;
1564
2179
  }
1565
2180
 
1566
2181
  async function ensureEditorContainerUp(cfg) {
@@ -1570,13 +2185,7 @@ async function ensureEditorContainerUp(cfg) {
1570
2185
  // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
1571
2186
  const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1572
2187
  console.error("Checking editor container state...");
1573
- const probe = runGcloud(
1574
- buildGcloudArgs("ssh-probe", cfg, {
1575
- command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
1576
- }),
1577
- { stdio: ["ignore", "pipe", "ignore"] },
1578
- );
1579
- if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
2188
+ if (probeEditorComposeState(cfg).state === "running") {
1580
2189
  console.error(" ✓ editor container is already running");
1581
2190
  // containerStarted: the container is confirmed up, so the CLI install runs
1582
2191
  // its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
@@ -1584,14 +2193,47 @@ async function ensureEditorContainerUp(cfg) {
1584
2193
  return;
1585
2194
  }
1586
2195
  console.error(" starting editor container (docker compose up -d)...");
2196
+ // CAPTURE rather than `stdio: "inherit"`: the daemon writes its container-name
2197
+ // conflict to stderr, and with "inherit" that text went straight to our parent
2198
+ // and was never observable here — so the conflict could not be told apart from
2199
+ // a real failure (the 2026-08-07 VM-4/VM-5 delete). spawnSync cannot both
2200
+ // stream and capture, so the operator's view is preserved by echoing the
2201
+ // captured streams immediately below instead of live. Do NOT revert this to
2202
+ // "inherit"; classification depends on it.
1587
2203
  const up = runGcloud(
1588
2204
  buildGcloudArgs("ssh-probe", cfg, {
1589
2205
  command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
1590
2206
  }),
1591
- { stdio: "inherit" },
2207
+ { stdio: ["ignore", "pipe", "pipe"] },
1592
2208
  );
2209
+ if (up.stdout) process.stderr.write(up.stdout);
2210
+ if (up.stderr) process.stderr.write(up.stderr);
1593
2211
  if (up.status !== 0) {
1594
- throw new Error("docker compose up -d failed on the VM");
2212
+ const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
2213
+ if (verdict !== "name-conflict") {
2214
+ throw new Error("docker compose up -d failed on the VM");
2215
+ }
2216
+ // A name conflict means a concurrent run already created this container —
2217
+ // the goal we were about to pursue is being reached by someone else. Confirm
2218
+ // that positively before converging: a conflict whose container never
2219
+ // reaches `running` (e.g. a stale STOPPED container squatting the name) is a
2220
+ // genuine problem and must still fail loudly, or convergence becomes a
2221
+ // catch-all that papers over real breakage.
2222
+ console.error(
2223
+ " a concurrent run already created the editor container — confirming it reaches running...",
2224
+ );
2225
+ if (!(await waitForEditorComposeRunning(cfg))) {
2226
+ throw new Error(
2227
+ "docker compose up -d hit a container-name conflict on the VM, but the conflicting " +
2228
+ "container never reached running (a stale stopped container may be holding the name). " +
2229
+ `Inspect with: gcloud compute ssh ${cfg.instanceName} --command 'cd ~/projects/${repoName} && ${composeCmd} ps -a'`,
2230
+ );
2231
+ }
2232
+ console.error(" ✓ editor container is running (started by a concurrent run)");
2233
+ // containerStarted: the container is confirmed up — same post-start contract
2234
+ // as the already-running short-circuit above.
2235
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
2236
+ return;
1595
2237
  }
1596
2238
  // containerStarted: `up -d` returned 0, so the CLI install is legitimately a
1597
2239
  // post-start step. A brief wait-for-running here (slow boot settling) is
@@ -1607,18 +2249,10 @@ async function ensureEditorContainerUp(cfg) {
1607
2249
  // success. This throws (non-zero exit) when the editor service isn't
1608
2250
  // `running`, converting a silent partial provision into a loud, actionable
1609
2251
  // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
1610
- // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
1611
- // parsed compose state, not the SSH/cd exit code.
2252
+ // on. An inconclusive probe is not "running" here — a terminal gate must have
2253
+ // positive evidence, and the caller's job is to fail loudly without it.
1612
2254
  async function assertEditorContainerRunning(cfg) {
1613
- const repoName = resolveProject(cfg).name;
1614
- const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1615
- const probe = runGcloud(
1616
- buildGcloudArgs("ssh-probe", cfg, {
1617
- command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
1618
- }),
1619
- { stdio: ["ignore", "pipe", "ignore"] },
1620
- );
1621
- if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
2255
+ if (probeEditorComposeState(cfg).state !== "running") {
1622
2256
  throw new Error(
1623
2257
  `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
1624
2258
  `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
@@ -1761,49 +2395,113 @@ function resolveGhcrPushToken() {
1761
2395
  return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1762
2396
  }
1763
2397
 
2398
+ // Registry manifest media types we accept when probing image presence over the
2399
+ // GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
2400
+ // multi-arch index answers 200 the same as a single-arch manifest.
2401
+ const MANIFEST_ACCEPT = [
2402
+ "application/vnd.docker.distribution.manifest.v2+json",
2403
+ "application/vnd.docker.distribution.manifest.list.v2+json",
2404
+ "application/vnd.oci.image.manifest.v1+json",
2405
+ "application/vnd.oci.image.index.v1+json",
2406
+ ].join(", ");
2407
+ const GHCR_PROBE_TIMEOUT_MS = 15000;
2408
+
2409
+ /** Default HTTPS client for the registry probe: a GET that resolves to
2410
+ * `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
2411
+ * to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
2412
+ * `httpImpl` option so probeUsableEditorImage is unit-testable with no network
2413
+ * (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
2414
+ * injectable so the response / error / timeout mapping is itself unit-testable
2415
+ * without a real socket. */
2416
+ function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
2417
+ return new Promise((resolve) => {
2418
+ const req = requestImpl(
2419
+ { hostname, path: reqPath, method: "GET", headers },
2420
+ (res) => {
2421
+ res.resume(); // drain the body so the socket is freed
2422
+ res.on("end", () => resolve({ statusCode: res.statusCode }));
2423
+ },
2424
+ );
2425
+ req.on("error", () => resolve({ statusCode: 0 }));
2426
+ req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
2427
+ req.end();
2428
+ });
2429
+ }
2430
+
2431
+ /** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
2432
+ * API — no docker daemon, so a docker-less operator host can still determine
2433
+ * presence. Returns true (200 present), false (404 absent), or null (auth /
2434
+ * network / malformed-ref → can't tell absent from forbidden). */
2435
+ async function probeManifestRef(fullRef, bearer, httpGet) {
2436
+ const colon = fullRef.lastIndexOf(":");
2437
+ if (colon < 0) return null;
2438
+ const ref = fullRef.slice(0, colon); // host/repo
2439
+ const tag = fullRef.slice(colon + 1); // slug-latest
2440
+ const slash = ref.indexOf("/");
2441
+ if (slash < 0) return null;
2442
+ const hostname = ref.slice(0, slash); // ghcr.io
2443
+ const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
2444
+ let res;
2445
+ try {
2446
+ res = await httpGet({
2447
+ hostname,
2448
+ path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
2449
+ headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
2450
+ });
2451
+ } catch (_e) {
2452
+ return null;
2453
+ }
2454
+ const code = res && res.statusCode;
2455
+ if (code === 200) return true;
2456
+ if (code === 404) return false;
2457
+ return null; // 401 / 403 / 5xx / 0 (network) → undetermined
2458
+ }
2459
+
1764
2460
  /** Read-only probe: does a usable prebuilt editor image already exist on the
1765
2461
  * registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
1766
2462
  * add to decide whether to pace one VM ahead of the rest (see
1767
- * npm/fleet-prewarm.js::decideBatchPrewarm). A `docker manifest inspect` is a
1768
- * HEAD-equivalent — it reads the manifest without pulling the (multi-GB) image.
2463
+ * npm/fleet-prewarm.js::decideBatchPrewarm).
2464
+ *
2465
+ * Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
2466
+ * HEAD-equivalent that reads the manifest without pulling the multi-GB image)
2467
+ * instead of `docker login` / `docker manifest inspect`. This is the root fix
2468
+ * for the docker-less GCE operator host: the old docker path errored on the
2469
+ * first `docker login` spawn (the control plane has no docker daemon) and so
2470
+ * returned `null` forever, which decideBatchPrewarm read as "fan out" — the
2471
+ * image was therefore never published and every cold add source-built. Over
2472
+ * HTTP the probe returns a DEFINITIVE verdict with no daemon.
1769
2473
  *
1770
2474
  * Returns:
1771
2475
  * true — at least one of the usable tags exists (a cold add can pull; no pacer)
1772
2476
  * false — every tag was checked and none exists (known-absent; pacing pays)
1773
- * null — undetermined: no read token, docker missing, or a probe error that
1774
- * can't be distinguished from "absent". The caller treats null as
1775
- * "fan out" (today's behavior, never worse) rather than risk a
1776
- * 10-min serialization that might buy nothing.
2477
+ * null — undetermined: no read token, or an auth/network error that can't be
2478
+ * distinguished from "absent".
1777
2479
  *
1778
- * The private packages require auth, so we `docker login` with the read token
1779
- * first; without a token we can't tell absent from forbidden, so → null.
1780
- * `spawnImpl` is injectable for unit tests. */
1781
- function probeUsableEditorImage({ branch, registry, token, spawnImpl } = {}) {
2480
+ * GHCR's private packages require auth; without a read token we can't tell
2481
+ * absent from forbidden, so → null. The read token is sent as the registry
2482
+ * bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
2483
+ * exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
2484
+ * `spawnImpl` option is still accepted (and ignored) for backward-compat — the
2485
+ * HTTP happy path needs no spawn. */
2486
+ async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
1782
2487
  const fp = require("./fleet-prewarm");
1783
- const spawn = spawnImpl || spawnSync;
1784
2488
  const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
1785
2489
  const readToken = token || resolveGhcrToken();
1786
2490
  if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
1787
- const host = String(reg).split("/")[0];
1788
- const login = spawn(
1789
- "docker",
1790
- ["login", host, "-u", process.env.CODEYAM_GHCR_USER || "codeyam", "--password-stdin"],
1791
- { input: readToken, encoding: "utf8" },
1792
- );
1793
- // spawn error (docker missing) or a non-zero login → undetermined.
1794
- if (!login || login.error || login.status !== 0) return null;
2491
+ const httpGet = httpImpl || httpsManifestGet;
2492
+ // GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
2493
+ // round-trip needed.
2494
+ const bearer = Buffer.from(readToken).toString("base64");
1795
2495
  const tags = fp.usableImageTags({ registry: reg, branch });
1796
2496
  let allClean = true;
1797
- for (const tag of tags) {
1798
- const res = spawn("docker", ["manifest", "inspect", tag], { encoding: "utf8" });
1799
- if (!res || res.error) {
1800
- allClean = false; // probe itself failed to run — can't claim "absent"
1801
- continue;
1802
- }
1803
- if (res.status === 0) return true; // a usable image exists
1804
- }
1805
- // Every tag inspected cleanly and none existed → definitively absent. If any
1806
- // probe failed to run, we can't be sure → undetermined.
2497
+ for (const fullRef of tags) {
2498
+ const verdict = await probeManifestRef(fullRef, bearer, httpGet);
2499
+ if (verdict === true) return true; // a usable image exists
2500
+ if (verdict === null) allClean = false; // this tag's probe was inconclusive
2501
+ // verdict === false → this tag is absent; keep checking the rest
2502
+ }
2503
+ // Every tag probed cleanly and none existed → definitively absent. If any probe
2504
+ // was inconclusive we can't claim absence → undetermined.
1807
2505
  return allClean ? false : null;
1808
2506
  }
1809
2507
 
@@ -1840,6 +2538,15 @@ function classifyGitRemoteError(stderr) {
1840
2538
  ) {
1841
2539
  return "network";
1842
2540
  }
2541
+ // A greenfield project (`--new-project` scaffold) has no origin remote and an
2542
+ // unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
2543
+ // git repository". No token would fix this — there is simply nothing to push
2544
+ // yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
2545
+ // match narrow: `repository not found` is a genuine access failure against a
2546
+ // real remote and must NOT be swallowed here.
2547
+ if (lower.includes("does not appear to be a git repository")) {
2548
+ return "no-remote";
2549
+ }
1843
2550
  return "other";
1844
2551
  }
1845
2552
 
@@ -1877,6 +2584,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
1877
2584
  reason: "git binary failed to launch — not a credential gap, not re-seeding",
1878
2585
  };
1879
2586
  }
2587
+ if (category === "no-remote") {
2588
+ return {
2589
+ heal: false,
2590
+ category,
2591
+ reason:
2592
+ "no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
2593
+ };
2594
+ }
1880
2595
  return {
1881
2596
  heal: true,
1882
2597
  category,
@@ -1894,6 +2609,16 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
1894
2609
  };
1895
2610
  }
1896
2611
 
2612
+ /** The single GitHub credential line format shared by every seed path: the
2613
+ * host staging file, the durable on-disk `store` fallback, and the live
2614
+ * cache-daemon approve all expect `https://x-access-token:<token>@github.com`
2615
+ * (git's credential-URL form, matched by both the entrypoint parser and
2616
+ * git-credential-store). Centralized so the `x-access-token` shape has one
2617
+ * source of truth instead of being re-typed at each call site. */
2618
+ function githubCredentialLine(token) {
2619
+ return `https://x-access-token:${token}@github.com`;
2620
+ }
2621
+
1897
2622
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1898
2623
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1899
2624
  * without this, claude / codex / gemini inside the container can clone the
@@ -1919,21 +2644,28 @@ async function seedGithubCredentials(cfg, opts = {}) {
1919
2644
  console.error(
1920
2645
  `Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
1921
2646
  );
1922
- const credLine = `https://x-access-token:${token}@github.com`;
2647
+ const credLine = githubCredentialLine(token);
1923
2648
  // Quote the token through one shell layer (gcloud ssh --command); the
1924
2649
  // value is base64'd to keep it out of the shell history.
1925
2650
  const b64 = Buffer.from(`${credLine}\n`).toString("base64");
1926
- // Verify-before-move: decode into a chmod-600 temp, assert it is non-empty
1927
- // (`[ -s ]`), and only THEN move it over the live staging file. An empty or
2651
+ // Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
2652
+ // (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
1928
2653
  // unreadable decode must never clobber an existing good file — the entrypoint
1929
2654
  // reads this file on every boot, and a silent empty write is exactly how a
1930
2655
  // reprovision lands a VM that "could not read Username for github.com". The
1931
- // trailing `echo OK` runs only on the move, so an empty write can't mask
2656
+ // trailing `echo OK` runs only on success, so an empty write can't mask
1932
2657
  // success with a `&& echo` (the failure shape seen on the Claude creds sinks).
2658
+ //
2659
+ // Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
2660
+ // bind-mounted read-only into the running container, and `mv` mints a NEW
2661
+ // inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
2662
+ // never reached the running container until a full recreate. Truncating the
2663
+ // existing file in place keeps its inode, so the durable write is visible to
2664
+ // the container immediately. Then remove the temp.
1933
2665
  const remoteCmd =
1934
2666
  `f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
1935
2667
  `echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
1936
- `if [ -s "$t" ]; then mv "$t" "$f" && echo OK; ` +
2668
+ `if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
1937
2669
  `else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
1938
2670
  const result = runGcloud(
1939
2671
  buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
@@ -1944,26 +2676,76 @@ async function seedGithubCredentials(cfg, opts = {}) {
1944
2676
  }
1945
2677
  }
1946
2678
 
2679
+ /** Sentinel the remote read prints when it cannot find an `editor.json` under
2680
+ * any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
2681
+ * project" (a real miss worth warning about) from "the project is scaffolded
2682
+ * but declares no port" (legitimately a `VITE_PORT` default). Without the
2683
+ * distinction both collapse to `{}` and the miss is silent. */
2684
+ const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
2685
+
2686
+ /** Build the remote shell command that locates the project's `.codeyam` dir and
2687
+ * cats its two config files in one round-trip.
2688
+ *
2689
+ * Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
2690
+ * directory is not guaranteed to be named after the repo (a renamed repo, a
2691
+ * manually-cloned dir, a fleet slug), and when the guess missed, the old code
2692
+ * silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
2693
+ * path is still tried FIRST so a multi-project VM resolves deterministically;
2694
+ * the glob is the fallback. When nothing matches, print the sentinel so the
2695
+ * caller can warn instead of guessing. Pure string builder; no I/O. */
2696
+ function buildVmAppPortReadCommand(repoName) {
2697
+ return (
2698
+ `d=""; ` +
2699
+ `for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
2700
+ `if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
2701
+ `if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
2702
+ `cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
2703
+ `echo '<<<LOCAL>>>'; ` +
2704
+ `cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
2705
+ );
2706
+ }
2707
+
1947
2708
  /** Best-effort read of the VM's resolved app dev-server port so cloud:up's
1948
2709
  * laptop `-L` forward, readiness probe, and previewOrigin seed all target the
1949
2710
  * port the app actually serves on instead of a hardcoded 5173. Cats the VM's
1950
2711
  * editor.json and editor.local.json in one SSH round-trip and resolves via
1951
- * `resolveConfiguredAppPort`. Any failure (SSH error, missing/corrupt JSON)
1952
- * falls back to `VITE_PORT` — the same default `resolved_port()` lands on for
1953
- * a portless config, so a 5173/greenfield project sees no behavior change. */
2712
+ * `resolveConfiguredAppPort`.
2713
+ *
2714
+ * Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
2715
+ * wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
2716
+ * and if the preview ends up on a dedicated origin the viewer gets
2717
+ * "localhost refused to connect" with nothing pointing at the cause. So an SSH
2718
+ * failure or a VM with no scaffolded project warns on stderr naming that
2719
+ * symptom. A scaffolded project that simply declares no port is NOT a miss —
2720
+ * `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
2721
+ * on) and stays quiet. */
1954
2722
  async function readVmAppPort(cfg) {
1955
2723
  const repoName = resolveProject(cfg).name;
1956
- const base = `~/projects/${repoName}/.codeyam`;
1957
2724
  const read = runGcloud(
1958
2725
  buildGcloudArgs("ssh-probe", cfg, {
1959
- command:
1960
- `cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
1961
- `echo '<<<LOCAL>>>'; ` +
1962
- `cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
2726
+ command: buildVmAppPortReadCommand(repoName),
1963
2727
  }),
1964
2728
  { stdio: ["ignore", "pipe", "ignore"] },
1965
2729
  );
1966
- if (read.status !== 0 || typeof read.stdout !== "string") return VITE_PORT;
2730
+ if (read.status !== 0 || typeof read.stdout !== "string") {
2731
+ console.error(
2732
+ `[cloud] WARNING: could not read the VM's app port over SSH — ` +
2733
+ `falling back to ${VITE_PORT}. If the app does not serve on ` +
2734
+ `${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
2735
+ `Live Preview will show "localhost refused to connect".`,
2736
+ );
2737
+ return VITE_PORT;
2738
+ }
2739
+ if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
2740
+ console.error(
2741
+ `[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
2742
+ `VM (looked for '${repoName}', then any project dir) — falling back to ` +
2743
+ `${VITE_PORT}. If the project is scaffolded under a different name and ` +
2744
+ `serves on another port, the port-forward targets the wrong port and ` +
2745
+ `the Live Preview will show "localhost refused to connect".`,
2746
+ );
2747
+ return VITE_PORT;
2748
+ }
1967
2749
  const parse = (s) => {
1968
2750
  try {
1969
2751
  const v = JSON.parse(s);
@@ -2287,6 +3069,66 @@ async function seedAgentApiKey(cfg) {
2287
3069
  // vm--no-cli-install-before-container-started removed; do not re-add it.
2288
3070
  }
2289
3071
 
3072
+ /** Pure: deep-merge `patch` over the VM's current editor.local.json content and
3073
+ * return the base64 of the pretty-printed result, ready for
3074
+ * buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
3075
+ * remote file (may be undefined, empty, or corrupt) — anything that doesn't
3076
+ * parse to a plain object is treated as `{}`, because overwriting a corrupt
3077
+ * override file with a known-good one is safer than refusing to seed. Extracted
3078
+ * so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
3079
+ * parse+merge+encode is unit-tested without a live VM. */
3080
+ function mergeEditorLocalPatchB64(currentRaw, patch) {
3081
+ let current = {};
3082
+ try {
3083
+ const parsed = JSON.parse(currentRaw);
3084
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
3085
+ current = parsed;
3086
+ }
3087
+ } catch {
3088
+ // Unparseable / missing remote content → start from {}.
3089
+ }
3090
+ const merged = deepMergeJson(current, patch);
3091
+ return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
3092
+ }
3093
+
3094
+ /** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
3095
+ * BEFORE compose up, so the editor caches the real project name at startup and
3096
+ * the fleet dashboard renders the correct card label with no false
3097
+ * project-dir-drift. Like seedAgentApiKey, this MUST run before
3098
+ * ensureEditorContainerUp — the editor reads its config once at startup.
3099
+ *
3100
+ * No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
3101
+ * from its origin basename, so there is nothing to seed and no SSH round-trip
3102
+ * is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
3103
+ * converging a VM provisioned before this fix onto the correct identity without
3104
+ * a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
3105
+ * mergeEditorLocalPatchB64 helper. */
3106
+ async function seedGreenfieldProjectTitle(cfg) {
3107
+ const patch = buildGreenfieldTitlePatch(cfg);
3108
+ if (!patch) return; // clone (or no greenfield name) — nothing to seed
3109
+
3110
+ const repoName = resolveProject(cfg).name;
3111
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
3112
+ console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
3113
+
3114
+ const read = runGcloud(
3115
+ buildGcloudArgs("ssh-probe", cfg, {
3116
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
3117
+ }),
3118
+ { stdio: ["ignore", "pipe", "ignore"] },
3119
+ );
3120
+ const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
3121
+ const write = runGcloud(
3122
+ buildGcloudArgs("ssh-probe", cfg, {
3123
+ command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
3124
+ }),
3125
+ { stdio: ["ignore", "inherit", "inherit"] },
3126
+ );
3127
+ if (write.status !== 0) {
3128
+ throw new Error(`Failed to write ${remotePath} on VM`);
3129
+ }
3130
+ }
3131
+
2290
3132
  /** Tri-state probe of the editor container's running state on the VM host.
2291
3133
  * One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
2292
3134
  *
@@ -2549,6 +3391,42 @@ function streamedScriptMergeBase(editorBranch) {
2549
3391
  return mbResult.status === 0 ? mbResult.stdout.trim() : null;
2550
3392
  }
2551
3393
 
3394
+ /** Fetch `editorBranch` from origin into a REAL remote-tracking ref
3395
+ * (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
3396
+ *
3397
+ * Operator hosts are commonly shallow, single-branch clones that track only
3398
+ * `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
3399
+ * every `git show origin/<branch>:<path>` against it fails. Fetching through an
3400
+ * explicit refspec creates the ref, which keeps the drift verdict and the bytes
3401
+ * actually streamed derived from the same object. Falls back to a plain fetch
3402
+ * (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
3403
+ * can still read through FETCH_HEAD in that case. */
3404
+ function fetchStreamedScriptRef(editorBranch) {
3405
+ const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
3406
+ const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
3407
+ if (withRef.status === 0) return { ok: true, stderr: "" };
3408
+
3409
+ const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
3410
+ if (plain.status === 0) return { ok: true, stderr: "" };
3411
+ return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
3412
+ }
3413
+
3414
+ /** Resolve one revision of a streamed script from the fetched remote, trying the
3415
+ * remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
3416
+ * (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
3417
+ * streamed bytes resolve through the SAME ref-preference order — a verdict of
3418
+ * "stream remote" can therefore never disagree with what actually got streamed.
3419
+ * Returns `{ ok: false }` only when the blob is unreadable through both refs. */
3420
+ function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
3421
+ for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
3422
+ const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
3423
+ if (result.status === 0 && typeof result.stdout === "string") {
3424
+ return { ok: true, stdout: result.stdout, ref };
3425
+ }
3426
+ }
3427
+ return { ok: false, stdout: "", ref: null };
3428
+ }
3429
+
2552
3430
  /** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
2553
3431
  * its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
2554
3432
  * hash, and whether the working tree is dirty. Any git failure yields a null hash,
@@ -2560,11 +3438,8 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
2560
3438
  const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
2561
3439
  const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
2562
3440
 
2563
- let remoteHash = null;
2564
- const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${relPath}`], { encoding: "utf8" });
2565
- if (remoteHashResult.status === 0) {
2566
- remoteHash = remoteHashResult.stdout.trim();
2567
- }
3441
+ const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
3442
+ const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
2568
3443
 
2569
3444
  let baseHash = null;
2570
3445
  if (mergeBase) {
@@ -2587,7 +3462,15 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
2587
3462
  * un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
2588
3463
  * when the operator authored local content the remote tip lacks (dirty edit or a
2589
3464
  * committed local-ahead change — developer mode) or when the remote is unreachable
2590
- * (offline fallback). `absScriptPath` is an absolute path under the repo root. */
3465
+ * (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
3466
+ *
3467
+ * A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
3468
+ * checkout the on-disk bytes are precisely the stale bytes the drift guard exists
3469
+ * to keep off the VM, so substituting them silently defeats the guard: the verdict
3470
+ * said "stream the reviewed remote tip" and the VM would instead receive the old
3471
+ * script, with only a `[Warning]` to show for it. When the remote blob cannot be
3472
+ * read through either ref this throws, aborting the add.
3473
+ * `absScriptPath` is an absolute path under the repo root. */
2591
3474
  function readStreamedScript(absScriptPath, cfg = {}) {
2592
3475
  const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
2593
3476
  const editorBranch = streamedScriptEditorBranch(cfg);
@@ -2596,15 +3479,18 @@ function readStreamedScript(absScriptPath, cfg = {}) {
2596
3479
 
2597
3480
  const { streamedScriptSource } = require("./script-drift");
2598
3481
  if (streamedScriptSource(facts) === "remote") {
2599
- const remote = spawnSync("git", ["show", `origin/${editorBranch}:${relPath}`], { encoding: "utf8" });
2600
- if (remote.status === 0 && typeof remote.stdout === "string") {
2601
- console.error(` streaming canonical ${relPath} from origin/${editorBranch} (checkout behind remote is fine)`);
3482
+ const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
3483
+ if (remote.ok) {
3484
+ console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
2602
3485
  return remote.stdout;
2603
3486
  }
2604
- // Remote read failed unexpectedly (raced fetch, ref pruned): fall through to disk.
2605
- console.error(
2606
- ` [Warning] Could not read ${relPath} from origin/${editorBranch} ` +
2607
- `(${(remote.stderr || "").trim() || "unavailable"}). Falling back to the on-disk copy.`,
3487
+ // The facts said "the remote tip is the canonical source" but its bytes are
3488
+ // unreadable. Streaming the on-disk copy here would provision unreviewed stale
3489
+ // bytes under the banner of a successful add — fail loud instead.
3490
+ throw new Error(
3491
+ `could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
3492
+ `refusing to stream a possibly-stale on-disk copy. ` +
3493
+ `Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
2608
3494
  );
2609
3495
  }
2610
3496
  return fs.readFileSync(absScriptPath, "utf8");
@@ -2626,10 +3512,11 @@ function checkScriptDrift(cfg) {
2626
3512
 
2627
3513
  console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
2628
3514
 
2629
- // 1. git fetch origin <editorBranch>
2630
- const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
2631
- if (fetchResult.status !== 0) {
2632
- console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
3515
+ // 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
3516
+ // single-branch clone that tracks only +refs/heads/main.
3517
+ const fetchResult = fetchStreamedScriptRef(editorBranch);
3518
+ if (!fetchResult.ok) {
3519
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
2633
3520
  return;
2634
3521
  }
2635
3522
 
@@ -2722,8 +3609,9 @@ function fleetInstancePrefix() {
2722
3609
  * scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
2723
3610
  * result so callers decide fatality — Add treats a non-zero as a loud warning
2724
3611
  * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
2725
- * teardown still removes the instance. */
2726
- function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
3612
+ * teardown still removes the instance. `vmAccess` (up only) is the Add's intended
3613
+ * principal set, which the script's grant step uses in place of its sidecar read. */
3614
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
2727
3615
  const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
2728
3616
  const inv = vmIngressInvocation({
2729
3617
  scriptPath,
@@ -2735,6 +3623,30 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
2735
3623
  appPort,
2736
3624
  instancePrefix: fleetInstancePrefix(),
2737
3625
  slug,
3626
+ vmAccess,
3627
+ });
3628
+ return spawnSync(inv.command, inv.args, {
3629
+ stdio: "inherit",
3630
+ env: { ...process.env, ...inv.env },
3631
+ });
3632
+ }
3633
+
3634
+ /** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
3635
+ * `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
3636
+ * `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
3637
+ * Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
3638
+ * VM whose backends already exist still (re-)grants the automation SA the IAP
3639
+ * accessor. Returns the spawnSync result so the caller logs granted vs failed;
3640
+ * best-effort + loud, never fatal. */
3641
+ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
3642
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3643
+ const inv = vmIngressGrantAccessInvocation({
3644
+ scriptPath,
3645
+ vmNumber,
3646
+ domain,
3647
+ project: cfg.project,
3648
+ zone: cfg.zone,
3649
+ slug,
2738
3650
  });
2739
3651
  return spawnSync(inv.command, inv.args, {
2740
3652
  stdio: "inherit",
@@ -2742,17 +3654,77 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
2742
3654
  });
2743
3655
  }
2744
3656
 
3657
+ /** THE principal set that must have IAP access to this VM after the Add: the
3658
+ * explicit `cfg.vmAccess` collaborators unioned with the resolved default
3659
+ * operator. Both add-time access paths consume this ONE function —
3660
+ * `wireVmIngressOnAdd` (which hands it to vm-ingress.sh's grant) and
3661
+ * `applyVmAccessOnAdd` (which grants it and subtracts it from the stale-revoke
3662
+ * set). They used to decide independently, and that is precisely how one path
3663
+ * granted `user:jared@codeyam.com` at 00:09:31 and the other revoked the same
3664
+ * principal 18 seconds later as a "stale prior tenant" (VMs 4/5/6, 2026-08-09):
3665
+ * the ingress path read the SIDECAR (still holding the prior tenant) while
3666
+ * `applyVmAccessOnAdd` resolved an empty operator. One source of truth removes
3667
+ * the whole class of disagreement. `env` is injectable for tests; the only I/O
3668
+ * is `resolveDefaultOperator`'s config/gcloud read. */
3669
+ function intendedVmPrincipals(cfg = {}, env = process.env) {
3670
+ return withDefaultOperator(
3671
+ normalizeAccessList(cfg.vmAccess || []),
3672
+ resolveDefaultOperator({ env }),
3673
+ );
3674
+ }
3675
+
3676
+ /** Add preflight: throw — BEFORE any GCE instance exists — when this Add would
3677
+ * come up on an IAP-gated public URL that authorizes NOBODY. The whole condition
3678
+ * is env + config (`intendedVmPrincipals` needs no GCP state), which is why it
3679
+ * belongs beside cmdUp's other cheap boundary validations rather than after a
3680
+ * machine has been provisioned: checking later means a half-provisioned VM to
3681
+ * clean up. On 2026-08-09 the un-refused version of this Add reported `done` for
3682
+ * VMs 4/5/6, left three unreachable URLs, and revoked each slot's prior tenant on
3683
+ * the way.
3684
+ *
3685
+ * Only fires in LB mode on a numbered fleet VM with nothing to grant; an explicit
3686
+ * `--vm-access` satisfies it with no default operator configured, so the
3687
+ * collaborator flow is unaffected, as is every legacy quick-tunnel Add. The
3688
+ * refusal text (and its `Next valid action:` remedy naming all three config
3689
+ * tiers) is the pure `unauthorizableAddRefusal`; this function is the composition
3690
+ * and the throw. */
3691
+ function assertAddCanAuthorizeSomeone(cfg, env = process.env) {
3692
+ let plan;
3693
+ try {
3694
+ plan = ingressWiringPlan(cfg, env);
3695
+ } catch {
3696
+ // A malformed domain is `resolveIngressDomain`'s error to raise on the wiring
3697
+ // path; it must not masquerade here as an authorization refusal.
3698
+ plan = { wire: false };
3699
+ }
3700
+ const refusal = unauthorizableAddRefusal({
3701
+ lbMode: !!plan.wire,
3702
+ vmNumber: plan.wire ? plan.vmNumber : null,
3703
+ intendedCount: plan.wire ? intendedVmPrincipals(cfg, env).length : null,
3704
+ });
3705
+ if (refusal) throw new Error(refusal);
3706
+ }
3707
+
2745
3708
  /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
2746
3709
  * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
2747
3710
  * (the VM is reachable operator-side over SSH regardless), so on failure it
2748
3711
  * prints the exact re-run command rather than throwing. No-op (and silent) in
2749
- * legacy quick-tunnel mode or for a non-fleet client project. */
3712
+ * legacy quick-tunnel mode or for a non-fleet client project.
3713
+ *
3714
+ * RETURNS the principals this call granted (`intendedVmPrincipals`), or [] when
3715
+ * it did not wire — the explicit dataflow `applyVmAccessOnAdd` needs to know what
3716
+ * the Add has already granted before it computes a stale-revoke. Returned on the
3717
+ * FAILURE path too: a non-zero `vm-ingress.sh up` may still have completed its
3718
+ * grant step, and a principal that was possibly granted must never be revoked. */
2750
3719
  function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
2751
3720
  const plan = ingressWiringPlan(cfg, env);
2752
- if (!plan.wire) return;
3721
+ if (!plan.wire) return [];
2753
3722
  const label = plan.slug || `vm-${plan.vmNumber}`;
3723
+ // Hand the intended set to the script so its `apply_default_iap_access` grants
3724
+ // THIS Add's principals instead of reading the sidecar's prior tenant.
3725
+ const vmAccess = intendedVmPrincipals(cfg, env);
2754
3726
  console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
2755
- const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
3727
+ const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
2756
3728
  if (res.status === 0) {
2757
3729
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
2758
3730
  } else {
@@ -2774,6 +3746,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
2774
3746
  `(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
2775
3747
  );
2776
3748
  }
3749
+ return vmAccess;
2777
3750
  }
2778
3751
 
2779
3752
  /** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
@@ -2842,8 +3815,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
2842
3815
  * a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
2843
3816
  * command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
2844
3817
  * array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
2845
- * list (the clean-slate case). */
2846
- function applyVmAccessOnAdd(cfg, env = process.env) {
3818
+ * list (the clean-slate case).
3819
+ *
3820
+ * `grantedThisRun` is what an EARLIER step of this same Add already granted —
3821
+ * `wireVmIngressOnAdd`'s return value. It is subtracted from the stale-revoke
3822
+ * set, so a principal this Add just authorized can never be revoked moments later
3823
+ * as a prior tenant. That is a second, cheaper guard than the shared
3824
+ * `intendedVmPrincipals` resolver above: it holds even if the two paths drift
3825
+ * apart again, which is exactly how VMs 4/5/6 broke on 2026-08-09. */
3826
+ function applyVmAccessOnAdd(cfg, env = process.env, { grantedThisRun = [] } = {}) {
2847
3827
  const desired = normalizeAccessList(cfg.vmAccess || []);
2848
3828
  let plan;
2849
3829
  try {
@@ -2860,11 +3840,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
2860
3840
  // never inaccessible-by-default (the Error-51 footgun). The default operator is
2861
3841
  // NOT persisted to the sidecar (it's a standing grant, not a per-VM
2862
3842
  // collaborator), so `desired` alone drives the sidecar + stale-revoke below.
2863
- const toGrant = withDefaultOperator(desired, resolveDefaultOperator({ env }));
3843
+ const toGrant = intendedVmPrincipals(cfg, env);
2864
3844
  const stale = accessFor(vmNumber);
2865
3845
  // Never revoke a principal we're about to (re-)grant — a prior tenant who was
2866
- // also the default operator must not be revoked-then-regranted for nothing.
2867
- const staleOnly = stale.filter((p) => !toGrant.includes(p));
3846
+ // also the default operator must not be revoked-then-regranted for nothing —
3847
+ // NOR one an earlier step of this same Add already granted. `toGrant` alone
3848
+ // protected nothing when it was empty: the ingress step had granted the operator
3849
+ // seconds earlier and this revoke took it straight back off (VMs 4/5/6).
3850
+ const grantedAlready = new Set([...toGrant, ...normalizeAccessList(grantedThisRun || [])]);
3851
+ const staleOnly = stale.filter((p) => !grantedAlready.has(p));
2868
3852
 
2869
3853
  if (plan.wire) {
2870
3854
  if (staleOnly.length > 0) {
@@ -2981,12 +3965,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
2981
3965
  }
2982
3966
  if (!plan.wire) {
2983
3967
  // Nothing to converge — the no-op wire keeps the clean-slate invariant.
2984
- wireVmIngressOnAdd(cfg, appPort, env);
2985
- return { wired: true, reason: "no-wire" };
2986
- }
2987
-
3968
+ const granted = wireVmIngressOnAdd(cfg, appPort, env);
3969
+ return { wired: true, reason: "no-wire", grantedThisRun: granted };
3970
+ }
3971
+
3972
+ // The intended set, resolved ONCE and handed to every `up` attempt so each
3973
+ // converge iteration grants the same principals — and reported back to the
3974
+ // caller as `grantedThisRun` so the access reconcile that follows cannot revoke
3975
+ // them. Every `up` runs the script's grant step, so these are granted whether
3976
+ // the loop converges or hits its deadline. Injectable via `deps` like the rest
3977
+ // of this loop's slow I/O: the resolve spawns `gcloud config get-value account`
3978
+ // on its last tier, and a unit test must never depend on the gcloud identity
3979
+ // that happens to be active on the machine running it.
3980
+ const grantedThisRun =
3981
+ deps.grantedThisRun != null ? deps.grantedThisRun : intendedVmPrincipals(cfg, env);
2988
3982
  const runUp =
2989
- deps.runUp || ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug));
3983
+ deps.runUp
3984
+ || ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug, grantedThisRun));
2990
3985
  const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
2991
3986
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
2992
3987
  const now = deps.now || (() => Date.now());
@@ -3029,7 +4024,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3029
4024
  if (decision.done) {
3030
4025
  if (decision.wired) {
3031
4026
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3032
- return { wired: true, reason: "wired", attempts: attempt + 1 };
4027
+ return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
3033
4028
  }
3034
4029
  const rewire =
3035
4030
  `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
@@ -3046,7 +4041,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3046
4041
  `CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
3047
4042
  `within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
3048
4043
  );
3049
- return { wired: false, reason: "deadline", attempts: attempt + 1 };
4044
+ return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
3050
4045
  }
3051
4046
  attempt += 1;
3052
4047
  await sleep(decision.delayMs);
@@ -3075,8 +4070,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3075
4070
  if (!plan.wire) {
3076
4071
  // Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
3077
4072
  // sidecar write. Run them inline so the clean-slate invariant still holds.
3078
- wireVmIngressOnAdd(cfg, appPort, env);
3079
- applyVmAccessOnAdd(cfg, env);
4073
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4074
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
3080
4075
  return;
3081
4076
  }
3082
4077
  const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
@@ -3108,8 +4103,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3108
4103
  `running it inline instead.`,
3109
4104
  );
3110
4105
  try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
3111
- wireVmIngressOnAdd(cfg, appPort, env);
3112
- applyVmAccessOnAdd(cfg, env);
4106
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4107
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
3113
4108
  }
3114
4109
  }
3115
4110
 
@@ -3141,8 +4136,11 @@ async function cmdWireAddTail() {
3141
4136
  // Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
3142
4137
  // `up` left to the slow reconcile sweep — then the IAP access grant, in the
3143
4138
  // SAME order cmdUp ran them inline.
3144
- await convergeVmIngressOnAdd(cfg, appPort);
3145
- applyVmAccessOnAdd(cfg);
4139
+ // Carry what the converge granted into the access reconcile, so the revoke
4140
+ // step cannot take back a principal this same Add just authorized — the exact
4141
+ // grant-then-revoke-18s-later sequence `/tmp/cy-wire-add-vm-4.log` recorded.
4142
+ const { grantedThisRun } = await convergeVmIngressOnAdd(cfg, appPort);
4143
+ applyVmAccessOnAdd(cfg, process.env, { grantedThisRun });
3146
4144
  } finally {
3147
4145
  if (marker) {
3148
4146
  try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
@@ -3204,6 +4202,75 @@ function releaseVmSlugOnDestroy(cfg) {
3204
4202
  return vmNumber;
3205
4203
  }
3206
4204
 
4205
+ /** Does `branch` still exist on `repo`? Answered with a single
4206
+ * `git ls-remote --heads` — about a second, and no cloud resource touched.
4207
+ *
4208
+ * Tri-state on purpose, and the third state is the load-bearing one:
4209
+ * true — the ref resolved.
4210
+ * false — the remote answered and the ref is NOT there. Provably gone.
4211
+ * null — we could not get an answer (no network, no credentials, git
4212
+ * missing, timeout). NOT evidence of a deleted branch.
4213
+ * Only `false` refuses an add. Collapsing `null` into `false` would turn every
4214
+ * transient GitHub blip into a fleet-wide add outage, which is a far worse
4215
+ * failure than the one this preflight exists to prevent — and the classifier
4216
+ * arm downstream still catches a genuinely dead ref, just later and dearer.
4217
+ *
4218
+ * A private repo needs credentials, so the token is inlined into the URL the
4219
+ * same way clone_editor_repo does it on the VM side (x-access-token), and for
4220
+ * the same reason: the plain URL 401s. The token is confined to this argv and
4221
+ * never logged — the caller prints `repo` (the un-inlined form) instead. */
4222
+ function gitBranchRefExists({ repo, branch }) {
4223
+ if (!repo || !branch) return null;
4224
+ let url = repo;
4225
+ const token = resolveGithubToken();
4226
+ if (token && /^https:\/\/github\.com\//.test(repo)) {
4227
+ url = repo.replace("https://", `https://x-access-token:${token}@`);
4228
+ }
4229
+ const res = spawnSync("git", ["ls-remote", "--heads", url, branch], {
4230
+ encoding: "utf8",
4231
+ timeout: 30000,
4232
+ });
4233
+ // A non-zero exit is an unreachable/unauthorized remote, not an absent branch:
4234
+ // git exits 0 and prints nothing for "connected fine, no such head".
4235
+ if (res.error || res.status !== 0) return null;
4236
+ return String(res.stdout || "").trim().length > 0;
4237
+ }
4238
+
4239
+ /** Refuse an add whose stored branch has been deleted from origin, BEFORE the
4240
+ * first gcloud call.
4241
+ *
4242
+ * Without this the failure lands as late and as expensively as it possibly can:
4243
+ * on 2026-08-25 four fleet VMs each created a real GCE instance, booted it,
4244
+ * installed npm and cloned 13,226 files before `git fetch` inside the VM said
4245
+ * `fatal: couldn't find remote ref editor-improvements-75` — a branch pruned
4246
+ * from origin hours earlier when it merged. One `git ls-remote` establishes the
4247
+ * same fact in about a second, for free. This repo prunes branches on merge, so
4248
+ * ANY stored feature-branch name expires silently; that makes this a recurring
4249
+ * condition, not a one-off.
4250
+ *
4251
+ * Placed beside checkScriptDrift and assertVariantProvisionable — the other
4252
+ * "fail loud before creating a GCE instance" guards — because it is only worth
4253
+ * anything ahead of ensureInstance. It cannot live in scripts/gcp-bootstrap.sh,
4254
+ * which already runs ON the provisioned box and never sees the client branch.
4255
+ *
4256
+ * Throws with the field, the branch and the repo named. Never throws for a ref
4257
+ * it could not check (see gitBranchRefExists). */
4258
+ function assertBranchRefsExist(cfg, { refExists = gitBranchRefExists } = {}) {
4259
+ // Lazy require: launch-config.js requires THIS module at load time, so a
4260
+ // top-level require here would close the cycle. Same pattern as the
4261
+ // isPlaceholderProject call sites above.
4262
+ const lc = require("./launch-config");
4263
+ const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
4264
+ const targets = lc.branchRefTargets(
4265
+ { clientRepo: cfg.repo, clientBranch: cfg.branch, editorBranch },
4266
+ { editorRepo },
4267
+ );
4268
+ if (!targets.length) return;
4269
+ console.error("Checking that the requested branches still exist on origin...");
4270
+ const missing = lc.missingBranchRefs(targets, refExists);
4271
+ if (missing.length) throw new Error(lc.missingBranchRefMessage(missing));
4272
+ }
4273
+
3207
4274
  async function cmdUp(args, projectDir = rootDir) {
3208
4275
  ensureGcloud();
3209
4276
  const flags = parseFlags(args);
@@ -3254,8 +4321,14 @@ async function cmdUp(args, projectDir = rootDir) {
3254
4321
  }
3255
4322
  }
3256
4323
 
4324
+ assertAddCanAuthorizeSomeone(cfg);
4325
+
3257
4326
  checkScriptDrift(cfg);
3258
4327
 
4328
+ // Last of the pre-instance guards: a deleted branch ref is checkable in ~1s
4329
+ // and otherwise costs a full instance + boot + 13k-file clone to discover.
4330
+ assertBranchRefsExist(cfg);
4331
+
3259
4332
  await ensureFirewallRule(cfg);
3260
4333
  const { createdThisRun } = await ensureInstance(cfg);
3261
4334
 
@@ -3285,6 +4358,10 @@ async function cmdUp(args, projectDir = rootDir) {
3285
4358
  }
3286
4359
  }
3287
4360
  await seedAgentApiKey(cfg);
4361
+ // Greenfield only (no-op for a clone): seed projectTitle so the editor
4362
+ // resolves its identity to the real project name instead of the bare
4363
+ // /workspace mount basename — the source of the false project-dir-drift.
4364
+ await seedGreenfieldProjectTitle(cfg);
3288
4365
  await ensureEditorContainerUp(cfg);
3289
4366
  // Provision contract: a just-provisioned VM must actually reach GitHub.
3290
4367
  // Verify in-container git auth now that the container (and its credential
@@ -3356,7 +4433,27 @@ async function cmdUp(args, projectDir = rootDir) {
3356
4433
  );
3357
4434
  }
3358
4435
  } catch (err) {
3359
- if (createdThisRun) {
4436
+ // `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
4437
+ // VM is in right now, then let the pure decision rule — see
4438
+ // abortTeardownDecision's doc comment for why each input is load-bearing.
4439
+ const state = probeAbortTeardownState(cfg, createdThisRun);
4440
+ const verdict = abortTeardownDecision({ createdThisRun, ...state });
4441
+ if (createdThisRun && verdict === "keep") {
4442
+ // Deliberately loud and specific. The old blanket "tearing down the VM
4443
+ // cloud:up created this run (no orphan left behind)" was actively
4444
+ // misleading here — it asserted no orphan while deleting a working VM.
4445
+ const why = state.probeInconclusive
4446
+ ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4447
+ : state.containerUp
4448
+ ? "its editor container is RUNNING — another run brought it up"
4449
+ : "it is in the fleet roster and visible to the dashboard";
4450
+ console.error(
4451
+ `✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
4452
+ ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
4453
+ ` If it really should go, remove it deliberately: npm run cloud:destroy`,
4454
+ );
4455
+ }
4456
+ if (verdict === "delete") {
3360
4457
  console.error(
3361
4458
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
3362
4459
  );
@@ -3538,14 +4635,7 @@ async function cmdStatus(args, projectDir = rootDir) {
3538
4635
  const imgStatus = String(imgProbe.stdout || "").trim() || "?";
3539
4636
  console.error(` image: ${imgStatus}`);
3540
4637
 
3541
- const repoName = resolveProject(cfg).name;
3542
- const containerProbe = runGcloud(
3543
- buildGcloudArgs("ssh-probe", cfg, {
3544
- command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
3545
- }),
3546
- { stdio: ["ignore", "pipe", "ignore"] },
3547
- );
3548
- const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
4638
+ const containerStatus = probeEditorComposeState(cfg).state || "missing";
3549
4639
  console.error(` editor container: ${containerStatus}`);
3550
4640
 
3551
4641
  const appPort = await readVmAppPort(cfg);
@@ -3639,17 +4729,40 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3639
4729
  zone: cfg.zone,
3640
4730
  slug,
3641
4731
  });
4732
+ // Bound the per-VM status probe: a hung `bash vm-ingress.sh <N> status`
4733
+ // (its gcloud describes stalling on an IAP/metadata hiccup) must not stall the
4734
+ // whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
4735
+ // returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
4736
+ // this VM and the sweep continues to the next one.
3642
4737
  const res = spawnSync(inv.command, inv.args, {
3643
4738
  env: { ...process.env, ...inv.env },
3644
4739
  encoding: "utf8",
4740
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
3645
4741
  });
3646
4742
  return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
3647
4743
  });
3648
4744
  const targets = selectIngressReconcileTargets(statuses);
3649
4745
  const targetNums = new Set(targets.map((t) => t.vmNumber));
4746
+ // The complement of the re-wire loop: `wired` VMs whose backends already exist
4747
+ // so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
4748
+ // `up` is the only path that grants it, and it never runs for a wired VM. This
4749
+ // closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
4750
+ // fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
4751
+ const grantOnlyNums = new Set(
4752
+ selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
4753
+ );
3650
4754
  for (const s of statuses) {
3651
- if (!targetNums.has(s.vmNumber)) {
3652
- const label = s.slug || `vm-${s.vmNumber}`;
4755
+ if (targetNums.has(s.vmNumber)) continue;
4756
+ const label = s.slug || `vm-${s.vmNumber}`;
4757
+ if (grantOnlyNums.has(s.vmNumber)) {
4758
+ // Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
4759
+ // NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
4760
+ // bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
4761
+ const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
4762
+ console.error(
4763
+ ` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
4764
+ );
4765
+ } else {
3653
4766
  console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
3654
4767
  }
3655
4768
  }
@@ -3679,7 +4792,13 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3679
4792
  for (const surface of gi.SURFACES) {
3680
4793
  const bes = gi.backendServiceName(n, surface);
3681
4794
  const inv = backendServiceTimeoutInvocation({ bes, project: cfg.project });
3682
- const res = spawnSync(inv.command, inv.args, { encoding: "utf8" });
4795
+ // Bound the per-BES converge too: a hung `gcloud backend-services update`
4796
+ // returns a non-zero (timed-out) result, so it falls into the skip branch
4797
+ // below and the sweep moves on to the next BES rather than stalling.
4798
+ const res = spawnSync(inv.command, inv.args, {
4799
+ encoding: "utf8",
4800
+ timeout: GCLOUD_CALL_TIMEOUT_MS,
4801
+ });
3683
4802
  if (res.status === 0) {
3684
4803
  console.error(` ${bes}: timeout → 86400`);
3685
4804
  } else {
@@ -3689,6 +4808,12 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3689
4808
  }
3690
4809
  }
3691
4810
 
4811
+ // Converge the shared fleet firewall, then surface any app port it still fails to
4812
+ // admit. Both ride this same boot + periodic cadence so the running fleet
4813
+ // self-heals without a hand-run bootstrap.
4814
+ const firewallState = convergeFleetFirewall({ cfg });
4815
+ await reportFirewallPortGaps({ cfg, roster, firewallState });
4816
+
3692
4817
  // Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
3693
4818
  // backend service whose VM number is gone from the roster is a destroyed VM's
3694
4819
  // leftover — cleaned here automatically with the verified `down`, so the
@@ -3697,22 +4822,131 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3697
4822
  sweepOrphanIngress({ cfg, domain, roster, projectDir });
3698
4823
  }
3699
4824
 
3700
- /** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): backend
3701
- * services whose owning VM number is gone from the roster (a destroyed VM) and
3702
- * has no in-flight add. Enumerates the live backends, maps each name back to its
3703
- * VM number, subtracts the roster + in-flight adds (the false-orphan guards),
3704
- * and runs the VERIFIED `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no,
3705
- * with retries) for each orphan — so an orphan is genuinely cleared or surfaced
3706
- * as a real failure, never a silent leak and never a manual command. Emits a
3707
- * machine-readable `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to
3708
- * reconcile its orphan-attention banner. Best-effort per VM: one `down` failing
3709
- * never aborts the rest of the sweep. Returns the `{ cleaned, failed, skipped }`
3710
- * summary (null when the enumeration itself failed) — the same shape it prints —
3711
- * so the flow is directly assertable. `deps` is injectable so the enumerate →
3712
- * select → down flow is testable without gcloud. */
4825
+ /** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
4826
+ * resources whose owning VM number is gone from the roster (a destroyed VM) and
4827
+ * has no in-flight add. Enumerates the live backend services AND zonal NEGs,
4828
+ * UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
4829
+ * backend service — the VM-5/6 case — still surfaces its VM as a candidate),
4830
+ * maps each name back to its VM number, subtracts the roster + in-flight adds +
4831
+ * live instances (the false-orphan guards), and runs the VERIFIED
4832
+ * `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
4833
+ * each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
4834
+ * never a silent leak and never a manual command. Emits a machine-readable
4835
+ * `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
4836
+ * orphan-attention banner; the summary carries `stillPresent` (VM numbers that
4837
+ * still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
4838
+ * banner for a VM that is now back in the roster or has no residual resources.
4839
+ * Best-effort per VM: one `down` failing never aborts the rest of the sweep.
4840
+ * Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
4841
+ * this pass (a live-but-unrostered VM must never be torn down) while the banner
4842
+ * reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
4843
+ * summary (null when the backend-service enumeration itself failed) — the same
4844
+ * shape it prints — so the flow is directly assertable. `deps` is injectable so
4845
+ * the enumerate → select → down flow is testable without gcloud. */
4846
+ /** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
4847
+ * port a project's app actually serves.
4848
+ *
4849
+ * A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
4850
+ * LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
4851
+ * outage — while the preview backend service still reads HEALTHY, because its
4852
+ * health check runs on the always-admitted editor port. The failure is therefore
4853
+ * invisible until someone opens the preview. Converging here (not only in the
4854
+ * hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
4855
+ * the running fleet.
4856
+ *
4857
+ * All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
4858
+ * to Google's LB + health-check source ranges and the fleet instance tag either
4859
+ * way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
4860
+ * without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
4861
+ * converged: describing only one silently no-ops on a fleet wired under the other.
4862
+ *
4863
+ * Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
4864
+ * protocols/ports, never a rule's source ranges or target tags, so this can never
4865
+ * change who the rule applies to. Best-effort throughout: a describe/update
4866
+ * failure logs and continues, never aborting the rest of the reconcile.
4867
+ *
4868
+ * Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
4869
+ * is the post-converge allowed value — so the flow is assertable without gcloud.
4870
+ * `deps.spawnSync` is injectable for the same reason.
4871
+ * See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
4872
+ function convergeFleetFirewall({ cfg } = {}, deps = {}) {
4873
+ const run = deps.spawnSync || spawnSync;
4874
+ const project = cfg && cfg.project;
4875
+ const state = [];
4876
+ for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
4877
+ const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
4878
+ const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
4879
+ // A non-zero describe means no rule under this name — expected, not an error:
4880
+ // only one of the candidate names is deployed on any given fleet.
4881
+ if (!dres || dres.status !== 0) continue;
4882
+ const current = ((dres.stdout || "") + "").trim();
4883
+ if (!firewallNeedsConverge(current)) {
4884
+ console.error(` firewall ${ruleName}: already all-tcp → ok`);
4885
+ state.push({ ruleName, rules: "tcp", converged: false });
4886
+ continue;
4887
+ }
4888
+ const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
4889
+ const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
4890
+ if (cres && cres.status === 0) {
4891
+ console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
4892
+ state.push({ ruleName, rules: "tcp", converged: true });
4893
+ } else {
4894
+ const tail = (
4895
+ (cres && (cres.stderr || (cres.error && cres.error.message))) || ""
4896
+ ).toString().trim().split("\n").pop() || "error";
4897
+ console.error(` firewall ${ruleName}: converge skipped (${tail})`);
4898
+ state.push({ ruleName, rules: current, converged: false });
4899
+ }
4900
+ }
4901
+ if (!state.length) {
4902
+ console.error(
4903
+ ` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
4904
+ " LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
4905
+ );
4906
+ }
4907
+ return state;
4908
+ }
4909
+
4910
+ /** Surface any rostered VM whose app port the fleet firewall does not admit — the
4911
+ * tripwire for a masked "HEALTHY but unreachable" preview.
4912
+ *
4913
+ * The preview backend service health-checks the fixed editor port, so a rule that
4914
+ * admits that port but not the app's real one reads HEALTHY while every real
4915
+ * request is dropped. That masking is what made the VM-6 outage invisible. Emits a
4916
+ * machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
4917
+ * `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
4918
+ *
4919
+ * Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
4920
+ * port, so the healthy path cannot produce a gap and skipping it avoids an SSH
4921
+ * round-trip per rostered VM for a check that cannot fail. In practice this fires
4922
+ * only if the converge itself failed (e.g. a permissions regression).
4923
+ *
4924
+ * Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
4925
+ * shape it prints — so the marker contract is directly assertable.
4926
+ * `deps.readVmAppPort` is injectable so tests never reach for SSH. */
4927
+ async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
4928
+ const readPort = deps.readVmAppPort || readVmAppPort;
4929
+ const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
4930
+ const gaps = [];
4931
+ if (!pinned.length) return gaps;
4932
+ for (const n of roster || []) {
4933
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
4934
+ const appPort = await readPort(perVmCfg);
4935
+ for (const f of pinned) {
4936
+ if (firewallAdmitsPort(f.rules, appPort)) continue;
4937
+ const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
4938
+ console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
4939
+ console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
4940
+ gaps.push(gap);
4941
+ }
4942
+ }
4943
+ return gaps;
4944
+ }
4945
+
3713
4946
  function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
3714
4947
  const gi = deps.googleIngress || require("./google-ingress.js");
3715
4948
  const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
4949
+ const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
3716
4950
  const run = deps.spawnSync || spawnSync;
3717
4951
 
3718
4952
  const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
@@ -3724,11 +4958,31 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3724
4958
  console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
3725
4959
  return null;
3726
4960
  }
3727
- const ingressVmNumbers = String(listRes.stdout || "")
4961
+ const besVmNumbers = String(listRes.stdout || "")
3728
4962
  .split("\n")
3729
4963
  .map((line) => gi.vmNumberFromResourceName(line.trim()))
3730
4964
  .filter((n) => n != null);
3731
4965
 
4966
+ // Also enumerate zonal NEGs and union their owners in: a partial teardown can
4967
+ // leave a `vm-<N>-<surface>-neg` behind after its backend service is already
4968
+ // gone, so a backend-service-only candidate set misses that residue. Best-effort
4969
+ // — a NEG-list failure never aborts the backend-service-driven sweep.
4970
+ let negVmNumbers = [];
4971
+ const negInv = negListInvocation({ project: cfg && cfg.project });
4972
+ const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
4973
+ if (negRes && negRes.status === 0) {
4974
+ negVmNumbers = String(negRes.stdout || "")
4975
+ .split("\n")
4976
+ .map((line) => gi.vmNumberFromResourceName(line.trim()))
4977
+ .filter((n) => n != null);
4978
+ } else {
4979
+ const tail = (
4980
+ (negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
4981
+ ).toString().trim().split("\n").pop() || "error";
4982
+ console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
4983
+ }
4984
+ const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
4985
+
3732
4986
  // A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
3733
4987
  // not yet rostered), is live — never an orphan.
3734
4988
  const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
@@ -3736,9 +4990,34 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3736
4990
  .listPendingJobs(pendingDir)
3737
4991
  .filter((j) => j.action === "add")
3738
4992
  .map((j) => j.n);
3739
- const orphans = selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds });
3740
4993
 
3741
- const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice() };
4994
+ // Defensive live-instance guard: never tear down a VM whose GCP instance is
4995
+ // actually running (a roster drift could leave a live VM unrostered; a teardown
4996
+ // that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
4997
+ // means the list couldn't be fetched — fail safe by skipping teardown this pass
4998
+ // (below) rather than risk a false teardown. The banner reconcile still runs.
4999
+ let liveVmNumbers = null;
5000
+ const instInv = instancesListInvocation({ project: cfg && cfg.project });
5001
+ const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
5002
+ if (instRes && instRes.status === 0) {
5003
+ liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
5004
+ } else {
5005
+ const tail = (
5006
+ (instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
5007
+ ).toString().trim().split("\n").pop() || "error";
5008
+ console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
5009
+ }
5010
+
5011
+ const orphans = liveVmNumbers === null
5012
+ ? []
5013
+ : selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
5014
+
5015
+ // stillPresent = every VM that owned an LB resource going in, minus the ones we
5016
+ // verifiably clean below. The dashboard clears a banner for any orphan VM not in
5017
+ // this set (or now back in the roster). Computed up front so the no-orphans and
5018
+ // fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
5019
+ // (re-rostered, so never a sweep target) with no teardown at all.
5020
+ const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
3742
5021
  if (!orphans.length) {
3743
5022
  console.error(" orphan sweep: no orphaned LB ingress found.");
3744
5023
  console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
@@ -3764,7 +5043,14 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3764
5043
  });
3765
5044
  const res = run(inv.command, inv.args, { encoding: "utf8", env: { ...process.env, ...inv.env } });
3766
5045
  const verdict = parseIngressTeardownVerdict((res && res.stdout) || "");
3767
- const outcome = classifyOrphanDownResult({ verdict, status: res ? res.status : null });
5046
+ // Pass `signal` too: a teardown the caller's group-kill reaped has a NULL
5047
+ // status, which the classifier would otherwise report as `spawn-failed`
5048
+ // rather than the truthful `killed=SIGKILL`.
5049
+ const outcome = classifyOrphanDownResult({
5050
+ verdict,
5051
+ status: res ? res.status : null,
5052
+ signal: res ? res.signal : null,
5053
+ });
3768
5054
  if (outcome.cleaned) {
3769
5055
  summary.cleaned.push(n);
3770
5056
  console.error(` VM-${n}: orphaned ingress → auto-cleaned (verified clean)`);
@@ -3775,6 +5061,11 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3775
5061
  console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
3776
5062
  }
3777
5063
  }
5064
+ // A verifiably-cleaned VM no longer owns any LB resource — drop it from
5065
+ // stillPresent so the dashboard clears its banner (failed VMs stay, keeping
5066
+ // their real attention item).
5067
+ const cleanedSet = new Set(summary.cleaned);
5068
+ summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
3778
5069
  console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
3779
5070
  return summary;
3780
5071
  }
@@ -3828,20 +5119,33 @@ async function probeGitCredentials(cfg) {
3828
5119
  /** Seed the LIVE in-memory git credential-cache daemon over its socket — the
3829
5120
  * same `git credential approve` shape the entrypoint uses
3830
5121
  * (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
3831
- * container restart. base64 keeps the token out of the shell history across
3832
- * the gcloud-ssh → docker layers. Throws if the approve fails. Shared by
3833
- * {@link cmdReconcileCredentials} and {@link assertGitPushCredentials}. */
5122
+ * container restart. ALSO writes the container-local on-disk `store` fallback
5123
+ * file (the fill-only second helper baked into the image) directly, so a
5124
+ * reconcile leaves the VM durable against the cache daemon dying mid-life, not
5125
+ * just healthy for the moment. base64 keeps the token out of the shell history
5126
+ * across the gcloud-ssh → docker layers, and the store write is an in-place
5127
+ * `cat > file` truncate (chmod 600), never touched by git's approve path.
5128
+ * Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
5129
+ * {@link assertGitPushCredentials}. */
3834
5130
  async function seedLiveCredentialCache(cfg, token) {
3835
5131
  const repoName = resolveProject(cfg).name;
3836
5132
  const projectCd = `cd ~/projects/${repoName}`;
3837
5133
  const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
3838
5134
  const socket = "/run/codeyam/git-credential-cache.socket";
5135
+ const store = "/run/codeyam/git-credentials-store";
3839
5136
  const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
3840
5137
  const b64 = Buffer.from(approveStdin).toString("base64");
5138
+ // The store-file line is the same one-URL-per-line format as the host staging
5139
+ // file; the image's fill-only `store` helper reads it on a cache miss.
5140
+ const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
3841
5141
  const seedCmd =
3842
- `${projectCd} && echo ${b64} | base64 -d | ${composeExec} ` +
5142
+ `${projectCd} && ` +
5143
+ `echo ${b64} | base64 -d | ${composeExec} ` +
3843
5144
  `git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
3844
- `credential approve && echo APPROVED`;
5145
+ `credential approve && ` +
5146
+ `echo ${storeB64} | base64 -d | ${composeExec} ` +
5147
+ `bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
5148
+ `echo APPROVED`;
3845
5149
  const seed = runGcloud(
3846
5150
  buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
3847
5151
  { stdio: ["ignore", "pipe", "inherit"] },
@@ -3880,6 +5184,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
3880
5184
  );
3881
5185
  }
3882
5186
 
5187
+ /** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
5188
+ * WITHOUT a full re-provision. Resolves the desired value from this invocation
5189
+ * (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
5190
+ * CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
5191
+ * compose edit + `docker compose up -d editor` does today, so the durable admin
5192
+ * setting no longer requires a full re-provision to change. */
5193
+ async function cmdReconcileAdmin(args, projectDir = rootDir) {
5194
+ ensureGcloud();
5195
+ const flags = parseFlags(args);
5196
+ const cfg = loadCloudConfig(projectDir);
5197
+ if (!cfg) {
5198
+ throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
5199
+ }
5200
+ const desired =
5201
+ buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
5202
+ const repoName = resolveProject(cfg).name;
5203
+ console.error(
5204
+ `Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
5205
+ );
5206
+ const result = runGcloud(
5207
+ buildGcloudArgs("ssh-probe", cfg, {
5208
+ command: buildReconcileAdminCommand(repoName, desired),
5209
+ }),
5210
+ { stdio: "inherit" },
5211
+ );
5212
+ if (result.status !== 0) {
5213
+ throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
5214
+ }
5215
+ console.error(
5216
+ ` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
5217
+ );
5218
+ }
5219
+
3883
5220
  /** Provision-time guarantee that a just-provisioned VM can actually reach
3884
5221
  * GitHub. Probes the staging file + in-container `git ls-remote origin` and
3885
5222
  * routes the outcome through the SAME {@link decideCredentialHeal} classifier
@@ -4011,6 +5348,7 @@ const HANDLERS = {
4011
5348
  "reconcile-ingress": cmdReconcileIngress,
4012
5349
  "wire-add-tail": cmdWireAddTail,
4013
5350
  "reconcile-credentials": cmdReconcileCredentials,
5351
+ "reconcile-admin": cmdReconcileAdmin,
4014
5352
  access: cmdAccess,
4015
5353
  };
4016
5354
 
@@ -4033,6 +5371,7 @@ async function cloudMain() {
4033
5371
 
4034
5372
  module.exports = {
4035
5373
  // pure helpers
5374
+ isSpawnTimeout,
4036
5375
  agentEnvVarName,
4037
5376
  agentNpmPackage,
4038
5377
  AGENT_ENV_VARS,
@@ -4043,11 +5382,15 @@ module.exports = {
4043
5382
  assertAgentCliInstalled,
4044
5383
  probeContainerRunning,
4045
5384
  probeContainerState,
5385
+ waitForEditorComposeRunning,
5386
+ abortTeardownDecision,
5387
+ probeAbortTeardownState,
4046
5388
  probeAgentBinaryInstalled,
4047
5389
  waitForContainerRunning,
4048
5390
  KNOWN_FLAGS,
4049
5391
  FIREWALL_RULE_NAME,
4050
5392
  INSTANCE_TAG,
5393
+ DEFAULT_FLEET_MACHINE_TYPE,
4051
5394
  EDITOR_PORT,
4052
5395
  VITE_PORT,
4053
5396
  parseFlags,
@@ -4066,6 +5409,7 @@ module.exports = {
4066
5409
  previewSameOriginSafeFromStack,
4067
5410
  isRottedManagedPreviewOrigin,
4068
5411
  isWrongModePreviewOrigin,
5412
+ isSilentlyBrokenPreview,
4069
5413
  resolveEditorConfig,
4070
5414
  validateEditorVariant,
4071
5415
  assertVariantProvisionable,
@@ -4074,13 +5418,19 @@ module.exports = {
4074
5418
  resolveGhcrToken,
4075
5419
  resolveGhcrPushToken,
4076
5420
  probeUsableEditorImage,
5421
+ httpsManifestGet,
4077
5422
  resolveProject,
4078
5423
  clientProjectBootstrapArgs,
4079
5424
  cloudConfigPath,
4080
5425
  loadCloudConfig,
4081
5426
  saveCloudConfig,
4082
5427
  loadOrInitCloudConfig,
5428
+ buildProvisionEnv,
5429
+ buildReconcileAdminCommand,
5430
+ cmdReconcileAdmin,
4083
5431
  buildEditorLocalEnvPatch,
5432
+ buildGreenfieldTitlePatch,
5433
+ mergeEditorLocalPatchB64,
4084
5434
  buildEditorLocalSeedWriteCommand,
4085
5435
  seedPreviewOrigin,
4086
5436
  healPreviewOriginAtReady,
@@ -4094,6 +5444,22 @@ module.exports = {
4094
5444
  decideBootstrapOutcome,
4095
5445
  projectBootstrapDoneMarker,
4096
5446
  projectBootstrapDoneProbeCommand,
5447
+ projectBootstrapScriptPath,
5448
+ projectBootstrapRunnerPath,
5449
+ projectBootstrapLogPath,
5450
+ projectBootstrapPidPath,
5451
+ projectBootstrapStatusPath,
5452
+ projectBootstrapRunnerScript,
5453
+ projectBootstrapLaunchCommand,
5454
+ projectBootstrapFollowCommand,
5455
+ projectBootstrapStatusCommand,
5456
+ parseProjectBootstrapStatus,
5457
+ decideProjectBootstrapOutcome,
5458
+ decideProjectBootstrapFollowAction,
5459
+ projectBootstrapEnv,
5460
+ PROJECT_BOOTSTRAP_LAUNCHED_MARKER,
5461
+ PROJECT_BOOTSTRAP_FOLLOW_SECS,
5462
+ ensureProjectBootstrapped,
4097
5463
  SSH_KEEPALIVE_FLAGS,
4098
5464
  resolveSetupForwardEnv,
4099
5465
  FORWARD_ENV_KEYS_ENV,
@@ -4108,6 +5474,8 @@ module.exports = {
4108
5474
  checkScriptDrift,
4109
5475
  streamedScriptEditorBranch,
4110
5476
  streamedScriptMergeBase,
5477
+ fetchStreamedScriptRef,
5478
+ resolveRemoteScriptRev,
4111
5479
  gatherScriptDriftFacts,
4112
5480
  readStreamedScript,
4113
5481
  STREAMED_SCRIPTS,
@@ -4116,6 +5484,8 @@ module.exports = {
4116
5484
  ingressWiringPlan,
4117
5485
  fleetInstancePrefix,
4118
5486
  runVmIngress,
5487
+ intendedVmPrincipals,
5488
+ assertAddCanAuthorizeSomeone,
4119
5489
  wireVmIngressOnAdd,
4120
5490
  probeVmIngressVerdict,
4121
5491
  convergeVmIngressOnAdd,
@@ -4134,6 +5504,8 @@ module.exports = {
4134
5504
  cmdIngressUp,
4135
5505
  cmdReconcileIngress,
4136
5506
  sweepOrphanIngress,
5507
+ convergeFleetFirewall,
5508
+ reportFirewallPortGaps,
4137
5509
  cmdReconcileCredentials,
4138
5510
  cmdAccess,
4139
5511
  cloudMain,
@@ -4141,13 +5513,17 @@ module.exports = {
4141
5513
  seedLiveCredentialCache,
4142
5514
  assertGitPushCredentials,
4143
5515
  seedGithubCredentials,
5516
+ githubCredentialLine,
4144
5517
  resolveGithubToken,
4145
5518
  resolveGithubTokenWithSource,
4146
5519
  MissingGitPushCredentialsError,
4147
5520
  classifyGitRemoteError,
4148
5521
  decideCredentialHeal,
4149
5522
  readVmAppPort,
5523
+ buildVmAppPortReadCommand,
4150
5524
  resolveLaptopGitIdentity,
5525
+ gitBranchRefExists,
5526
+ assertBranchRefsExist,
4151
5527
  };
4152
5528
 
4153
5529
  if (require.main === module) {