@codeyam-editor/codeyam-editor 0.1.0-staging.g6631ba2 → 0.1.0-staging.g83619a4

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 (61) hide show
  1. package/npm/cloud.js +729 -85
  2. package/npm/codesign-plan.js +68 -0
  3. package/npm/empty-project-dir.js +92 -7
  4. package/npm/fleet-add-failure.js +166 -0
  5. package/npm/fleet-auth-health.js +95 -0
  6. package/npm/fleet-build-data.js +42 -3
  7. package/npm/fleet-cleanup.js +92 -1
  8. package/npm/fleet-early-roster.js +87 -0
  9. package/npm/fleet-effect-poll.js +110 -0
  10. package/npm/fleet-env.js +86 -0
  11. package/npm/fleet-ghcr-secret.js +129 -0
  12. package/npm/fleet-git-maintenance.js +142 -0
  13. package/npm/fleet-host-op-limiter.js +68 -0
  14. package/npm/fleet-iap-token.js +165 -0
  15. package/npm/fleet-plans.js +81 -3
  16. package/npm/fleet-pool.js +75 -16
  17. package/npm/fleet-prewarm.js +33 -11
  18. package/npm/fleet-project-drift.js +58 -0
  19. package/npm/fleet-public-url.js +34 -0
  20. package/npm/fleet-queue.js +58 -1
  21. package/npm/fleet-session-token-cache.js +65 -0
  22. package/npm/fleet-status-refs.js +96 -0
  23. package/npm/fleet-strand.js +39 -8
  24. package/npm/fleet-vm-access.js +75 -7
  25. package/npm/fleet-vm-probe-target.js +187 -0
  26. package/npm/fleet-vm-readiness.js +21 -4
  27. package/npm/ingress-invoke.js +240 -13
  28. package/npm/launch-config.js +115 -2
  29. package/npm/scenario-check.js +282 -10
  30. package/npm/scenario-handlers.js +85 -27
  31. package/npm/scenario-interactivity.js +145 -6
  32. package/npm/scenario-issues.js +13 -1
  33. package/npm/scenario-mocks.js +129 -26
  34. package/npm/scenario-playwright.js +286 -12
  35. package/npm/utils.js +37 -3
  36. package/package.json +5 -5
  37. package/ui/dist/assets/{ContainerInspector-B3vO6op1.js → ContainerInspector-Di2c_yrg.js} +1 -1
  38. package/ui/dist/assets/EditorShell-BBjfN0wn.js +252 -0
  39. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  40. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  41. package/ui/dist/assets/ProjectLauncherScreen-C5jktR8F.js +1 -0
  42. package/ui/dist/assets/ScenarioDataPanel-BlYKgvU_.js +5 -0
  43. package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-6-39HCFL.js} +1 -1
  44. package/ui/dist/assets/ScenarioRenderer-sh_-nNw8.js +21 -0
  45. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  46. package/ui/dist/assets/{index-B14nyUpe.js → index-3Jd_sXhk.js} +4 -4
  47. package/ui/dist/assets/index-U81w-IPK.css +1 -0
  48. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  49. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  50. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  51. package/ui/dist/index.html +4 -4
  52. package/ui/dist/open-source/codeyam-counter.png +0 -0
  53. package/ui/dist/open-source/el-carot.png +0 -0
  54. package/ui/dist/open-source/tabcommand.png +0 -0
  55. package/ui/dist/assets/EditorShell-CndrQhoL.js +0 -191
  56. package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
  57. package/ui/dist/assets/ProjectLauncherScreen-Bq8CJo9M.js +0 -1
  58. package/ui/dist/assets/ScenarioDataPanel-DIAw2DpS.js +0 -5
  59. package/ui/dist/assets/ScenarioRenderer-rbCbaiLe.js +0 -21
  60. package/ui/dist/assets/index-DK-ZOfVw.css +0 -1
  61. 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");
@@ -75,12 +76,16 @@ const {
75
76
  regionFromZone,
76
77
  vmIngressInvocation,
77
78
  vmIngressStatusInvocation,
79
+ vmIngressGrantAccessInvocation,
78
80
  parseIngressStatusVerdict,
79
81
  parseIngressTeardownVerdict,
80
82
  selectIngressReconcileTargets,
83
+ selectIngressGrantOnlyTargets,
81
84
  selectOrphanIngressTargets,
82
85
  classifyOrphanDownResult,
83
86
  backendServicesListInvocation,
87
+ negListInvocation,
88
+ instancesListInvocation,
84
89
  ingressConvergeDecision,
85
90
  detachedAddTailInvocation,
86
91
  ingressInflightMarkerPath,
@@ -88,6 +93,11 @@ const {
88
93
  iapAccessInvocation,
89
94
  sharedIngressInvocation,
90
95
  backendServiceTimeoutInvocation,
96
+ FLEET_FIREWALL_RULE_NAMES,
97
+ fleetFirewallDescribeInvocation,
98
+ fleetFirewallConvergeInvocation,
99
+ firewallNeedsConverge,
100
+ firewallAdmitsPort,
91
101
  } = require("./ingress-invoke");
92
102
  // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
93
103
  // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
@@ -157,14 +167,34 @@ const KNOWN_FLAGS = [
157
167
  // default is loud — a tokenless VM cannot sync plan claims and must never
158
168
  // report healthy silently. Use only when push genuinely isn't wanted.
159
169
  "allow-no-git-credentials",
170
+ // Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
171
+ // so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
172
+ // cloud:up …`; absent = strict non-admin (the safe default). Survives
173
+ // re-provision because it rides the compose-render substitution, unlike a
174
+ // manual VM compose edit.
175
+ "admin",
176
+ // GCE machine type for the VM (e.g. n2-standard-4, e2-standard-4). Persisted
177
+ // in cloud.json so a later bare `cloud:up` keeps it. Defaults to
178
+ // DEFAULT_FLEET_MACHINE_TYPE. The whole point of exposing this is fleet
179
+ // scale-out: the region's E2_CPUS quota (72 in us-central1) caps the fleet at
180
+ // ~14 e2-standard-4 VMs, while N2_CPUS/N2D_CPUS (200, unused) leave room for
181
+ // 30 — so the fleet defaults to N2 and this flag lets a caller override.
182
+ "machine-type",
160
183
  ];
161
184
 
162
185
  // Flags that take no value (presence = true). parseFlags must not demand a
163
186
  // value for these or `--no-tunnel` would error "requires a value".
164
- const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
187
+ const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
165
188
 
166
189
  const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
167
190
  const INSTANCE_TAG = "codeyam-editor";
191
+ // Default GCE machine type for fleet VMs. N2 (not E2) because the fleet must
192
+ // reach 30 VMs: us-central1 E2_CPUS quota is 72, so 30 × e2-standard-4 (120
193
+ // vCPU) + the operator host exceed it and cap the fleet at ~14 regardless of
194
+ // code — while N2_CPUS/N2D_CPUS are 200 and unused, leaving headroom for 30 ×
195
+ // n2-standard-4. Override per-VM with `--machine-type`. See the vm--scale-
196
+ // fleet-to-30-vms plan for the measured quota rationale.
197
+ const DEFAULT_FLEET_MACHINE_TYPE = "n2-standard-4";
168
198
  const EDITOR_PORT = 14199;
169
199
  const VITE_PORT = 5173;
170
200
 
@@ -548,6 +578,32 @@ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
548
578
  return LOOPBACK_ORIGIN_RE.test(origin);
549
579
  }
550
580
 
581
+ /** Decide whether a VM is presenting as HEALTHY while its Live Preview is
582
+ * silently dead — the VM-9 failure mode. The heal
583
+ * (`reconcilePreviewOriginToSlugHost`) fixes the mechanism; this predicate is
584
+ * the smoke detector that catches a FUTURE mechanism gap (a new stack shape, a
585
+ * new provisioning path) by observation instead of by a user noticing a blank
586
+ * iframe days later.
587
+ *
588
+ * Input is the editor's own `/api/config` payload — the effective truth table
589
+ * (`previewSameOriginSafe` + the resolved `previewOrigin`), not a
590
+ * re-derivation from stack.json over ssh. True iff the stack is NOT
591
+ * same-origin-safe AND its effective previewOrigin is one a remote browser can
592
+ * never load (the `/__codeyam_preview` sentinel, any relative path, or a
593
+ * loopback origin) — i.e. exactly `isWrongModePreviewOrigin`'s wrong-mode
594
+ * condition, evaluated against the live config rather than editor.local.json.
595
+ * Composing the two keeps ONE definition of "wrong-mode origin" in this file.
596
+ *
597
+ * Deliberately conservative: a missing/unparseable payload, an absent
598
+ * `previewSameOriginSafe`, or a legitimate same-origin-safe stack on the
599
+ * subpath all return false. Absence of evidence is never a false alarm — the
600
+ * caller renders "unknown" instead. Pure. */
601
+ function isSilentlyBrokenPreview(apiConfig) {
602
+ if (!apiConfig || typeof apiConfig !== "object") return false;
603
+ if (apiConfig.previewSameOriginSafe !== false) return false;
604
+ return isWrongModePreviewOrigin(apiConfig, false);
605
+ }
606
+
551
607
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
552
608
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
553
609
  function readCloudflaredUrls() {
@@ -745,6 +801,28 @@ function buildEditorLocalEnvPatch(localEnv, agent) {
745
801
  return { provider: agent, env: { [varName]: value } };
746
802
  }
747
803
 
804
+ /** Build the editor.local.json patch that seeds a GREENFIELD project's
805
+ * `projectTitle` so its live identity resolves to the real project name from
806
+ * first boot. A greenfield project (`--new-project <name>`, no clientRepo) has
807
+ * no origin remote and is bind-mounted at /workspace on the VM, so the editor's
808
+ * identity resolver would otherwise fall back to the bare mount basename
809
+ * "workspace" — which the fleet dashboard then reads as a permanent false
810
+ * project-dir-drift. Seeding `projectTitle` (deep-merged over editor.json on the
811
+ * VM) makes `project_identity::read` prefer the real name.
812
+ *
813
+ * Returns `null` for a clone (a repo Add already labels from its origin
814
+ * basename, and re-seeding a clone's title would be wrong) or when no
815
+ * greenfield name is available, so the caller can no-op without an SSH round
816
+ * trip. Pure — mirrors buildEditorLocalEnvPatch so the decision is unit-tested
817
+ * without a live VM. */
818
+ function buildGreenfieldTitlePatch(cfg = {}) {
819
+ const p = resolveProject(cfg);
820
+ if (!p.greenfield) return null;
821
+ const name = typeof p.name === 'string' ? p.name.trim() : '';
822
+ if (!name) return null;
823
+ return { projectTitle: name };
824
+ }
825
+
748
826
  /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
749
827
  * line) and return the editor service's `State` string, or null when the
750
828
  * service is absent / output is unparseable. Two callers shared an identical
@@ -787,7 +865,7 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
787
865
  return [
788
866
  "compute", "instances", "create", instanceName,
789
867
  "--project", project, "--zone", zone,
790
- "--machine-type=e2-standard-4",
868
+ `--machine-type=${cfg.machineType || DEFAULT_FLEET_MACHINE_TYPE}`,
791
869
  "--image-family=debian-12",
792
870
  "--image-project=debian-cloud",
793
871
  "--boot-disk-size=100GB",
@@ -1494,6 +1572,43 @@ function bootstrapLogTail(cfg, lines = 40) {
1494
1572
  return String(r.stdout || "").trim() || "(log unavailable)";
1495
1573
  }
1496
1574
 
1575
+ /** Assemble the extra provision-time env forwarded into the bootstrap shell,
1576
+ * where it feeds the compose-render substitution. Currently just the admin
1577
+ * toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
1578
+ * editor-server process env (handlers.rs::admin_enabled, strict literal "1").
1579
+ * The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
1580
+ * (which lands as cfg.admin); anything else forwards "" so a normal launch —
1581
+ * and a re-provision without the flag — is strict non-admin. Pure over its
1582
+ * inputs so cloud.test.js can pin it without touching gcloud. */
1583
+ function buildProvisionEnv(env = process.env, flags = {}) {
1584
+ const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
1585
+ return { CODEYAM_ADMIN: admin ? "1" : "" };
1586
+ }
1587
+
1588
+ /** Build the one-shot SSH shell command that reconciles a running VM's admin
1589
+ * mode without a full re-provision: rewrite the rendered compose's
1590
+ * CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
1591
+ * VM whose compose predates the placeholder — then recreate the editor service
1592
+ * so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
1593
+ * normalizes), so inlining it into sed is safe. Pure/string-only so
1594
+ * cloud.test.js can assert its shape without SSHing. */
1595
+ function buildReconcileAdminCommand(repoName, value) {
1596
+ const f = "docker-compose.cloud.yml";
1597
+ const entry = ` - CODEYAM_ADMIN=${value}`;
1598
+ // Capture only the leading indentation (\1) and re-emit the literal key, so a
1599
+ // value that starts with a digit (e.g. "1") never lands right after the
1600
+ // backreference — `\11` would be read by sed as group 11, not `\1` + "1".
1601
+ return (
1602
+ `cd ~/projects/${repoName} && ` +
1603
+ `if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
1604
+ `sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
1605
+ `else ` +
1606
+ `sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
1607
+ `fi && ` +
1608
+ `docker compose -f ${f} up -d editor`
1609
+ );
1610
+ }
1611
+
1497
1612
  async function ensureProjectBootstrapped(cfg) {
1498
1613
  const repoName = resolveProject(cfg).name;
1499
1614
  console.error(`Checking project bootstrap state for ${repoName}...`);
@@ -1547,12 +1662,18 @@ async function ensureProjectBootstrapped(cfg) {
1547
1662
  const ingressEnv = {};
1548
1663
  const ingressPlan = ingressWiringPlan(cfg);
1549
1664
  if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
1665
+ // Forward the admin-mode toggle (Home-tab Setup door) into the compose
1666
+ // render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
1667
+ // carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
1668
+ // non-admin, so this leaves the normal launch and re-provision unchanged.
1669
+ const provisionEnv = buildProvisionEnv(process.env, cfg);
1550
1670
  const command = sshScriptCommand(scriptBody, scriptArgs, {
1551
1671
  GITHUB_TOKEN: resolveGithubToken(),
1552
1672
  CY_GIT_USER_NAME: laptopGit.name,
1553
1673
  CY_GIT_USER_EMAIL: laptopGit.email,
1554
1674
  ...ingressEnv,
1555
1675
  ...forwardEnv,
1676
+ ...provisionEnv,
1556
1677
  });
1557
1678
  const result = runGcloud(
1558
1679
  buildGcloudArgs("ssh-script", cfg, { command }),
@@ -1761,49 +1882,113 @@ function resolveGhcrPushToken() {
1761
1882
  return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1762
1883
  }
1763
1884
 
1885
+ // Registry manifest media types we accept when probing image presence over the
1886
+ // GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
1887
+ // multi-arch index answers 200 the same as a single-arch manifest.
1888
+ const MANIFEST_ACCEPT = [
1889
+ "application/vnd.docker.distribution.manifest.v2+json",
1890
+ "application/vnd.docker.distribution.manifest.list.v2+json",
1891
+ "application/vnd.oci.image.manifest.v1+json",
1892
+ "application/vnd.oci.image.index.v1+json",
1893
+ ].join(", ");
1894
+ const GHCR_PROBE_TIMEOUT_MS = 15000;
1895
+
1896
+ /** Default HTTPS client for the registry probe: a GET that resolves to
1897
+ * `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
1898
+ * to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
1899
+ * `httpImpl` option so probeUsableEditorImage is unit-testable with no network
1900
+ * (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
1901
+ * injectable so the response / error / timeout mapping is itself unit-testable
1902
+ * without a real socket. */
1903
+ function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
1904
+ return new Promise((resolve) => {
1905
+ const req = requestImpl(
1906
+ { hostname, path: reqPath, method: "GET", headers },
1907
+ (res) => {
1908
+ res.resume(); // drain the body so the socket is freed
1909
+ res.on("end", () => resolve({ statusCode: res.statusCode }));
1910
+ },
1911
+ );
1912
+ req.on("error", () => resolve({ statusCode: 0 }));
1913
+ req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
1914
+ req.end();
1915
+ });
1916
+ }
1917
+
1918
+ /** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
1919
+ * API — no docker daemon, so a docker-less operator host can still determine
1920
+ * presence. Returns true (200 present), false (404 absent), or null (auth /
1921
+ * network / malformed-ref → can't tell absent from forbidden). */
1922
+ async function probeManifestRef(fullRef, bearer, httpGet) {
1923
+ const colon = fullRef.lastIndexOf(":");
1924
+ if (colon < 0) return null;
1925
+ const ref = fullRef.slice(0, colon); // host/repo
1926
+ const tag = fullRef.slice(colon + 1); // slug-latest
1927
+ const slash = ref.indexOf("/");
1928
+ if (slash < 0) return null;
1929
+ const hostname = ref.slice(0, slash); // ghcr.io
1930
+ const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
1931
+ let res;
1932
+ try {
1933
+ res = await httpGet({
1934
+ hostname,
1935
+ path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
1936
+ headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
1937
+ });
1938
+ } catch (_e) {
1939
+ return null;
1940
+ }
1941
+ const code = res && res.statusCode;
1942
+ if (code === 200) return true;
1943
+ if (code === 404) return false;
1944
+ return null; // 401 / 403 / 5xx / 0 (network) → undetermined
1945
+ }
1946
+
1764
1947
  /** Read-only probe: does a usable prebuilt editor image already exist on the
1765
1948
  * registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
1766
1949
  * 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.
1950
+ * npm/fleet-prewarm.js::decideBatchPrewarm).
1951
+ *
1952
+ * Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
1953
+ * HEAD-equivalent that reads the manifest without pulling the multi-GB image)
1954
+ * instead of `docker login` / `docker manifest inspect`. This is the root fix
1955
+ * for the docker-less GCE operator host: the old docker path errored on the
1956
+ * first `docker login` spawn (the control plane has no docker daemon) and so
1957
+ * returned `null` forever, which decideBatchPrewarm read as "fan out" — the
1958
+ * image was therefore never published and every cold add source-built. Over
1959
+ * HTTP the probe returns a DEFINITIVE verdict with no daemon.
1769
1960
  *
1770
1961
  * Returns:
1771
1962
  * true — at least one of the usable tags exists (a cold add can pull; no pacer)
1772
1963
  * 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.
1964
+ * null — undetermined: no read token, or an auth/network error that can't be
1965
+ * distinguished from "absent".
1777
1966
  *
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 } = {}) {
1967
+ * GHCR's private packages require auth; without a read token we can't tell
1968
+ * absent from forbidden, so → null. The read token is sent as the registry
1969
+ * bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
1970
+ * exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
1971
+ * `spawnImpl` option is still accepted (and ignored) for backward-compat — the
1972
+ * HTTP happy path needs no spawn. */
1973
+ async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
1782
1974
  const fp = require("./fleet-prewarm");
1783
- const spawn = spawnImpl || spawnSync;
1784
1975
  const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
1785
1976
  const readToken = token || resolveGhcrToken();
1786
1977
  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;
1978
+ const httpGet = httpImpl || httpsManifestGet;
1979
+ // GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
1980
+ // round-trip needed.
1981
+ const bearer = Buffer.from(readToken).toString("base64");
1795
1982
  const tags = fp.usableImageTags({ registry: reg, branch });
1796
1983
  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.
1984
+ for (const fullRef of tags) {
1985
+ const verdict = await probeManifestRef(fullRef, bearer, httpGet);
1986
+ if (verdict === true) return true; // a usable image exists
1987
+ if (verdict === null) allClean = false; // this tag's probe was inconclusive
1988
+ // verdict === false → this tag is absent; keep checking the rest
1989
+ }
1990
+ // Every tag probed cleanly and none existed → definitively absent. If any probe
1991
+ // was inconclusive we can't claim absence → undetermined.
1807
1992
  return allClean ? false : null;
1808
1993
  }
1809
1994
 
@@ -1840,6 +2025,15 @@ function classifyGitRemoteError(stderr) {
1840
2025
  ) {
1841
2026
  return "network";
1842
2027
  }
2028
+ // A greenfield project (`--new-project` scaffold) has no origin remote and an
2029
+ // unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
2030
+ // git repository". No token would fix this — there is simply nothing to push
2031
+ // yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
2032
+ // match narrow: `repository not found` is a genuine access failure against a
2033
+ // real remote and must NOT be swallowed here.
2034
+ if (lower.includes("does not appear to be a git repository")) {
2035
+ return "no-remote";
2036
+ }
1843
2037
  return "other";
1844
2038
  }
1845
2039
 
@@ -1877,6 +2071,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
1877
2071
  reason: "git binary failed to launch — not a credential gap, not re-seeding",
1878
2072
  };
1879
2073
  }
2074
+ if (category === "no-remote") {
2075
+ return {
2076
+ heal: false,
2077
+ category,
2078
+ reason:
2079
+ "no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
2080
+ };
2081
+ }
1880
2082
  return {
1881
2083
  heal: true,
1882
2084
  category,
@@ -1894,6 +2096,16 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
1894
2096
  };
1895
2097
  }
1896
2098
 
2099
+ /** The single GitHub credential line format shared by every seed path: the
2100
+ * host staging file, the durable on-disk `store` fallback, and the live
2101
+ * cache-daemon approve all expect `https://x-access-token:<token>@github.com`
2102
+ * (git's credential-URL form, matched by both the entrypoint parser and
2103
+ * git-credential-store). Centralized so the `x-access-token` shape has one
2104
+ * source of truth instead of being re-typed at each call site. */
2105
+ function githubCredentialLine(token) {
2106
+ return `https://x-access-token:${token}@github.com`;
2107
+ }
2108
+
1897
2109
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1898
2110
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1899
2111
  * without this, claude / codex / gemini inside the container can clone the
@@ -1919,21 +2131,28 @@ async function seedGithubCredentials(cfg, opts = {}) {
1919
2131
  console.error(
1920
2132
  `Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
1921
2133
  );
1922
- const credLine = `https://x-access-token:${token}@github.com`;
2134
+ const credLine = githubCredentialLine(token);
1923
2135
  // Quote the token through one shell layer (gcloud ssh --command); the
1924
2136
  // value is base64'd to keep it out of the shell history.
1925
2137
  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
2138
+ // Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
2139
+ // (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
1928
2140
  // unreadable decode must never clobber an existing good file — the entrypoint
1929
2141
  // reads this file on every boot, and a silent empty write is exactly how a
1930
2142
  // 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
2143
+ // trailing `echo OK` runs only on success, so an empty write can't mask
1932
2144
  // success with a `&& echo` (the failure shape seen on the Claude creds sinks).
2145
+ //
2146
+ // Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
2147
+ // bind-mounted read-only into the running container, and `mv` mints a NEW
2148
+ // inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
2149
+ // never reached the running container until a full recreate. Truncating the
2150
+ // existing file in place keeps its inode, so the durable write is visible to
2151
+ // the container immediately. Then remove the temp.
1933
2152
  const remoteCmd =
1934
2153
  `f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
1935
2154
  `echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
1936
- `if [ -s "$t" ]; then mv "$t" "$f" && echo OK; ` +
2155
+ `if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
1937
2156
  `else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
1938
2157
  const result = runGcloud(
1939
2158
  buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
@@ -1944,26 +2163,76 @@ async function seedGithubCredentials(cfg, opts = {}) {
1944
2163
  }
1945
2164
  }
1946
2165
 
2166
+ /** Sentinel the remote read prints when it cannot find an `editor.json` under
2167
+ * any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
2168
+ * project" (a real miss worth warning about) from "the project is scaffolded
2169
+ * but declares no port" (legitimately a `VITE_PORT` default). Without the
2170
+ * distinction both collapse to `{}` and the miss is silent. */
2171
+ const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
2172
+
2173
+ /** Build the remote shell command that locates the project's `.codeyam` dir and
2174
+ * cats its two config files in one round-trip.
2175
+ *
2176
+ * Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
2177
+ * directory is not guaranteed to be named after the repo (a renamed repo, a
2178
+ * manually-cloned dir, a fleet slug), and when the guess missed, the old code
2179
+ * silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
2180
+ * path is still tried FIRST so a multi-project VM resolves deterministically;
2181
+ * the glob is the fallback. When nothing matches, print the sentinel so the
2182
+ * caller can warn instead of guessing. Pure string builder; no I/O. */
2183
+ function buildVmAppPortReadCommand(repoName) {
2184
+ return (
2185
+ `d=""; ` +
2186
+ `for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
2187
+ `if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
2188
+ `if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
2189
+ `cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
2190
+ `echo '<<<LOCAL>>>'; ` +
2191
+ `cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
2192
+ );
2193
+ }
2194
+
1947
2195
  /** Best-effort read of the VM's resolved app dev-server port so cloud:up's
1948
2196
  * laptop `-L` forward, readiness probe, and previewOrigin seed all target the
1949
2197
  * port the app actually serves on instead of a hardcoded 5173. Cats the VM's
1950
2198
  * 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. */
2199
+ * `resolveConfiguredAppPort`.
2200
+ *
2201
+ * Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
2202
+ * wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
2203
+ * and if the preview ends up on a dedicated origin the viewer gets
2204
+ * "localhost refused to connect" with nothing pointing at the cause. So an SSH
2205
+ * failure or a VM with no scaffolded project warns on stderr naming that
2206
+ * symptom. A scaffolded project that simply declares no port is NOT a miss —
2207
+ * `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
2208
+ * on) and stays quiet. */
1954
2209
  async function readVmAppPort(cfg) {
1955
2210
  const repoName = resolveProject(cfg).name;
1956
- const base = `~/projects/${repoName}/.codeyam`;
1957
2211
  const read = runGcloud(
1958
2212
  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 '{}'`,
2213
+ command: buildVmAppPortReadCommand(repoName),
1963
2214
  }),
1964
2215
  { stdio: ["ignore", "pipe", "ignore"] },
1965
2216
  );
1966
- if (read.status !== 0 || typeof read.stdout !== "string") return VITE_PORT;
2217
+ if (read.status !== 0 || typeof read.stdout !== "string") {
2218
+ console.error(
2219
+ `[cloud] WARNING: could not read the VM's app port over SSH — ` +
2220
+ `falling back to ${VITE_PORT}. If the app does not serve on ` +
2221
+ `${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
2222
+ `Live Preview will show "localhost refused to connect".`,
2223
+ );
2224
+ return VITE_PORT;
2225
+ }
2226
+ if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
2227
+ console.error(
2228
+ `[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
2229
+ `VM (looked for '${repoName}', then any project dir) — falling back to ` +
2230
+ `${VITE_PORT}. If the project is scaffolded under a different name and ` +
2231
+ `serves on another port, the port-forward targets the wrong port and ` +
2232
+ `the Live Preview will show "localhost refused to connect".`,
2233
+ );
2234
+ return VITE_PORT;
2235
+ }
1967
2236
  const parse = (s) => {
1968
2237
  try {
1969
2238
  const v = JSON.parse(s);
@@ -2287,6 +2556,66 @@ async function seedAgentApiKey(cfg) {
2287
2556
  // vm--no-cli-install-before-container-started removed; do not re-add it.
2288
2557
  }
2289
2558
 
2559
+ /** Pure: deep-merge `patch` over the VM's current editor.local.json content and
2560
+ * return the base64 of the pretty-printed result, ready for
2561
+ * buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
2562
+ * remote file (may be undefined, empty, or corrupt) — anything that doesn't
2563
+ * parse to a plain object is treated as `{}`, because overwriting a corrupt
2564
+ * override file with a known-good one is safer than refusing to seed. Extracted
2565
+ * so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
2566
+ * parse+merge+encode is unit-tested without a live VM. */
2567
+ function mergeEditorLocalPatchB64(currentRaw, patch) {
2568
+ let current = {};
2569
+ try {
2570
+ const parsed = JSON.parse(currentRaw);
2571
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2572
+ current = parsed;
2573
+ }
2574
+ } catch {
2575
+ // Unparseable / missing remote content → start from {}.
2576
+ }
2577
+ const merged = deepMergeJson(current, patch);
2578
+ return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
2579
+ }
2580
+
2581
+ /** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
2582
+ * BEFORE compose up, so the editor caches the real project name at startup and
2583
+ * the fleet dashboard renders the correct card label with no false
2584
+ * project-dir-drift. Like seedAgentApiKey, this MUST run before
2585
+ * ensureEditorContainerUp — the editor reads its config once at startup.
2586
+ *
2587
+ * No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
2588
+ * from its origin basename, so there is nothing to seed and no SSH round-trip
2589
+ * is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
2590
+ * converging a VM provisioned before this fix onto the correct identity without
2591
+ * a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
2592
+ * mergeEditorLocalPatchB64 helper. */
2593
+ async function seedGreenfieldProjectTitle(cfg) {
2594
+ const patch = buildGreenfieldTitlePatch(cfg);
2595
+ if (!patch) return; // clone (or no greenfield name) — nothing to seed
2596
+
2597
+ const repoName = resolveProject(cfg).name;
2598
+ const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
2599
+ console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
2600
+
2601
+ const read = runGcloud(
2602
+ buildGcloudArgs("ssh-probe", cfg, {
2603
+ command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
2604
+ }),
2605
+ { stdio: ["ignore", "pipe", "ignore"] },
2606
+ );
2607
+ const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
2608
+ const write = runGcloud(
2609
+ buildGcloudArgs("ssh-probe", cfg, {
2610
+ command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
2611
+ }),
2612
+ { stdio: ["ignore", "inherit", "inherit"] },
2613
+ );
2614
+ if (write.status !== 0) {
2615
+ throw new Error(`Failed to write ${remotePath} on VM`);
2616
+ }
2617
+ }
2618
+
2290
2619
  /** Tri-state probe of the editor container's running state on the VM host.
2291
2620
  * One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
2292
2621
  *
@@ -2549,6 +2878,42 @@ function streamedScriptMergeBase(editorBranch) {
2549
2878
  return mbResult.status === 0 ? mbResult.stdout.trim() : null;
2550
2879
  }
2551
2880
 
2881
+ /** Fetch `editorBranch` from origin into a REAL remote-tracking ref
2882
+ * (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
2883
+ *
2884
+ * Operator hosts are commonly shallow, single-branch clones that track only
2885
+ * `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
2886
+ * every `git show origin/<branch>:<path>` against it fails. Fetching through an
2887
+ * explicit refspec creates the ref, which keeps the drift verdict and the bytes
2888
+ * actually streamed derived from the same object. Falls back to a plain fetch
2889
+ * (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
2890
+ * can still read through FETCH_HEAD in that case. */
2891
+ function fetchStreamedScriptRef(editorBranch) {
2892
+ const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
2893
+ const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
2894
+ if (withRef.status === 0) return { ok: true, stderr: "" };
2895
+
2896
+ const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
2897
+ if (plain.status === 0) return { ok: true, stderr: "" };
2898
+ return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
2899
+ }
2900
+
2901
+ /** Resolve one revision of a streamed script from the fetched remote, trying the
2902
+ * remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
2903
+ * (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
2904
+ * streamed bytes resolve through the SAME ref-preference order — a verdict of
2905
+ * "stream remote" can therefore never disagree with what actually got streamed.
2906
+ * Returns `{ ok: false }` only when the blob is unreadable through both refs. */
2907
+ function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
2908
+ for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
2909
+ const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
2910
+ if (result.status === 0 && typeof result.stdout === "string") {
2911
+ return { ok: true, stdout: result.stdout, ref };
2912
+ }
2913
+ }
2914
+ return { ok: false, stdout: "", ref: null };
2915
+ }
2916
+
2552
2917
  /** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
2553
2918
  * its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
2554
2919
  * hash, and whether the working tree is dirty. Any git failure yields a null hash,
@@ -2560,11 +2925,8 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
2560
2925
  const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
2561
2926
  const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
2562
2927
 
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
- }
2928
+ const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
2929
+ const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
2568
2930
 
2569
2931
  let baseHash = null;
2570
2932
  if (mergeBase) {
@@ -2587,7 +2949,15 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
2587
2949
  * un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
2588
2950
  * when the operator authored local content the remote tip lacks (dirty edit or a
2589
2951
  * committed local-ahead change — developer mode) or when the remote is unreachable
2590
- * (offline fallback). `absScriptPath` is an absolute path under the repo root. */
2952
+ * (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
2953
+ *
2954
+ * A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
2955
+ * checkout the on-disk bytes are precisely the stale bytes the drift guard exists
2956
+ * to keep off the VM, so substituting them silently defeats the guard: the verdict
2957
+ * said "stream the reviewed remote tip" and the VM would instead receive the old
2958
+ * script, with only a `[Warning]` to show for it. When the remote blob cannot be
2959
+ * read through either ref this throws, aborting the add.
2960
+ * `absScriptPath` is an absolute path under the repo root. */
2591
2961
  function readStreamedScript(absScriptPath, cfg = {}) {
2592
2962
  const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
2593
2963
  const editorBranch = streamedScriptEditorBranch(cfg);
@@ -2596,15 +2966,18 @@ function readStreamedScript(absScriptPath, cfg = {}) {
2596
2966
 
2597
2967
  const { streamedScriptSource } = require("./script-drift");
2598
2968
  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)`);
2969
+ const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
2970
+ if (remote.ok) {
2971
+ console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
2602
2972
  return remote.stdout;
2603
2973
  }
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.`,
2974
+ // The facts said "the remote tip is the canonical source" but its bytes are
2975
+ // unreadable. Streaming the on-disk copy here would provision unreviewed stale
2976
+ // bytes under the banner of a successful add — fail loud instead.
2977
+ throw new Error(
2978
+ `could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
2979
+ `refusing to stream a possibly-stale on-disk copy. ` +
2980
+ `Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
2608
2981
  );
2609
2982
  }
2610
2983
  return fs.readFileSync(absScriptPath, "utf8");
@@ -2626,10 +2999,11 @@ function checkScriptDrift(cfg) {
2626
2999
 
2627
3000
  console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
2628
3001
 
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.`);
3002
+ // 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
3003
+ // single-branch clone that tracks only +refs/heads/main.
3004
+ const fetchResult = fetchStreamedScriptRef(editorBranch);
3005
+ if (!fetchResult.ok) {
3006
+ console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
2633
3007
  return;
2634
3008
  }
2635
3009
 
@@ -2742,6 +3116,29 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
2742
3116
  });
2743
3117
  }
2744
3118
 
3119
+ /** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
3120
+ * `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
3121
+ * `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
3122
+ * Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
3123
+ * VM whose backends already exist still (re-)grants the automation SA the IAP
3124
+ * accessor. Returns the spawnSync result so the caller logs granted vs failed;
3125
+ * best-effort + loud, never fatal. */
3126
+ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
3127
+ const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3128
+ const inv = vmIngressGrantAccessInvocation({
3129
+ scriptPath,
3130
+ vmNumber,
3131
+ domain,
3132
+ project: cfg.project,
3133
+ zone: cfg.zone,
3134
+ slug,
3135
+ });
3136
+ return spawnSync(inv.command, inv.args, {
3137
+ stdio: "inherit",
3138
+ env: { ...process.env, ...inv.env },
3139
+ });
3140
+ }
3141
+
2745
3142
  /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
2746
3143
  * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
2747
3144
  * (the VM is reachable operator-side over SSH regardless), so on failure it
@@ -3285,6 +3682,10 @@ async function cmdUp(args, projectDir = rootDir) {
3285
3682
  }
3286
3683
  }
3287
3684
  await seedAgentApiKey(cfg);
3685
+ // Greenfield only (no-op for a clone): seed projectTitle so the editor
3686
+ // resolves its identity to the real project name instead of the bare
3687
+ // /workspace mount basename — the source of the false project-dir-drift.
3688
+ await seedGreenfieldProjectTitle(cfg);
3288
3689
  await ensureEditorContainerUp(cfg);
3289
3690
  // Provision contract: a just-provisioned VM must actually reach GitHub.
3290
3691
  // Verify in-container git auth now that the container (and its credential
@@ -3647,9 +4048,26 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3647
4048
  });
3648
4049
  const targets = selectIngressReconcileTargets(statuses);
3649
4050
  const targetNums = new Set(targets.map((t) => t.vmNumber));
4051
+ // The complement of the re-wire loop: `wired` VMs whose backends already exist
4052
+ // so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
4053
+ // `up` is the only path that grants it, and it never runs for a wired VM. This
4054
+ // closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
4055
+ // fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
4056
+ const grantOnlyNums = new Set(
4057
+ selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
4058
+ );
3650
4059
  for (const s of statuses) {
3651
- if (!targetNums.has(s.vmNumber)) {
3652
- const label = s.slug || `vm-${s.vmNumber}`;
4060
+ if (targetNums.has(s.vmNumber)) continue;
4061
+ const label = s.slug || `vm-${s.vmNumber}`;
4062
+ if (grantOnlyNums.has(s.vmNumber)) {
4063
+ // Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
4064
+ // NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
4065
+ // bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
4066
+ const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
4067
+ console.error(
4068
+ ` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
4069
+ );
4070
+ } else {
3653
4071
  console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
3654
4072
  }
3655
4073
  }
@@ -3689,6 +4107,12 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3689
4107
  }
3690
4108
  }
3691
4109
 
4110
+ // Converge the shared fleet firewall, then surface any app port it still fails to
4111
+ // admit. Both ride this same boot + periodic cadence so the running fleet
4112
+ // self-heals without a hand-run bootstrap.
4113
+ const firewallState = convergeFleetFirewall({ cfg });
4114
+ await reportFirewallPortGaps({ cfg, roster, firewallState });
4115
+
3692
4116
  // Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
3693
4117
  // backend service whose VM number is gone from the roster is a destroyed VM's
3694
4118
  // leftover — cleaned here automatically with the verified `down`, so the
@@ -3697,22 +4121,131 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
3697
4121
  sweepOrphanIngress({ cfg, domain, roster, projectDir });
3698
4122
  }
3699
4123
 
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. */
4124
+ /** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
4125
+ * resources whose owning VM number is gone from the roster (a destroyed VM) and
4126
+ * has no in-flight add. Enumerates the live backend services AND zonal NEGs,
4127
+ * UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
4128
+ * backend service — the VM-5/6 case — still surfaces its VM as a candidate),
4129
+ * maps each name back to its VM number, subtracts the roster + in-flight adds +
4130
+ * live instances (the false-orphan guards), and runs the VERIFIED
4131
+ * `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
4132
+ * each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
4133
+ * never a silent leak and never a manual command. Emits a machine-readable
4134
+ * `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
4135
+ * orphan-attention banner; the summary carries `stillPresent` (VM numbers that
4136
+ * still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
4137
+ * banner for a VM that is now back in the roster or has no residual resources.
4138
+ * Best-effort per VM: one `down` failing never aborts the rest of the sweep.
4139
+ * Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
4140
+ * this pass (a live-but-unrostered VM must never be torn down) while the banner
4141
+ * reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
4142
+ * summary (null when the backend-service enumeration itself failed) — the same
4143
+ * shape it prints — so the flow is directly assertable. `deps` is injectable so
4144
+ * the enumerate → select → down flow is testable without gcloud. */
4145
+ /** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
4146
+ * port a project's app actually serves.
4147
+ *
4148
+ * A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
4149
+ * LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
4150
+ * outage — while the preview backend service still reads HEALTHY, because its
4151
+ * health check runs on the always-admitted editor port. The failure is therefore
4152
+ * invisible until someone opens the preview. Converging here (not only in the
4153
+ * hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
4154
+ * the running fleet.
4155
+ *
4156
+ * All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
4157
+ * to Google's LB + health-check source ranges and the fleet instance tag either
4158
+ * way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
4159
+ * without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
4160
+ * converged: describing only one silently no-ops on a fleet wired under the other.
4161
+ *
4162
+ * Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
4163
+ * protocols/ports, never a rule's source ranges or target tags, so this can never
4164
+ * change who the rule applies to. Best-effort throughout: a describe/update
4165
+ * failure logs and continues, never aborting the rest of the reconcile.
4166
+ *
4167
+ * Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
4168
+ * is the post-converge allowed value — so the flow is assertable without gcloud.
4169
+ * `deps.spawnSync` is injectable for the same reason.
4170
+ * See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
4171
+ function convergeFleetFirewall({ cfg } = {}, deps = {}) {
4172
+ const run = deps.spawnSync || spawnSync;
4173
+ const project = cfg && cfg.project;
4174
+ const state = [];
4175
+ for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
4176
+ const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
4177
+ const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
4178
+ // A non-zero describe means no rule under this name — expected, not an error:
4179
+ // only one of the candidate names is deployed on any given fleet.
4180
+ if (!dres || dres.status !== 0) continue;
4181
+ const current = ((dres.stdout || "") + "").trim();
4182
+ if (!firewallNeedsConverge(current)) {
4183
+ console.error(` firewall ${ruleName}: already all-tcp → ok`);
4184
+ state.push({ ruleName, rules: "tcp", converged: false });
4185
+ continue;
4186
+ }
4187
+ const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
4188
+ const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
4189
+ if (cres && cres.status === 0) {
4190
+ console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
4191
+ state.push({ ruleName, rules: "tcp", converged: true });
4192
+ } else {
4193
+ const tail = (
4194
+ (cres && (cres.stderr || (cres.error && cres.error.message))) || ""
4195
+ ).toString().trim().split("\n").pop() || "error";
4196
+ console.error(` firewall ${ruleName}: converge skipped (${tail})`);
4197
+ state.push({ ruleName, rules: current, converged: false });
4198
+ }
4199
+ }
4200
+ if (!state.length) {
4201
+ console.error(
4202
+ ` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
4203
+ " LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
4204
+ );
4205
+ }
4206
+ return state;
4207
+ }
4208
+
4209
+ /** Surface any rostered VM whose app port the fleet firewall does not admit — the
4210
+ * tripwire for a masked "HEALTHY but unreachable" preview.
4211
+ *
4212
+ * The preview backend service health-checks the fixed editor port, so a rule that
4213
+ * admits that port but not the app's real one reads HEALTHY while every real
4214
+ * request is dropped. That masking is what made the VM-6 outage invisible. Emits a
4215
+ * machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
4216
+ * `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
4217
+ *
4218
+ * Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
4219
+ * port, so the healthy path cannot produce a gap and skipping it avoids an SSH
4220
+ * round-trip per rostered VM for a check that cannot fail. In practice this fires
4221
+ * only if the converge itself failed (e.g. a permissions regression).
4222
+ *
4223
+ * Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
4224
+ * shape it prints — so the marker contract is directly assertable.
4225
+ * `deps.readVmAppPort` is injectable so tests never reach for SSH. */
4226
+ async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
4227
+ const readPort = deps.readVmAppPort || readVmAppPort;
4228
+ const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
4229
+ const gaps = [];
4230
+ if (!pinned.length) return gaps;
4231
+ for (const n of roster || []) {
4232
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
4233
+ const appPort = await readPort(perVmCfg);
4234
+ for (const f of pinned) {
4235
+ if (firewallAdmitsPort(f.rules, appPort)) continue;
4236
+ const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
4237
+ console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
4238
+ console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
4239
+ gaps.push(gap);
4240
+ }
4241
+ }
4242
+ return gaps;
4243
+ }
4244
+
3713
4245
  function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
3714
4246
  const gi = deps.googleIngress || require("./google-ingress.js");
3715
4247
  const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
4248
+ const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
3716
4249
  const run = deps.spawnSync || spawnSync;
3717
4250
 
3718
4251
  const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
@@ -3724,11 +4257,31 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3724
4257
  console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
3725
4258
  return null;
3726
4259
  }
3727
- const ingressVmNumbers = String(listRes.stdout || "")
4260
+ const besVmNumbers = String(listRes.stdout || "")
3728
4261
  .split("\n")
3729
4262
  .map((line) => gi.vmNumberFromResourceName(line.trim()))
3730
4263
  .filter((n) => n != null);
3731
4264
 
4265
+ // Also enumerate zonal NEGs and union their owners in: a partial teardown can
4266
+ // leave a `vm-<N>-<surface>-neg` behind after its backend service is already
4267
+ // gone, so a backend-service-only candidate set misses that residue. Best-effort
4268
+ // — a NEG-list failure never aborts the backend-service-driven sweep.
4269
+ let negVmNumbers = [];
4270
+ const negInv = negListInvocation({ project: cfg && cfg.project });
4271
+ const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
4272
+ if (negRes && negRes.status === 0) {
4273
+ negVmNumbers = String(negRes.stdout || "")
4274
+ .split("\n")
4275
+ .map((line) => gi.vmNumberFromResourceName(line.trim()))
4276
+ .filter((n) => n != null);
4277
+ } else {
4278
+ const tail = (
4279
+ (negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
4280
+ ).toString().trim().split("\n").pop() || "error";
4281
+ console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
4282
+ }
4283
+ const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
4284
+
3732
4285
  // A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
3733
4286
  // not yet rostered), is live — never an orphan.
3734
4287
  const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
@@ -3736,9 +4289,34 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3736
4289
  .listPendingJobs(pendingDir)
3737
4290
  .filter((j) => j.action === "add")
3738
4291
  .map((j) => j.n);
3739
- const orphans = selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds });
3740
4292
 
3741
- const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice() };
4293
+ // Defensive live-instance guard: never tear down a VM whose GCP instance is
4294
+ // actually running (a roster drift could leave a live VM unrostered; a teardown
4295
+ // that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
4296
+ // means the list couldn't be fetched — fail safe by skipping teardown this pass
4297
+ // (below) rather than risk a false teardown. The banner reconcile still runs.
4298
+ let liveVmNumbers = null;
4299
+ const instInv = instancesListInvocation({ project: cfg && cfg.project });
4300
+ const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
4301
+ if (instRes && instRes.status === 0) {
4302
+ liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
4303
+ } else {
4304
+ const tail = (
4305
+ (instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
4306
+ ).toString().trim().split("\n").pop() || "error";
4307
+ console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
4308
+ }
4309
+
4310
+ const orphans = liveVmNumbers === null
4311
+ ? []
4312
+ : selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
4313
+
4314
+ // stillPresent = every VM that owned an LB resource going in, minus the ones we
4315
+ // verifiably clean below. The dashboard clears a banner for any orphan VM not in
4316
+ // this set (or now back in the roster). Computed up front so the no-orphans and
4317
+ // fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
4318
+ // (re-rostered, so never a sweep target) with no teardown at all.
4319
+ const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
3742
4320
  if (!orphans.length) {
3743
4321
  console.error(" orphan sweep: no orphaned LB ingress found.");
3744
4322
  console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
@@ -3775,6 +4353,11 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
3775
4353
  console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
3776
4354
  }
3777
4355
  }
4356
+ // A verifiably-cleaned VM no longer owns any LB resource — drop it from
4357
+ // stillPresent so the dashboard clears its banner (failed VMs stay, keeping
4358
+ // their real attention item).
4359
+ const cleanedSet = new Set(summary.cleaned);
4360
+ summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
3778
4361
  console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
3779
4362
  return summary;
3780
4363
  }
@@ -3828,20 +4411,33 @@ async function probeGitCredentials(cfg) {
3828
4411
  /** Seed the LIVE in-memory git credential-cache daemon over its socket — the
3829
4412
  * same `git credential approve` shape the entrypoint uses
3830
4413
  * (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}. */
4414
+ * container restart. ALSO writes the container-local on-disk `store` fallback
4415
+ * file (the fill-only second helper baked into the image) directly, so a
4416
+ * reconcile leaves the VM durable against the cache daemon dying mid-life, not
4417
+ * just healthy for the moment. base64 keeps the token out of the shell history
4418
+ * across the gcloud-ssh → docker layers, and the store write is an in-place
4419
+ * `cat > file` truncate (chmod 600), never touched by git's approve path.
4420
+ * Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
4421
+ * {@link assertGitPushCredentials}. */
3834
4422
  async function seedLiveCredentialCache(cfg, token) {
3835
4423
  const repoName = resolveProject(cfg).name;
3836
4424
  const projectCd = `cd ~/projects/${repoName}`;
3837
4425
  const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
3838
4426
  const socket = "/run/codeyam/git-credential-cache.socket";
4427
+ const store = "/run/codeyam/git-credentials-store";
3839
4428
  const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
3840
4429
  const b64 = Buffer.from(approveStdin).toString("base64");
4430
+ // The store-file line is the same one-URL-per-line format as the host staging
4431
+ // file; the image's fill-only `store` helper reads it on a cache miss.
4432
+ const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
3841
4433
  const seedCmd =
3842
- `${projectCd} && echo ${b64} | base64 -d | ${composeExec} ` +
4434
+ `${projectCd} && ` +
4435
+ `echo ${b64} | base64 -d | ${composeExec} ` +
3843
4436
  `git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
3844
- `credential approve && echo APPROVED`;
4437
+ `credential approve && ` +
4438
+ `echo ${storeB64} | base64 -d | ${composeExec} ` +
4439
+ `bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
4440
+ `echo APPROVED`;
3845
4441
  const seed = runGcloud(
3846
4442
  buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
3847
4443
  { stdio: ["ignore", "pipe", "inherit"] },
@@ -3880,6 +4476,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
3880
4476
  );
3881
4477
  }
3882
4478
 
4479
+ /** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
4480
+ * WITHOUT a full re-provision. Resolves the desired value from this invocation
4481
+ * (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
4482
+ * CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
4483
+ * compose edit + `docker compose up -d editor` does today, so the durable admin
4484
+ * setting no longer requires a full re-provision to change. */
4485
+ async function cmdReconcileAdmin(args, projectDir = rootDir) {
4486
+ ensureGcloud();
4487
+ const flags = parseFlags(args);
4488
+ const cfg = loadCloudConfig(projectDir);
4489
+ if (!cfg) {
4490
+ throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
4491
+ }
4492
+ const desired =
4493
+ buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
4494
+ const repoName = resolveProject(cfg).name;
4495
+ console.error(
4496
+ `Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
4497
+ );
4498
+ const result = runGcloud(
4499
+ buildGcloudArgs("ssh-probe", cfg, {
4500
+ command: buildReconcileAdminCommand(repoName, desired),
4501
+ }),
4502
+ { stdio: "inherit" },
4503
+ );
4504
+ if (result.status !== 0) {
4505
+ throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
4506
+ }
4507
+ console.error(
4508
+ ` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
4509
+ );
4510
+ }
4511
+
3883
4512
  /** Provision-time guarantee that a just-provisioned VM can actually reach
3884
4513
  * GitHub. Probes the staging file + in-container `git ls-remote origin` and
3885
4514
  * routes the outcome through the SAME {@link decideCredentialHeal} classifier
@@ -4011,6 +4640,7 @@ const HANDLERS = {
4011
4640
  "reconcile-ingress": cmdReconcileIngress,
4012
4641
  "wire-add-tail": cmdWireAddTail,
4013
4642
  "reconcile-credentials": cmdReconcileCredentials,
4643
+ "reconcile-admin": cmdReconcileAdmin,
4014
4644
  access: cmdAccess,
4015
4645
  };
4016
4646
 
@@ -4048,6 +4678,7 @@ module.exports = {
4048
4678
  KNOWN_FLAGS,
4049
4679
  FIREWALL_RULE_NAME,
4050
4680
  INSTANCE_TAG,
4681
+ DEFAULT_FLEET_MACHINE_TYPE,
4051
4682
  EDITOR_PORT,
4052
4683
  VITE_PORT,
4053
4684
  parseFlags,
@@ -4066,6 +4697,7 @@ module.exports = {
4066
4697
  previewSameOriginSafeFromStack,
4067
4698
  isRottedManagedPreviewOrigin,
4068
4699
  isWrongModePreviewOrigin,
4700
+ isSilentlyBrokenPreview,
4069
4701
  resolveEditorConfig,
4070
4702
  validateEditorVariant,
4071
4703
  assertVariantProvisionable,
@@ -4074,13 +4706,19 @@ module.exports = {
4074
4706
  resolveGhcrToken,
4075
4707
  resolveGhcrPushToken,
4076
4708
  probeUsableEditorImage,
4709
+ httpsManifestGet,
4077
4710
  resolveProject,
4078
4711
  clientProjectBootstrapArgs,
4079
4712
  cloudConfigPath,
4080
4713
  loadCloudConfig,
4081
4714
  saveCloudConfig,
4082
4715
  loadOrInitCloudConfig,
4716
+ buildProvisionEnv,
4717
+ buildReconcileAdminCommand,
4718
+ cmdReconcileAdmin,
4083
4719
  buildEditorLocalEnvPatch,
4720
+ buildGreenfieldTitlePatch,
4721
+ mergeEditorLocalPatchB64,
4084
4722
  buildEditorLocalSeedWriteCommand,
4085
4723
  seedPreviewOrigin,
4086
4724
  healPreviewOriginAtReady,
@@ -4108,6 +4746,8 @@ module.exports = {
4108
4746
  checkScriptDrift,
4109
4747
  streamedScriptEditorBranch,
4110
4748
  streamedScriptMergeBase,
4749
+ fetchStreamedScriptRef,
4750
+ resolveRemoteScriptRev,
4111
4751
  gatherScriptDriftFacts,
4112
4752
  readStreamedScript,
4113
4753
  STREAMED_SCRIPTS,
@@ -4134,6 +4774,8 @@ module.exports = {
4134
4774
  cmdIngressUp,
4135
4775
  cmdReconcileIngress,
4136
4776
  sweepOrphanIngress,
4777
+ convergeFleetFirewall,
4778
+ reportFirewallPortGaps,
4137
4779
  cmdReconcileCredentials,
4138
4780
  cmdAccess,
4139
4781
  cloudMain,
@@ -4141,12 +4783,14 @@ module.exports = {
4141
4783
  seedLiveCredentialCache,
4142
4784
  assertGitPushCredentials,
4143
4785
  seedGithubCredentials,
4786
+ githubCredentialLine,
4144
4787
  resolveGithubToken,
4145
4788
  resolveGithubTokenWithSource,
4146
4789
  MissingGitPushCredentialsError,
4147
4790
  classifyGitRemoteError,
4148
4791
  decideCredentialHeal,
4149
4792
  readVmAppPort,
4793
+ buildVmAppPortReadCommand,
4150
4794
  resolveLaptopGitIdentity,
4151
4795
  };
4152
4796