@codeyam-editor/codeyam-editor 0.1.0-staging.g296f8a2 → 0.1.0-staging.g3858572

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 (33) hide show
  1. package/npm/cloud.js +308 -52
  2. package/npm/compose-conflict.js +59 -0
  3. package/npm/fleet-add-failure.js +59 -12
  4. package/npm/fleet-cleanup.js +129 -0
  5. package/npm/fleet-memory.js +29 -4
  6. package/npm/fleet-queue.js +141 -0
  7. package/npm/fleet-vm-access.js +162 -0
  8. package/npm/ingress-invoke.js +46 -2
  9. package/npm/launch-config.js +43 -0
  10. package/npm/scenario-check.js +63 -19
  11. package/npm/scenario-handlers.js +18 -0
  12. package/npm/scenario-issues.js +11 -0
  13. package/npm/scenario-mocks.js +245 -39
  14. package/npm/scenario-playwright.js +114 -22
  15. package/package.json +5 -5
  16. package/ui/dist/assets/{ContainerInspector-B4reOSvu.js → ContainerInspector-DSVZvEkB.js} +1 -1
  17. package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
  18. package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
  19. package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
  20. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
  21. package/ui/dist/assets/{ScenarioDataPanelFullPage-aIivPUt9.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
  22. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
  23. package/ui/dist/assets/{index-D_Bz8tMJ.js → index-BPLKgwon.js} +9 -9
  24. package/ui/dist/assets/index-DKpgJLo3.css +1 -0
  25. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  26. package/ui/dist/index.html +2 -2
  27. package/ui/dist/assets/EditorShell-CxwZ5mHM.js +0 -252
  28. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  29. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
  30. package/ui/dist/assets/ScenarioDataPanel-wBhNIA9N.js +0 -9
  31. package/ui/dist/assets/ScenarioRenderer-BhJNFcF2.js +0 -21
  32. package/ui/dist/assets/index-BygCiJSb.css +0 -1
  33. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
package/npm/cloud.js CHANGED
@@ -59,6 +59,7 @@ const {
59
59
  normalizePrincipal,
60
60
  resolveDefaultOperator,
61
61
  withDefaultOperator,
62
+ unauthorizableAddRefusal,
62
63
  accessFor,
63
64
  setAccess,
64
65
  addAccess,
@@ -105,6 +106,10 @@ const {
105
106
  // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
106
107
  // fleet for `reconcile-ingress`.
107
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");
108
113
 
109
114
  // =============================================================================
110
115
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -845,6 +850,79 @@ function parseEditorComposeState(stdout) {
845
850
  return null;
846
851
  }
847
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
+
848
926
  /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
849
927
  * switch's job is to keep every per-verb command shape in one reviewable
850
928
  * place — tests assert the arrays without ever spawning a process. */
@@ -2049,20 +2127,65 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2049
2127
  }
2050
2128
  }
2051
2129
 
2052
- async function ensureEditorContainerUp(cfg) {
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) {
2053
2143
  const repoName = resolveProject(cfg).name;
2054
- // -f docker-compose.cloud.yml everywhere: the editor repo's own
2055
- // docker-compose.yml does `build: .` from source and would shadow the
2056
- // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
2057
2144
  const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2058
- console.error("Checking editor container state...");
2059
2145
  const probe = runGcloud(
2060
2146
  buildGcloudArgs("ssh-probe", cfg, {
2061
- command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
2147
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2062
2148
  }),
2063
2149
  { stdio: ["ignore", "pipe", "ignore"] },
2064
2150
  );
2065
- if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
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
+ }
2177
+ }
2178
+ return false;
2179
+ }
2180
+
2181
+ async function ensureEditorContainerUp(cfg) {
2182
+ const repoName = resolveProject(cfg).name;
2183
+ // -f docker-compose.cloud.yml everywhere: the editor repo's own
2184
+ // docker-compose.yml does `build: .` from source and would shadow the
2185
+ // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
2186
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2187
+ console.error("Checking editor container state...");
2188
+ if (probeEditorComposeState(cfg).state === "running") {
2066
2189
  console.error(" ✓ editor container is already running");
2067
2190
  // containerStarted: the container is confirmed up, so the CLI install runs
2068
2191
  // its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
@@ -2070,14 +2193,47 @@ async function ensureEditorContainerUp(cfg) {
2070
2193
  return;
2071
2194
  }
2072
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.
2073
2203
  const up = runGcloud(
2074
2204
  buildGcloudArgs("ssh-probe", cfg, {
2075
2205
  command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
2076
2206
  }),
2077
- { stdio: "inherit" },
2207
+ { stdio: ["ignore", "pipe", "pipe"] },
2078
2208
  );
2209
+ if (up.stdout) process.stderr.write(up.stdout);
2210
+ if (up.stderr) process.stderr.write(up.stderr);
2079
2211
  if (up.status !== 0) {
2080
- 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;
2081
2237
  }
2082
2238
  // containerStarted: `up -d` returned 0, so the CLI install is legitimately a
2083
2239
  // post-start step. A brief wait-for-running here (slow boot settling) is
@@ -2093,18 +2249,10 @@ async function ensureEditorContainerUp(cfg) {
2093
2249
  // success. This throws (non-zero exit) when the editor service isn't
2094
2250
  // `running`, converting a silent partial provision into a loud, actionable
2095
2251
  // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
2096
- // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
2097
- // 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.
2098
2254
  async function assertEditorContainerRunning(cfg) {
2099
- const repoName = resolveProject(cfg).name;
2100
- const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2101
- const probe = runGcloud(
2102
- buildGcloudArgs("ssh-probe", cfg, {
2103
- command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2104
- }),
2105
- { stdio: ["ignore", "pipe", "ignore"] },
2106
- );
2107
- if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
2255
+ if (probeEditorComposeState(cfg).state !== "running") {
2108
2256
  throw new Error(
2109
2257
  `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
2110
2258
  `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
@@ -3461,8 +3609,9 @@ function fleetInstancePrefix() {
3461
3609
  * scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
3462
3610
  * result so callers decide fatality — Add treats a non-zero as a loud warning
3463
3611
  * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
3464
- * teardown still removes the instance. */
3465
- function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
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) {
3466
3615
  const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3467
3616
  const inv = vmIngressInvocation({
3468
3617
  scriptPath,
@@ -3474,6 +3623,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
3474
3623
  appPort,
3475
3624
  instancePrefix: fleetInstancePrefix(),
3476
3625
  slug,
3626
+ vmAccess,
3477
3627
  });
3478
3628
  return spawnSync(inv.command, inv.args, {
3479
3629
  stdio: "inherit",
@@ -3504,17 +3654,77 @@ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
3504
3654
  });
3505
3655
  }
3506
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
+
3507
3708
  /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
3508
3709
  * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
3509
3710
  * (the VM is reachable operator-side over SSH regardless), so on failure it
3510
3711
  * prints the exact re-run command rather than throwing. No-op (and silent) in
3511
- * legacy quick-tunnel mode or for a non-fleet client project. */
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. */
3512
3719
  function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
3513
3720
  const plan = ingressWiringPlan(cfg, env);
3514
- if (!plan.wire) return;
3721
+ if (!plan.wire) return [];
3515
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);
3516
3726
  console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
3517
- const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
3727
+ const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
3518
3728
  if (res.status === 0) {
3519
3729
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3520
3730
  } else {
@@ -3536,6 +3746,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
3536
3746
  `(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
3537
3747
  );
3538
3748
  }
3749
+ return vmAccess;
3539
3750
  }
3540
3751
 
3541
3752
  /** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
@@ -3604,8 +3815,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
3604
3815
  * a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
3605
3816
  * command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
3606
3817
  * array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
3607
- * list (the clean-slate case). */
3608
- 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 = [] } = {}) {
3609
3827
  const desired = normalizeAccessList(cfg.vmAccess || []);
3610
3828
  let plan;
3611
3829
  try {
@@ -3622,11 +3840,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
3622
3840
  // never inaccessible-by-default (the Error-51 footgun). The default operator is
3623
3841
  // NOT persisted to the sidecar (it's a standing grant, not a per-VM
3624
3842
  // collaborator), so `desired` alone drives the sidecar + stale-revoke below.
3625
- const toGrant = withDefaultOperator(desired, resolveDefaultOperator({ env }));
3843
+ const toGrant = intendedVmPrincipals(cfg, env);
3626
3844
  const stale = accessFor(vmNumber);
3627
3845
  // Never revoke a principal we're about to (re-)grant — a prior tenant who was
3628
- // also the default operator must not be revoked-then-regranted for nothing.
3629
- const staleOnly = stale.filter((p) => !toGrant.includes(p));
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));
3630
3852
 
3631
3853
  if (plan.wire) {
3632
3854
  if (staleOnly.length > 0) {
@@ -3743,12 +3965,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3743
3965
  }
3744
3966
  if (!plan.wire) {
3745
3967
  // Nothing to converge — the no-op wire keeps the clean-slate invariant.
3746
- wireVmIngressOnAdd(cfg, appPort, env);
3747
- return { wired: true, reason: "no-wire" };
3748
- }
3749
-
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);
3750
3982
  const runUp =
3751
- 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));
3752
3985
  const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
3753
3986
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
3754
3987
  const now = deps.now || (() => Date.now());
@@ -3791,7 +4024,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3791
4024
  if (decision.done) {
3792
4025
  if (decision.wired) {
3793
4026
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3794
- return { wired: true, reason: "wired", attempts: attempt + 1 };
4027
+ return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
3795
4028
  }
3796
4029
  const rewire =
3797
4030
  `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
@@ -3808,7 +4041,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3808
4041
  `CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
3809
4042
  `within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
3810
4043
  );
3811
- return { wired: false, reason: "deadline", attempts: attempt + 1 };
4044
+ return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
3812
4045
  }
3813
4046
  attempt += 1;
3814
4047
  await sleep(decision.delayMs);
@@ -3837,8 +4070,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3837
4070
  if (!plan.wire) {
3838
4071
  // Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
3839
4072
  // sidecar write. Run them inline so the clean-slate invariant still holds.
3840
- wireVmIngressOnAdd(cfg, appPort, env);
3841
- applyVmAccessOnAdd(cfg, env);
4073
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4074
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
3842
4075
  return;
3843
4076
  }
3844
4077
  const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
@@ -3870,8 +4103,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3870
4103
  `running it inline instead.`,
3871
4104
  );
3872
4105
  try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
3873
- wireVmIngressOnAdd(cfg, appPort, env);
3874
- applyVmAccessOnAdd(cfg, env);
4106
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4107
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
3875
4108
  }
3876
4109
  }
3877
4110
 
@@ -3903,8 +4136,11 @@ async function cmdWireAddTail() {
3903
4136
  // Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
3904
4137
  // `up` left to the slow reconcile sweep — then the IAP access grant, in the
3905
4138
  // SAME order cmdUp ran them inline.
3906
- await convergeVmIngressOnAdd(cfg, appPort);
3907
- 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 });
3908
4144
  } finally {
3909
4145
  if (marker) {
3910
4146
  try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
@@ -4016,6 +4252,8 @@ async function cmdUp(args, projectDir = rootDir) {
4016
4252
  }
4017
4253
  }
4018
4254
 
4255
+ assertAddCanAuthorizeSomeone(cfg);
4256
+
4019
4257
  checkScriptDrift(cfg);
4020
4258
 
4021
4259
  await ensureFirewallRule(cfg);
@@ -4122,7 +4360,27 @@ async function cmdUp(args, projectDir = rootDir) {
4122
4360
  );
4123
4361
  }
4124
4362
  } catch (err) {
4125
- if (createdThisRun) {
4363
+ // `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
4364
+ // VM is in right now, then let the pure decision rule — see
4365
+ // abortTeardownDecision's doc comment for why each input is load-bearing.
4366
+ const state = probeAbortTeardownState(cfg, createdThisRun);
4367
+ const verdict = abortTeardownDecision({ createdThisRun, ...state });
4368
+ if (createdThisRun && verdict === "keep") {
4369
+ // Deliberately loud and specific. The old blanket "tearing down the VM
4370
+ // cloud:up created this run (no orphan left behind)" was actively
4371
+ // misleading here — it asserted no orphan while deleting a working VM.
4372
+ const why = state.probeInconclusive
4373
+ ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4374
+ : state.containerUp
4375
+ ? "its editor container is RUNNING — another run brought it up"
4376
+ : "it is in the fleet roster and visible to the dashboard";
4377
+ console.error(
4378
+ `✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
4379
+ ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
4380
+ ` If it really should go, remove it deliberately: npm run cloud:destroy`,
4381
+ );
4382
+ }
4383
+ if (verdict === "delete") {
4126
4384
  console.error(
4127
4385
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
4128
4386
  );
@@ -4304,14 +4562,7 @@ async function cmdStatus(args, projectDir = rootDir) {
4304
4562
  const imgStatus = String(imgProbe.stdout || "").trim() || "?";
4305
4563
  console.error(` image: ${imgStatus}`);
4306
4564
 
4307
- const repoName = resolveProject(cfg).name;
4308
- const containerProbe = runGcloud(
4309
- buildGcloudArgs("ssh-probe", cfg, {
4310
- command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
4311
- }),
4312
- { stdio: ["ignore", "pipe", "ignore"] },
4313
- );
4314
- const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
4565
+ const containerStatus = probeEditorComposeState(cfg).state || "missing";
4315
4566
  console.error(` editor container: ${containerStatus}`);
4316
4567
 
4317
4568
  const appPort = await readVmAppPort(cfg);
@@ -5058,6 +5309,9 @@ module.exports = {
5058
5309
  assertAgentCliInstalled,
5059
5310
  probeContainerRunning,
5060
5311
  probeContainerState,
5312
+ waitForEditorComposeRunning,
5313
+ abortTeardownDecision,
5314
+ probeAbortTeardownState,
5061
5315
  probeAgentBinaryInstalled,
5062
5316
  waitForContainerRunning,
5063
5317
  KNOWN_FLAGS,
@@ -5157,6 +5411,8 @@ module.exports = {
5157
5411
  ingressWiringPlan,
5158
5412
  fleetInstancePrefix,
5159
5413
  runVmIngress,
5414
+ intendedVmPrincipals,
5415
+ assertAddCanAuthorizeSomeone,
5160
5416
  wireVmIngressOnAdd,
5161
5417
  probeVmIngressVerdict,
5162
5418
  convergeVmIngressOnAdd,
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // npm/compose-conflict.js — classify a failed `docker compose up -d` on a fleet
4
+ // VM, so `cloud:up` can tell "another run beat me to this container" apart from
5
+ // "provisioning genuinely broke".
6
+ //
7
+ // Why this exists: on 2026-08-07 two concurrent `cloud:up` runs raced to start
8
+ // the editor container on one fresh VM. The loser's `up -d` exited non-zero with
9
+ // the docker daemon's container-name conflict, `cloud.js` flattened that into a
10
+ // generic "docker compose up -d failed on the VM", and the abort handler
11
+ // escalated it all the way to deleting a VM the winner had already brought up
12
+ // and rostered. A name conflict is the opposite of a failure — it is proof the
13
+ // shared goal (container running) was reached by someone else.
14
+ //
15
+ // Matching a daemon error string is exactly the kind of thing that rots when
16
+ // docker rephrases it, so it lives here as a named, unit-tested function with
17
+ // the real observed message pinned as a fixture — not as an inline regex at the
18
+ // call site.
19
+
20
+ // The stable fragments of the daemon's conflict message. The container id and
21
+ // name vary per run and docker's exact phrasing has changed across versions, so
22
+ // we require all three fragments rather than the whole sentence. Observed
23
+ // verbatim in the VM-4 add log:
24
+ //
25
+ // Error response from daemon: Conflict. The container name
26
+ // "/codeyam-editor-editor-1" is already in use by container "c9d448c6…".
27
+ // You have to remove (or rename) that container to be able to reuse that name.
28
+ //
29
+ // Compose emits it twice (once prefixed `service:editor:1`, once bare) and
30
+ // interleaves its own progress lines, so the match is substring-based over the
31
+ // whole captured stream rather than line- or anchor-based.
32
+ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
33
+
34
+ /** Classify the outcome of `docker compose up -d`.
35
+ *
36
+ * Returns one of:
37
+ * - `'ok'` — the command succeeded; there is no failure to classify. Guards
38
+ * against a caller that classifies unconditionally, so a zero status can
39
+ * never be read as a conflict.
40
+ * - `'name-conflict'` — the daemon refused because a container of that name
41
+ * already exists, i.e. a concurrent run already started it. The caller
42
+ * re-probes for a running container and converges instead of throwing.
43
+ * - `'failed'` — anything else. Existing behavior: a real provisioning
44
+ * failure.
45
+ *
46
+ * Pure: `{ status, stderr } → tag`, with no I/O, so the whole matrix is
47
+ * testable without a VM. `stderr` may be null/undefined (the pre-fix call site
48
+ * captured nothing), which classifies as `'failed'` — the conservative
49
+ * direction, since converging requires positive evidence of a conflict. */
50
+ function classifyComposeUpFailure({ status, stderr } = {}) {
51
+ if (status === 0) return 'ok';
52
+ const text = String(stderr == null ? '' : stderr);
53
+ if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
54
+ return 'name-conflict';
55
+ }
56
+ return 'failed';
57
+ }
58
+
59
+ module.exports = { classifyComposeUpFailure };