@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4

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 (37) hide show
  1. package/npm/cloud.js +779 -116
  2. package/npm/container.js +11 -0
  3. package/npm/disk-pressure.js +89 -0
  4. package/npm/editor-dev.js +144 -52
  5. package/npm/editor.js +47 -17
  6. package/npm/fleet-branch.js +49 -0
  7. package/npm/fleet-broker-death.js +44 -0
  8. package/npm/fleet-broker-debounce.js +63 -0
  9. package/npm/fleet-broker-status.js +45 -0
  10. package/npm/fleet-build-data.js +81 -0
  11. package/npm/fleet-cleanup.js +216 -16
  12. package/npm/fleet-effect-status.js +179 -0
  13. package/npm/fleet-grouping.js +56 -0
  14. package/npm/fleet-job-badge.js +83 -0
  15. package/npm/fleet-memory.js +35 -5
  16. package/npm/fleet-plans.js +96 -0
  17. package/npm/fleet-reconcile.js +58 -1
  18. package/npm/fleet-reset.js +94 -16
  19. package/npm/launch-config.js +197 -14
  20. package/npm/preflight-guidance.js +9 -0
  21. package/npm/scenario-check.js +106 -1
  22. package/npm/scenario-handlers.js +16 -0
  23. package/npm/scenario-interactivity.js +53 -7
  24. package/npm/scenario-playwright.js +184 -5
  25. package/npm/utils.js +238 -53
  26. package/package.json +5 -5
  27. package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
  28. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
  31. package/ui/dist/assets/index-BGqairFV.css +1 -0
  32. package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
  33. package/ui/dist/index.html +2 -2
  34. package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
  35. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
  37. package/ui/dist/assets/index-DVr5rY2d.css +0 -1
package/npm/cloud.js CHANGED
@@ -51,6 +51,12 @@ const AGENT_ENV_VARS = {
51
51
  gemini: "GEMINI_API_KEY",
52
52
  };
53
53
 
54
+ const AGENT_NPM_PACKAGES = {
55
+ claude: "@anthropic-ai/claude-code",
56
+ codex: "@openai/codex",
57
+ gemini: "@google/gemini-cli",
58
+ };
59
+
54
60
  const KNOWN_FLAGS = [
55
61
  "repo", "branch", "instance-name", "project", "zone", "agent",
56
62
  // Editor-source selection, decoupled from the client project (repo/branch):
@@ -110,11 +116,11 @@ const CLOUDFLARED_URLS_FILE =
110
116
  // image out of the client repo.
111
117
  const CANONICAL_EDITOR_REPO = "https://github.com/codeyam-ai/codeyam-editor.git";
112
118
 
113
- // The editor distribution channels an operator can pick per VM:
119
+ // The editor distribution channels an operator can pick per VM. All three are
120
+ // provisionable (gcp-bootstrap dispatches on --editor-variant):
114
121
  // dev — build the editor image from the editor repo @ editorBranch
115
- // (today's path; the only one wired into provisioning so far)
116
- // staging — `npm i -g @codeyam-editor/codeyam-editor@staging` (Phase 2)
117
- // production — `npm i -g @codeyam-editor/codeyam-editor` (Phase 2)
122
+ // staging — run the published `@codeyam-editor/codeyam-editor@staging` image
123
+ // production — run the published `@codeyam-editor/codeyam-editor` (latest) image
118
124
  const EDITOR_VARIANTS = ["dev", "staging", "production"];
119
125
 
120
126
  /** Map an agent name to its API-key env var. Throws on unknown agents so
@@ -129,6 +135,17 @@ function agentEnvVarName(agent) {
129
135
  return AGENT_ENV_VARS[agent];
130
136
  }
131
137
 
138
+ /** Map an agent name to its global npm package. Throws on unknown agents so
139
+ * adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
140
+ function agentNpmPackage(agent) {
141
+ if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
142
+ throw new Error(
143
+ `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
144
+ );
145
+ }
146
+ return AGENT_NPM_PACKAGES[agent];
147
+ }
148
+
132
149
  /** Parse `--flag value` argv into an object. Unknown flags or missing values
133
150
  * throw with a usage hint — silently dropping them would let typos like
134
151
  * `--branchh main` succeed with no branch flag at all. */
@@ -302,33 +319,98 @@ function resolveSeedPreviewOrigin(
302
319
  urls = {},
303
320
  appPort = VITE_PORT,
304
321
  env = process.env,
322
+ sameOriginSafe = true,
305
323
  ) {
324
+ // Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
325
+ // same-origin under the reserved relative path, in every fleet mode. The
326
+ // editor mounts `/__codeyam_preview` on its own origin and forwards to the
327
+ // in-VM app-port reverse proxy, so the iframe loads from the exact origin the
328
+ // editor is served on (public quick-tunnel, named tunnel, SSH-tunnel
329
+ // localhost, anything). It is a CONSTANT relative path: it never rotates and
330
+ // depends only on the editor's own origin being reachable, so it can never rot
331
+ // the way a frozen managed quick-tunnel URL does (the VM-1/2/6 leftover this
332
+ // plan fixes). No `_managedPreviewOrigin` marker is stamped — the marker is
333
+ // reserved for the genuinely-managed dedicated origins below, so the self-heal
334
+ // predicate can tell a rotting managed URL apart from this constant path.
335
+ if (sameOriginSafe) {
336
+ return { previewOrigin: SAME_ORIGIN_PREVIEW_PATH };
337
+ }
338
+
339
+ // NOT same-origin-safe (client-routed stacks — Next.js etc.): the client
340
+ // router breaks the same-origin subpath proxy (it pushState's to root-absolute
341
+ // paths and recurses into the editor), so the preview must come from a
342
+ // DEDICATED origin. We stamp `_managedPreviewOrigin` so the (legacy) monitor
343
+ // recognises the value as ours and won't mistake it for a user pin.
306
344
  const vm = resolveFleetVmNumber(instanceName);
307
345
  const domain = resolveFleetDomain(env);
308
- // Named tunnels are per fleet-VM; a non-fleet client project (vm == null)
309
- // keeps the legacy localhost seed even when a domain is configured.
346
+ // Named-tunnel mode: the deterministic `vm-<N>.preview.<domain>` host is a
347
+ // dedicated sibling subdomain of the editor and never rotates. A non-fleet
348
+ // client project (vm == null) keeps the localhost seed even with a domain set.
310
349
  if (domain && vm != null) {
311
350
  return {
312
351
  previewOrigin: vmUrls(vm, domain).preview,
313
352
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
314
353
  };
315
354
  }
316
- // Quick-tunnel / domain-free mode: serve the app same-origin under the
317
- // reserved relative path. The editor mounts `/__codeyam_preview` on its own
318
- // origin and forwards to the in-VM app-port reverse proxy, so the iframe loads
319
- // from the exact origin the editor is served on (public quick-tunnel,
320
- // SSH-tunnel localhost, anything). This replaces the prior
321
- // `http://localhost:${appPort}` seed — a loopback URL a public editor page
322
- // could not load (Private Network Access block) — and the monitor's rotating
323
- // `<N>-preview` quick-tunnel URL, which a constant relative path makes moot.
324
- // `appPort` / `urls` are retained in the signature for callers but no longer
325
- // seeded — the same-origin path is a constant.
355
+ // Quick-tunnel / domain-free mode: prefer the operator's published
356
+ // `<N>-preview` quick-tunnel URL (the public origin a remote browser can
357
+ // actually load), else the SSH-tunnel bootstrap localhost origin
358
+ // (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
359
+ // the same-origin subpath — that is exactly what recurses for this stack.
360
+ const previewKey = vm != null ? `${vm}-preview` : null;
361
+ const tunnelUrl =
362
+ previewKey &&
363
+ typeof urls[previewKey] === "string" &&
364
+ urls[previewKey].length > 0
365
+ ? urls[previewKey]
366
+ : `http://localhost:${appPort}`;
326
367
  return {
327
- previewOrigin: SAME_ORIGIN_PREVIEW_PATH,
368
+ previewOrigin: tunnelUrl,
328
369
  _managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
329
370
  };
330
371
  }
331
372
 
373
+ /** Extract the stack's `preview.sameOriginSafe` flag from a parsed
374
+ * `.codeyam/stack.json` object. Absent / non-object / non-boolean ⇒ `true`
375
+ * (the same-origin default), mirroring `PreviewConfig::same_origin_safe()` on
376
+ * the Rust side so the seeder and the editor agree on the truth table. Pure. */
377
+ function previewSameOriginSafeFromStack(stackJson) {
378
+ const preview =
379
+ stackJson && typeof stackJson === "object" ? stackJson.preview : null;
380
+ const flag =
381
+ preview && typeof preview === "object" ? preview.sameOriginSafe : undefined;
382
+ return typeof flag === "boolean" ? flag : true;
383
+ }
384
+
385
+ // Matches an absolute cloudflared quick-tunnel origin
386
+ // (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
387
+ // URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
388
+ const TRYCLOUDFLARE_ORIGIN_RE =
389
+ /^https:\/\/[a-z0-9-]+\.trycloudflare\.com(?:[:/?#]|$)/i;
390
+
391
+ /** Decide whether a VM's current `editor.local.json` carries a ROTTED managed
392
+ * cloudflared previewOrigin that must be self-healed back to the same-origin
393
+ * path. True iff ALL hold:
394
+ * - the stack is same-origin-safe (a client-routed stack legitimately keeps a
395
+ * dedicated origin — never heal it);
396
+ * - the value is stamped `_managedPreviewOrigin: cloudflared-monitor` (WE
397
+ * wrote it, not a user pin); and
398
+ * - the `previewOrigin` is an absolute `*.trycloudflare.com` quick-tunnel URL
399
+ * (the rotating kind that goes dead — a deterministic named-tunnel host or
400
+ * the constant `/__codeyam_preview` path is left alone).
401
+ * Idempotent by construction: a VM already on `/__codeyam_preview` (or any
402
+ * non-trycloudflare origin) returns false, so re-running the heal is a no-op.
403
+ * Pure — `localJson` is a parsed editor.local.json object. */
404
+ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
405
+ if (!sameOriginSafe) return false;
406
+ if (!localJson || typeof localJson !== "object") return false;
407
+ if (localJson._managedPreviewOrigin !== MANAGED_PREVIEW_ORIGIN) return false;
408
+ return (
409
+ typeof localJson.previewOrigin === "string" &&
410
+ TRYCLOUDFLARE_ORIGIN_RE.test(localJson.previewOrigin)
411
+ );
412
+ }
413
+
332
414
  /** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
333
415
  * → {} (the monitor may not have run yet; the seed falls back to localhost). */
334
416
  function readCloudflaredUrls() {
@@ -370,26 +452,33 @@ function validateEditorVariant(variant) {
370
452
  return variant;
371
453
  }
372
454
 
373
- /** Phase-1 gate: only `dev` is wired into cloud provisioning. The
374
- * staging/production npm-install container image lands in Phase 2. Fails loud
375
- * with an actionable message rather than silently building a dev image. */
455
+ /** Assert a variant can be provisioned onto a cloud VM. Every known variant is
456
+ * provisionable now — `dev` builds the editor image from source, while
457
+ * `staging`/`production` run the published npm-package image (gcp-bootstrap
458
+ * pulls/builds Dockerfile.npm keyed off the variant). So this collapses to the
459
+ * unknown-variant guard: a typo'd `--editor-variant` fails loud rather than
460
+ * silently launching the wrong (or no) image. */
376
461
  function assertVariantProvisionable(variant) {
377
- validateEditorVariant(variant);
378
- if (variant !== "dev") {
379
- throw new Error(
380
- `Editor variant '${variant}' is not yet wired for cloud provisioning ` +
381
- `(Phase 2 — the @staging/production npm-install image). Use --editor-variant dev.`,
382
- );
383
- }
384
- return variant;
462
+ return validateEditorVariant(variant);
385
463
  }
386
464
 
387
- /** Args for scripts/gcp-bootstrap.sh, which builds the codeyam-editor:local
388
- * image from the EDITOR source — NOT the client project. */
465
+ /** Args for scripts/gcp-bootstrap.sh, which produces the codeyam-editor:local
466
+ * image. For the `dev` variant that image is built from the EDITOR source
467
+ * (--repo/--branch); for packaged variants (staging/production) it comes from
468
+ * the published npm package, so the editor source repo/branch is irrelevant and
469
+ * omitted — gcp-bootstrap pulls/builds Dockerfile.npm keyed off --editor-variant.
470
+ * The CLIENT project repo/branch is unaffected: it flows through cloud:up's own
471
+ * project bootstrap, not this function. */
389
472
  function editorImageBootstrapArgs(cfg) {
390
- const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
391
- const args = ["--agent", cfg.agent, "--repo", editorRepo];
392
- if (editorBranch) args.push("--branch", editorBranch);
473
+ const { editorRepo, editorBranch, editorVariant } = resolveEditorConfig(cfg);
474
+ const args = ["--agent", cfg.agent, "--editor-variant", editorVariant];
475
+ // Stack assumption: only the `dev` variant builds the editor image from source,
476
+ // so only it needs the editor repo/branch plumbing gcp-bootstrap's source path
477
+ // consumes. Packaged variants install the binary from the published npm package.
478
+ if (editorVariant === "dev") {
479
+ args.push("--repo", editorRepo);
480
+ if (editorBranch) args.push("--branch", editorBranch);
481
+ }
393
482
  return args;
394
483
  }
395
484
 
@@ -468,14 +557,26 @@ function saveCloudConfig(projectDir, cfg) {
468
557
  }
469
558
 
470
559
  /** Build the JSON patch to merge into the VM's `editor.local.json` so the
471
- * configured agent picks up the user's local API key on first launch.
472
- * Returns `null` when the key isn't set locally — the wrapper then prints
473
- * the interactive-auth fallback line instead of writing an empty patch. */
560
+ * configured agent becomes the editor's build-agent provider AND (when the
561
+ * key is available) picks up the user's local API key on first launch. The
562
+ * top-level `provider` field (lowercase — matching `BuildAgentProvider`'s
563
+ * serde) makes the VM's editor default to the chosen agent instead of Claude;
564
+ * `editor.local.json` is deep-merged over `editor.json` on the VM, so this
565
+ * overrides the packaged default.
566
+ *
567
+ * The patch ALWAYS carries `provider` — the agent is a launch *choice* that
568
+ * must persist on the VM regardless of whether the operator happens to have
569
+ * the agent's API key exported locally (Gemini, for instance, is normally
570
+ * authenticated interactively on the VM). `env.<KEY>` is the separate,
571
+ * opportunistic seed: it's included only when the matching key is set in
572
+ * `localEnv`. The function never returns `null`. Writing `provider: "claude"`
573
+ * when claude is the default deep-merges to a no-op, so the provider can be
574
+ * written unconditionally without special-casing the default. */
474
575
  function buildEditorLocalEnvPatch(localEnv, agent) {
475
576
  const varName = agentEnvVarName(agent);
476
577
  const value = localEnv[varName];
477
- if (!value) return null;
478
- return { env: { [varName]: value } };
578
+ if (!value) return { provider: agent };
579
+ return { provider: agent, env: { [varName]: value } };
479
580
  }
480
581
 
481
582
  /** Parse `docker compose ps --format json` (compose v2 — one JSON object per
@@ -568,10 +669,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
568
669
  // shell-quoting through gcloud → ssh → remote bash. The script body
569
670
  // is delivered as one argument; the wrapper feeds the base64 into
570
671
  // `extras.command` and the caller sets `input` to the script body.
672
+ //
673
+ // Keepalive flags are defense-in-depth for the long-running bootstrap
674
+ // follow session: a quiet stretch during `cargo build` (no stdout for
675
+ // minutes) must not trip an idle disconnect. ServerAliveInterval pings
676
+ // the VM every 30s and ServerAliveCountMax tolerates 6 missed replies
677
+ // (~3 min) before giving up, while TCPKeepAlive keeps NAT mappings warm.
678
+ // The detached server-side runner (bootstrapLaunchCommand) makes a real
679
+ // drop non-fatal; keepalive just makes spurious drops rarer.
571
680
  return [
572
681
  "compute", "ssh", instanceName,
573
682
  "--project", project, "--zone", zone,
574
683
  "--tunnel-through-iap",
684
+ ...SSH_KEEPALIVE_FLAGS,
575
685
  "--command", extras.command,
576
686
  ];
577
687
  case "ssh-tunnel": {
@@ -619,6 +729,72 @@ function runGcloud(args, opts = {}) {
619
729
  });
620
730
  }
621
731
 
732
+ // Build a bounded, de-noised failure detail from a runGcloud (spawnSync)
733
+ // result: prefer stderr, fall back to stdout, and surface a spawn-level error
734
+ // (e.g. ENOENT when gcloud is not on PATH) when there is no captured output.
735
+ // Trims the known gcloud noise lines — the same `/WARNING|NumPy|google.com/iap/`
736
+ // filter the dashboard's tailLog applies — and keeps the last ~15 lines so the
737
+ // actionable message is not buried under auth/IAP warnings. Pure: result in,
738
+ // string out, so it is unit-testable without spawning gcloud.
739
+ function formatGcloudFailure(result) {
740
+ const denoise = (text) =>
741
+ String(text || "")
742
+ .split("\n")
743
+ .map((l) => l.replace(/\r$/, ""))
744
+ .filter((l) => l.trim() !== "")
745
+ .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
746
+ .slice(-15)
747
+ .join("\n");
748
+
749
+ const stderr = denoise(result && result.stderr);
750
+ if (stderr) return stderr;
751
+ const stdout = denoise(result && result.stdout);
752
+ if (stdout) return stdout;
753
+ if (result && result.error && result.error.message) {
754
+ return result.error.message;
755
+ }
756
+ return "(no output captured from gcloud)";
757
+ }
758
+
759
+ // A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
760
+ // for an ssh failure) and a signature match against known transient strings.
761
+ function isTransientSshFailure(result) {
762
+ if (!result || result.status !== 255) {
763
+ return false;
764
+ }
765
+ const output = String(result.stderr || "") + String(result.stdout || "");
766
+ const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
767
+ return transientRegex.test(output);
768
+ }
769
+
770
+ // A retry wrapper for gcloud ssh-script calls that handles transient connection drops
771
+ // with exponential backoff, mimicking the IAP ready backoff.
772
+ async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, label, sleepFn } = {}) {
773
+ let lastResult;
774
+ for (let attempt = 1; attempt <= retries; attempt++) {
775
+ const result = runGcloud(buildGcloudArgs(verb, cfg, extras), { stdio });
776
+ if (result.status === 0) {
777
+ return result;
778
+ }
779
+ lastResult = result;
780
+ if (isTransientSshFailure(result)) {
781
+ if (attempt < retries) {
782
+ const delay = Math.min(2000 * Math.pow(1.5, attempt - 1), 8000);
783
+ console.error(` ↺ transient SSH failure (attempt ${attempt}/${retries}) [${label || verb}] ... retrying in ${delay / 1000}s`);
784
+ if (sleepFn) {
785
+ await sleepFn(delay);
786
+ } else {
787
+ await new Promise((r) => setTimeout(r, delay));
788
+ }
789
+ continue;
790
+ }
791
+ }
792
+ // For non-transient failure, return immediately, or if we exhausted our attempts.
793
+ return result;
794
+ }
795
+ return lastResult;
796
+ }
797
+
622
798
  function ensureGcloud() {
623
799
  const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
624
800
  if (result.error || result.status !== 0) {
@@ -718,9 +894,11 @@ async function ensureFirewallRule(cfg) {
718
894
  async function ensureInstance(cfg) {
719
895
  console.error(`Checking instance ${cfg.instanceName}...`);
720
896
  const initialState = fetchInstanceState(cfg);
897
+ const createdThisRun = initialState === null;
898
+
721
899
  if (initialState === "RUNNING") {
722
900
  console.error(` ✓ ${cfg.instanceName} is RUNNING`);
723
- return;
901
+ return { createdThisRun };
724
902
  }
725
903
  if (initialState === "TERMINATED") {
726
904
  console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
@@ -742,7 +920,7 @@ async function ensureInstance(cfg) {
742
920
  if (fetchInstanceState(cfg) === "RUNNING") {
743
921
  console.error(` ✓ ${cfg.instanceName} is RUNNING`);
744
922
  await waitForIapReady(cfg);
745
- return;
923
+ return { createdThisRun };
746
924
  }
747
925
  await new Promise((r) => setTimeout(r, 2000));
748
926
  }
@@ -807,23 +985,198 @@ function resolveSetupForwardEnv(env = process.env) {
807
985
  return out;
808
986
  }
809
987
 
810
- function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
811
- const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
812
- const argsString = scriptArgs.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" ");
813
- // Prefix each env var as `KEY='value'` so they're set for the bash -s
814
- // process only — no persistence in the VM's shell history, no /etc/environment
815
- // edit. Single-quoted values are safe against shell metacharacters; an
816
- // embedded apostrophe in the value would be exotic for these (token,
817
- // bool flag), but escape it the same way as scriptArgs for symmetry.
818
- const envString = Object.entries(envVars)
988
+ // --ssh-flag values gcloud forwards verbatim to the underlying `ssh`. Shaped as
989
+ // `-o Key=Value` so they survive the gcloud → ssh hop unchanged. Used by the
990
+ // ssh-script verb (the long bootstrap follow session) — short ssh-probe calls
991
+ // don't need them. See the ssh-script case in buildGcloudArgs for the rationale.
992
+ const SSH_KEEPALIVE_FLAGS = [
993
+ "--ssh-flag=-o ServerAliveInterval=30",
994
+ "--ssh-flag=-o ServerAliveCountMax=6",
995
+ "--ssh-flag=-o TCPKeepAlive=yes",
996
+ ];
997
+
998
+ // Canonical VM-side paths for the detached bootstrap. The build runs from
999
+ // BOOTSTRAP_RUNNER_PATH (a tiny wrapper that execs the real script), streams to
1000
+ // BOOTSTRAP_LOG_PATH, and writes its exit status into BOOTSTRAP_DONE_PATH on
1001
+ // completion. Completion + success are derived from these files (plus the
1002
+ // docker image), NOT from the SSH exit code — so a dropped follow-session over
1003
+ // a healthy VM is no longer misreported as "failed on the VM".
1004
+ const BOOTSTRAP_SCRIPT_PATH = "$HOME/.codeyam-bootstrap.sh";
1005
+ const BOOTSTRAP_RUNNER_PATH = "$HOME/.codeyam-bootstrap-run.sh";
1006
+ const BOOTSTRAP_LOG_PATH = "$HOME/.codeyam-bootstrap.log";
1007
+ const BOOTSTRAP_DONE_PATH = "$HOME/.codeyam-bootstrap.done";
1008
+ const BOOTSTRAP_PID_PATH = "$HOME/.codeyam-bootstrap.pid";
1009
+ const BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_BOOTSTRAP_LAUNCHED";
1010
+ const BOOTSTRAP_STATUS_MARKER = "CYBOOT";
1011
+ // improve40 — VM-side path of the PROJECT-bootstrap terminal completion marker.
1012
+ // gcp-project-bootstrap.sh writes this as its very last step (only reached after
1013
+ // `set -euo pipefail` carried every provisioning step to success), and
1014
+ // ensureProjectBootstrapped's done-probe gates on it so a half-finished
1015
+ // bootstrap is correctly re-run instead of false-skipped (the VM-7 incident).
1016
+ // Lives outside the project's git tree (a $HOME dotfile, keyed by repo name) so
1017
+ // it never churns the repo. Pure so the bash writer and the JS probe derive the
1018
+ // EXACT same path; tested without a live VM.
1019
+ function projectBootstrapDoneMarker(repoName) {
1020
+ return `$HOME/.codeyam-project-bootstrap-${repoName}.done`;
1021
+ }
1022
+ // improve40 — the VM-side shell `test` chain ensureProjectBootstrapped runs to
1023
+ // decide "is this project already bootstrapped?". Gates on the terminal `.done`
1024
+ // marker FIRST (the VM-7 false-skip fix — both structural artifacts can exist on
1025
+ // a half-done bootstrap), then keeps the `.codeyam/` + docker-compose.cloud.yml
1026
+ // checks as belt-and-suspenders for a legacy VM whose marker predates this change.
1027
+ // Pure (just the command string) so the gating is unit-testable without gcloud.
1028
+ function projectBootstrapDoneProbeCommand(repoName) {
1029
+ return (
1030
+ `test -f ${projectBootstrapDoneMarker(repoName)} ` +
1031
+ `&& test -d ~/projects/${repoName}/.codeyam ` +
1032
+ `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`
1033
+ );
1034
+ }
1035
+ // One follow session caps at 30 min (comfortably over the ~14-min source
1036
+ // build); the overall wait caps at 40 min across re-attaches before we bail
1037
+ // loudly with the VM-side log location.
1038
+ const BOOTSTRAP_FOLLOW_SECS = 1800;
1039
+ const BOOTSTRAP_DEADLINE_MS = 40 * 60 * 1000;
1040
+
1041
+ // Quote a shell value the same way scriptArgs are quoted: single-quote it and
1042
+ // escape any embedded apostrophe. Shared by the env-prefix and arg encoders so
1043
+ // a token with an exotic character can't break out of its quoting.
1044
+ function shQuote(v) {
1045
+ return `'${String(v).replace(/'/g, "'\\''")}'`;
1046
+ }
1047
+
1048
+ // Encode env vars as a shell-assignment PREFIX (`KEY='value' …`). Assignment
1049
+ // prefixes set the variable in the launched command's environment only — never
1050
+ // in its argv — so tokens stay out of `ps` on the VM. Empty / nullish values
1051
+ // are dropped so the no-token path is unchanged.
1052
+ function envAssignmentPrefix(envVars) {
1053
+ const s = Object.entries(envVars)
819
1054
  .filter(([, v]) => v !== undefined && v !== null && v !== "")
820
- .map(([k, v]) => `${k}='${String(v).replace(/'/g, "'\\''")}'`)
1055
+ .map(([k, v]) => `${k}=${shQuote(v)}`)
821
1056
  .join(" ");
822
- const envPrefix = envString ? `${envString} ` : "";
1057
+ return s ? `${s} ` : "";
1058
+ }
1059
+
1060
+ // The tiny VM-side wrapper the detached launch execs. It runs the real
1061
+ // bootstrap, redirects all output to the log, and records the exit status in
1062
+ // the sentinel — the single source of truth for "did the build finish, and
1063
+ // did it succeed?" once the SSH follow-session is gone. Contains NO secrets
1064
+ // (tokens arrive via the launch env prefix, never written to disk).
1065
+ function bootstrapRunnerScript() {
1066
+ return [
1067
+ "#!/usr/bin/env bash",
1068
+ "# Detached bootstrap runner (written by npm/cloud.js ensureBootstrapped).",
1069
+ "# Survives SSH disconnect: the laptop only *follows* the log below; this",
1070
+ "# process keeps running under its own session if the follow drops.",
1071
+ "# Records its own PID so the status probe can check liveness via `kill -0`",
1072
+ "# (a `pgrep` on the script path would also match the probe command itself).",
1073
+ `echo "$$" > "$HOME/.codeyam-bootstrap.pid"`,
1074
+ `bash "$HOME/.codeyam-bootstrap.sh" "$@" > "$HOME/.codeyam-bootstrap.log" 2>&1`,
1075
+ "status=$?",
1076
+ `echo "$status" > "$HOME/.codeyam-bootstrap.done"`,
1077
+ `rm -f "$HOME/.codeyam-bootstrap.pid"`,
1078
+ "",
1079
+ ].join("\n");
1080
+ }
1081
+
1082
+ // Build the remote command that (1) materializes the bootstrap script + runner
1083
+ // on the VM, (2) clears any stale sentinel, (3) launches the runner DETACHED
1084
+ // (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
1085
+ // session, and (4) prints a launched marker. The detach is the durable fix:
1086
+ // the build no longer dies when the laptop's SSH pipe breaks.
1087
+ function bootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}) {
1088
+ const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1089
+ const runnerB64 = Buffer.from(bootstrapRunnerScript(), "utf8").toString("base64");
1090
+ const argsString = scriptArgs.map(shQuote).join(" ");
1091
+ const envPrefix = envAssignmentPrefix(envVars);
1092
+ // Writes run synchronously (fast, before any backgrounding) so a decode error
1093
+ // aborts before launch; then the runner is detached. The env prefix is on the
1094
+ // setsid command so the tokens are inherited through nohup→setsid→bash but
1095
+ // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1096
+ // launch in the background while still confirming it fired.
1097
+ const writes = [
1098
+ `echo '${scriptB64}' | base64 -d > ${BOOTSTRAP_SCRIPT_PATH}`,
1099
+ `echo '${runnerB64}' | base64 -d > ${BOOTSTRAP_RUNNER_PATH}`,
1100
+ `rm -f ${BOOTSTRAP_DONE_PATH}`,
1101
+ ].join(" && ");
1102
+ const launch =
1103
+ `${envPrefix}nohup setsid bash ${BOOTSTRAP_RUNNER_PATH} ${argsString}`.trimEnd() +
1104
+ ` </dev/null >/dev/null 2>&1 &`;
1105
+ return `${writes}; ${launch} echo ${BOOTSTRAP_LAUNCHED_MARKER}`;
1106
+ }
1107
+
1108
+ // Build the remote command that FOLLOWS the detached build: tail the log live
1109
+ // for the operator, polling for the sentinel, and exit when the build finishes
1110
+ // OR a per-session deadline elapses. If the SSH session drops mid-follow this
1111
+ // command dies but the detached build keeps going — the caller then re-attaches.
1112
+ function bootstrapFollowCommand(deadlineSecs = BOOTSTRAP_FOLLOW_SECS) {
1113
+ return [
1114
+ `touch ${BOOTSTRAP_LOG_PATH}`,
1115
+ `tail -n +1 -F ${BOOTSTRAP_LOG_PATH} 2>/dev/null & TP=$!`,
1116
+ `waited=0`,
1117
+ `while [ ! -f ${BOOTSTRAP_DONE_PATH} ]; do ` +
1118
+ `sleep 3; waited=$((waited+3)); ` +
1119
+ `if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
1120
+ `done`,
1121
+ `sleep 1`,
1122
+ `kill "$TP" 2>/dev/null`,
1123
+ ].join("; ");
1124
+ }
1125
+
1126
+ // Build the remote command that reports terminal VM state in one parseable
1127
+ // line: whether the image exists, the sentinel's exit code (or `none`), and
1128
+ // whether a bootstrap process is still alive. Cheap — runs over ssh-probe.
1129
+ function bootstrapStatusCommand() {
1130
+ // Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
1131
+ // command's own argv contains the bootstrap path, so a `pgrep -f` on it would
1132
+ // match this very probe and always report running. A stale PID file from a
1133
+ // killed runner reads as not-running because `kill -0` fails.
1134
+ return [
1135
+ `img=absent; docker image inspect codeyam-editor:local >/dev/null 2>&1 && img=present`,
1136
+ `done=none; [ -f ${BOOTSTRAP_DONE_PATH} ] && done=$(cat ${BOOTSTRAP_DONE_PATH} 2>/dev/null)`,
1137
+ `run=no; if [ -f ${BOOTSTRAP_PID_PATH} ]; then p=$(cat ${BOOTSTRAP_PID_PATH} 2>/dev/null); ` +
1138
+ `{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
1139
+ `echo "${BOOTSTRAP_STATUS_MARKER} img=$img done=$done running=$run"`,
1140
+ ].join("; ");
1141
+ }
1142
+
1143
+ // Parse the single status line emitted by bootstrapStatusCommand. Returns
1144
+ // `{ img, done, running }` or null when no status line is present (e.g. the
1145
+ // probe ssh itself failed and produced no stdout).
1146
+ function parseBootstrapStatus(stdout) {
1147
+ const m = String(stdout || "").match(
1148
+ new RegExp(`${BOOTSTRAP_STATUS_MARKER} img=(\\S+) done=(\\S+) running=(\\S+)`),
1149
+ );
1150
+ if (!m) return null;
1151
+ return { img: m[1], done: m[2], running: m[3] };
1152
+ }
1153
+
1154
+ // Decide the bootstrap outcome from VM-side state alone — never the SSH exit
1155
+ // code. The image is the real artifact, so its presence is success regardless
1156
+ // of how the follow session ended. A live process means "re-attach". A sentinel
1157
+ // without an image means the build ran and failed. Nothing at all means it was
1158
+ // never started (or the probe couldn't see it).
1159
+ function decideBootstrapOutcome(status) {
1160
+ if (!status) return "absent";
1161
+ const { img, done, running } = status;
1162
+ if (img === "present") return "success";
1163
+ if (running === "yes") return "running";
1164
+ if (done && done !== "none") return "failed";
1165
+ return "absent";
1166
+ }
1167
+
1168
+ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
1169
+ const b64 = Buffer.from(scriptBody, "utf8").toString("base64");
1170
+ // Args + env share the quoting/encoding helpers with bootstrapLaunchCommand:
1171
+ // single-quoted args (shQuote) and a `KEY='value'` assignment prefix
1172
+ // (envAssignmentPrefix) that sets the vars for the `bash -s` process only —
1173
+ // no persistence in the VM's shell history, no argv exposure of tokens.
1174
+ const argsString = scriptArgs.map(shQuote).join(" ");
1175
+ const envPrefix = envAssignmentPrefix(envVars);
823
1176
  return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
824
1177
  }
825
1178
 
826
- async function ensureBootstrapped(cfg) {
1179
+ async function ensureBootstrapped(cfg, { sleepFn } = {}) {
827
1180
  console.error("Checking VM bootstrap state...");
828
1181
  const probe = runGcloud(
829
1182
  buildGcloudArgs("ssh-probe", cfg, {
@@ -850,42 +1203,129 @@ async function ensureBootstrapped(cfg) {
850
1203
  // wrapper / Dockerfile patches from a feature branch; without it the image
851
1204
  // build uses the editor repo's default branch.
852
1205
  const bootstrapArgs = editorImageBootstrapArgs(cfg);
853
- // CODEYAM_GHCR_TOKEN lets the bootstrap `docker login ghcr.io` and pull the
854
- // PRIVATE prebuilt base + branch-binary packages — without it every pull
855
- // 403s and the bootstrap silently slow-builds (~14 min) from source.
856
- // CODEYAM_GHCR_USER is an optional login-username override (the bootstrap
857
- // otherwise derives it from the registry owner). Empty values are dropped by
858
- // sshScriptCommand, so the no-registry / local-only path is unchanged.
859
- const command = sshScriptCommand(scriptBody, bootstrapArgs, {
1206
+ // Token threading into the bootstrap env (assignment prefix, never argv):
1207
+ // CODEYAM_GHCR_TOKEN — read:packages, for `docker login` + the PRIVATE
1208
+ // prebuilt base/binary PULLS. Without it every pull 403s and the
1209
+ // bootstrap slow-builds (~14 min) from source.
1210
+ // CODEYAM_GHCR_PUSH_TOKEN — write:packages, used ONLY by the post-compile
1211
+ // publish step's push. Distinct from the read token because a push needs
1212
+ // a strictly broader scope; reusing the read token guarantees a 403.
1213
+ // Absent → the bootstrap skips the publish cleanly (no doomed push).
1214
+ // CODEYAM_GHCR_USER / CODEYAM_GHCR_PUSH_USER — optional login-username
1215
+ // overrides. Empty values are dropped, so the no-registry path is
1216
+ // unchanged.
1217
+ const bootstrapEnv = {
860
1218
  GITHUB_TOKEN: resolveGithubToken(),
861
1219
  CODEYAM_GHCR_TOKEN: resolveGhcrToken(),
862
1220
  CODEYAM_GHCR_USER: process.env.CODEYAM_GHCR_USER || "",
863
- });
864
- const result = runGcloud(
865
- buildGcloudArgs("ssh-script", cfg, { command }),
866
- { stdio: "inherit" },
867
- );
868
- if (result.status !== 0) {
869
- throw new Error("scripts/gcp-bootstrap.sh failed on the VM");
1221
+ CODEYAM_GHCR_PUSH_TOKEN: resolveGhcrPushToken(),
1222
+ CODEYAM_GHCR_PUSH_USER: process.env.CODEYAM_GHCR_PUSH_USER || "",
1223
+ };
1224
+
1225
+ // Idempotent re-attach: a prior cloud:up whose follow-session dropped may
1226
+ // have left a detached build still running (or already finished). Converge
1227
+ // on it instead of starting over.
1228
+ const initial = probeBootstrapOutcome(cfg);
1229
+ if (initial === "success") {
1230
+ console.error(" ✓ a previous detached bootstrap already completed");
1231
+ return;
1232
+ }
1233
+ if (initial === "running") {
1234
+ console.error(" ↺ a detached bootstrap is already in progress — re-attaching to its log...");
1235
+ } else {
1236
+ // "absent" (never started) OR "failed" (a prior run died) → (re)launch a
1237
+ // fresh detached build that will survive an SSH disconnect.
1238
+ console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
1239
+ const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
1240
+ const launched = await runGcloudSshWithRetry(
1241
+ "ssh-script",
1242
+ cfg,
1243
+ { command: launch },
1244
+ { stdio: ["ignore", "pipe", "pipe"], label: "bootstrap launch", sleepFn }
1245
+ );
1246
+ if (launched.status !== 0) {
1247
+ const detail = formatGcloudFailure(launched);
1248
+ throw new Error(
1249
+ `failed to launch the detached bootstrap on the VM (gcloud ssh exit ${launched.status}).\n${detail}`,
1250
+ );
1251
+ }
1252
+ }
1253
+
1254
+ // Follow the live log, re-attaching across any follow-session drops, until
1255
+ // the VM reports a terminal state or the overall deadline elapses. Success
1256
+ // and failure are read from VM state (image + sentinel), never the SSH exit
1257
+ // code — so "my laptop's SSH dropped" no longer masquerades as a build error.
1258
+ const deadline = Date.now() + BOOTSTRAP_DEADLINE_MS;
1259
+ for (;;) {
1260
+ runGcloud(
1261
+ buildGcloudArgs("ssh-script", cfg, { command: bootstrapFollowCommand() }),
1262
+ { stdio: "inherit" },
1263
+ );
1264
+ const outcome = probeBootstrapOutcome(cfg);
1265
+ if (outcome === "success") {
1266
+ console.error(" ✓ VM bootstrap complete");
1267
+ return;
1268
+ }
1269
+ if (outcome === "failed") {
1270
+ const tail = bootstrapLogTail(cfg);
1271
+ throw new Error(
1272
+ "scripts/gcp-bootstrap.sh failed on the VM. " +
1273
+ `Last lines of ~/.codeyam-bootstrap.log:\n${tail}`,
1274
+ );
1275
+ }
1276
+ if (Date.now() >= deadline) {
1277
+ throw new Error(
1278
+ "Timed out waiting for the VM bootstrap. It may still be running — " +
1279
+ "re-run cloud:up to re-attach, or inspect ~/.codeyam-bootstrap.log on the VM.",
1280
+ );
1281
+ }
1282
+ // outcome === "running": the follow session ended but the build is alive.
1283
+ console.error(" ↺ follow session ended but the build is still running — re-attaching...");
870
1284
  }
871
1285
  }
872
1286
 
1287
+ // Probe the VM for the detached bootstrap's terminal state and reduce it to a
1288
+ // single outcome string ("success" | "failed" | "running" | "absent"). Used
1289
+ // both for the idempotent re-attach decision and after each follow session.
1290
+ function probeBootstrapOutcome(cfg) {
1291
+ const r = runGcloud(
1292
+ buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
1293
+ { stdio: ["ignore", "pipe", "pipe"] },
1294
+ );
1295
+ return decideBootstrapOutcome(parseBootstrapStatus(r.stdout));
1296
+ }
1297
+
1298
+ // Fetch the tail of the VM-side bootstrap log for an actionable failure
1299
+ // message — the operator sees WHY the build failed, not a bare exit code.
1300
+ function bootstrapLogTail(cfg, lines = 40) {
1301
+ const r = runGcloud(
1302
+ buildGcloudArgs("ssh-probe", cfg, {
1303
+ command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
1304
+ }),
1305
+ { stdio: ["ignore", "pipe", "pipe"] },
1306
+ );
1307
+ return String(r.stdout || "").trim() || "(log unavailable)";
1308
+ }
1309
+
873
1310
  async function ensureProjectBootstrapped(cfg) {
874
1311
  const repoName = resolveProject(cfg).name;
875
1312
  console.error(`Checking project bootstrap state for ${repoName}...`);
876
- // Probe both .codeyam/ (created by `editor init`) AND
877
- // docker-compose.cloud.yml (written near the end of the script). A previous
878
- // run that crashed between init and the compose write would leave the
879
- // first marker but not the second; the old probe wrongly considered the
880
- // project bootstrapped and the next `cloud:up` then hit
881
- // "open docker-compose.cloud.yml: no such file or directory".
882
- // The probe is the seam where idempotency lives — make sure it gates on
883
- // the LAST artifact the script produces, not the first.
1313
+ // Gate on the TERMINAL completion marker the script writes as its very last
1314
+ // step (scripts/gcp-project-bootstrap.sh → ~/.codeyam-project-bootstrap-<repo>.done),
1315
+ // not on intermediate structural artifacts. Earlier this probed only `.codeyam/`
1316
+ // + `docker-compose.cloud.yml` — but BOTH can exist while the bootstrap is
1317
+ // still incomplete (init scaffolds `.codeyam/` and the compose render happens
1318
+ // mid-script, before the creds/claude-dir setup and `npm install`/`setup`
1319
+ // finish). The improve40 VM-7 incident hit exactly this: a recovery re-run saw
1320
+ // the two markers, declared "already bootstrapped", and SKIPPED the unfinished
1321
+ // work. The `.done` marker is written only after `set -euo pipefail` has
1322
+ // carried every step to success, so an interrupted bootstrap lacks it and is
1323
+ // correctly re-run (the re-run is idempotent — skip-on-exists per step). The
1324
+ // two structural checks stay as belt-and-suspenders for a legacy VM whose
1325
+ // marker predates this change but is otherwise wired.
884
1326
  const probe = runGcloud(
885
1327
  buildGcloudArgs("ssh-probe", cfg, {
886
- command:
887
- `test -d ~/projects/${repoName}/.codeyam ` +
888
- `&& test -f ~/projects/${repoName}/docker-compose.cloud.yml`,
1328
+ command: projectBootstrapDoneProbeCommand(repoName),
889
1329
  }),
890
1330
  { stdio: ["ignore", "pipe", "pipe"] },
891
1331
  );
@@ -942,6 +1382,7 @@ async function ensureEditorContainerUp(cfg) {
942
1382
  );
943
1383
  if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
944
1384
  console.error(" ✓ editor container is already running");
1385
+ await ensureAgentCliInstalled(cfg);
945
1386
  return;
946
1387
  }
947
1388
  console.error(" starting editor container (docker compose up -d)...");
@@ -954,6 +1395,34 @@ async function ensureEditorContainerUp(cfg) {
954
1395
  if (up.status !== 0) {
955
1396
  throw new Error("docker compose up -d failed on the VM");
956
1397
  }
1398
+ await ensureAgentCliInstalled(cfg);
1399
+ }
1400
+
1401
+ // Hard gate: provisioning success REQUIRES a running editor container. Reuses
1402
+ // the exact compose-ps probe ensureEditorContainerUp uses, but as a TERMINAL
1403
+ // re-check rather than a precondition — `docker compose up -d` can exit 0 over
1404
+ // a container that then crash-loops or exits, and an upstream phase that never
1405
+ // ran leaves no /workspace at all. Either way `cmdUp` would otherwise report
1406
+ // success. This throws (non-zero exit) when the editor service isn't
1407
+ // `running`, converting a silent partial provision into a loud, actionable
1408
+ // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
1409
+ // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
1410
+ // parsed compose state, not the SSH/cd exit code.
1411
+ async function assertEditorContainerRunning(cfg) {
1412
+ const repoName = resolveProject(cfg).name;
1413
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
1414
+ const probe = runGcloud(
1415
+ buildGcloudArgs("ssh-probe", cfg, {
1416
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
1417
+ }),
1418
+ { stdio: ["ignore", "pipe", "ignore"] },
1419
+ );
1420
+ if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
1421
+ throw new Error(
1422
+ `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
1423
+ `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
1424
+ );
1425
+ }
957
1426
  }
958
1427
 
959
1428
  /** Read the laptop's git author identity (`user.name`, `user.email`) so
@@ -991,7 +1460,7 @@ function resolveGithubToken() {
991
1460
  return "";
992
1461
  }
993
1462
 
994
- /** A `read:packages`-scoped token the VM bootstrap uses to `docker login
1463
+ /** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
995
1464
  * ghcr.io` and pull the PRIVATE prebuilt cloud-smoke base + branch-binary
996
1465
  * packages. Prefer an explicit CODEYAM_GHCR_TOKEN (the operator's scoped
997
1466
  * fleet secret); fall back to the same GitHub token already used for the
@@ -999,11 +1468,29 @@ function resolveGithubToken() {
999
1468
  * in the common fleet setup. Empty string when neither is available — the
1000
1469
  * bootstrap then skips login and the private pulls fall back to a source
1001
1470
  * build (today's behavior, never worse). Threaded into gcp-bootstrap.sh via
1002
- * the env channel in ensureBootstrapped; never passed as an argv. */
1471
+ * the env channel in ensureBootstrapped; never passed as an argv.
1472
+ *
1473
+ * This token is NOT sufficient for the post-compile PUBLISH — a `docker push`
1474
+ * needs write:packages. Reusing it for the push is exactly the 403 that kept
1475
+ * publish-on-compile from ever priming the fast path. Use resolveGhcrPushToken
1476
+ * for that. */
1003
1477
  function resolveGhcrToken() {
1004
1478
  return process.env.CODEYAM_GHCR_TOKEN || resolveGithubToken();
1005
1479
  }
1006
1480
 
1481
+ /** A WRITE-scoped (`write:packages`) token used ONLY for the post-compile
1482
+ * publish — `publish_compiled_binary`'s `docker push` of the just-built
1483
+ * binary. Deliberately has NO fallback to the read/clone token: a `docker
1484
+ * push` with a read-only token 403s, so silently reusing the read token would
1485
+ * reintroduce the exact failure that kept the fast path from priming. Returns
1486
+ * the empty string when CODEYAM_GHCR_PUSH_TOKEN is unset — the bootstrap then
1487
+ * SKIPS the publish loudly (no doomed push, no 403 spam) and the next VM
1488
+ * simply recompiles that commit. Threaded into gcp-bootstrap.sh via the env
1489
+ * channel in ensureBootstrapped; never passed as an argv. */
1490
+ function resolveGhcrPushToken() {
1491
+ return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
1492
+ }
1493
+
1007
1494
  /** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
1008
1495
  * the VM so the cloud compose can mount it into /root/.git-credentials —
1009
1496
  * without this, claude / codex / gemini inside the container can clone the
@@ -1121,37 +1608,76 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
1121
1608
  async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1122
1609
  const repoName = resolveProject(cfg).name;
1123
1610
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1124
- const seed = resolveSeedPreviewOrigin(
1125
- cfg.instanceName,
1126
- readCloudflaredUrls(),
1127
- appPort,
1128
- );
1611
+ const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
1129
1612
 
1613
+ // One round-trip reads both the current editor.local.json (for the
1614
+ // skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
1615
+ // decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
1616
+ // separator-delimited cat.
1130
1617
  const read = runGcloud(
1131
1618
  buildGcloudArgs("ssh-probe", cfg, {
1132
- command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
1619
+ command:
1620
+ `cat ${remotePath} 2>/dev/null || echo '{}'; ` +
1621
+ `echo '<<<STACK>>>'; ` +
1622
+ `cat ${stackPath} 2>/dev/null || echo '{}'`,
1133
1623
  }),
1134
1624
  { stdio: ["ignore", "pipe", "ignore"] },
1135
1625
  );
1136
- let current = {};
1137
- if (read.status === 0) {
1626
+ const parseObj = (s) => {
1138
1627
  try {
1139
- const parsed = JSON.parse(read.stdout);
1140
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1141
- current = parsed;
1142
- }
1628
+ const v = JSON.parse(s);
1629
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1143
1630
  } catch {
1144
1631
  // Unparseable remote content is treated as `{}` — overwriting a
1145
1632
  // corrupt local override file with a known-good one is the safer
1146
1633
  // move than refusing to seed at all.
1634
+ return {};
1147
1635
  }
1636
+ };
1637
+ let current = {};
1638
+ let sameOriginSafe = true;
1639
+ if (read.status === 0 && typeof read.stdout === "string") {
1640
+ const [localJson = "", stackJson = ""] = read.stdout.split("<<<STACK>>>");
1641
+ current = parseObj(localJson);
1642
+ sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
1148
1643
  }
1149
- if (typeof current.previewOrigin === "string" && current.previewOrigin.length > 0) {
1644
+
1645
+ const seed = resolveSeedPreviewOrigin(
1646
+ cfg.instanceName,
1647
+ readCloudflaredUrls(),
1648
+ appPort,
1649
+ process.env,
1650
+ sameOriginSafe,
1651
+ );
1652
+
1653
+ // Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
1654
+ // is normally a user pin or a good prior seed and is left untouched. The one
1655
+ // exception is a ROTTED managed cloudflared quick-tunnel URL on a
1656
+ // same-origin-safe VM (the VM-1/2/6 leftover from before the improve38
1657
+ // same-origin migration): that frozen URL points at a dead rotated tunnel
1658
+ // (NXDOMAIN / blank preview), so we heal it back to the same-origin path
1659
+ // instead of preserving it. Because every dashboard action (Reset / Update /
1660
+ // Add / bootstrap-recovery) re-runs this seed over cloud:up, the heal reaches
1661
+ // every touch-point without a separate per-VM hand-fix.
1662
+ const originAlreadySet =
1663
+ typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
1664
+ const healing =
1665
+ originAlreadySet && isRottedManagedPreviewOrigin(current, sameOriginSafe);
1666
+ if (originAlreadySet && !healing) {
1150
1667
  return;
1151
1668
  }
1152
1669
 
1153
- console.error(`Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`);
1154
- const merged = deepMergeJson(current, seed);
1670
+ // On heal, also DROP the legacy `_managedPreviewOrigin` marker (deepMergeJson
1671
+ // deletes any key whose overlay value is null) — the same-origin path is a
1672
+ // constant the retired monitor push has nothing to manage, exactly matching
1673
+ // the manual `config-override --unset _managedPreviewOrigin` fix.
1674
+ const overlay = healing ? { ...seed, _managedPreviewOrigin: null } : seed;
1675
+ console.error(
1676
+ healing
1677
+ ? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
1678
+ : `Seeding previewOrigin=${seed.previewOrigin} into ${remotePath}...`,
1679
+ );
1680
+ const merged = deepMergeJson(current, overlay);
1155
1681
  const mergedJson = JSON.stringify(merged, null, 2);
1156
1682
  const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
1157
1683
  const write = runGcloud(
@@ -1165,20 +1691,35 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
1165
1691
  }
1166
1692
  }
1167
1693
 
1694
+ /** Write the chosen agent provider and local API keys into editor.local.json on
1695
+ * the VM BEFORE compose up so the file exists when the bind-mount happens —
1696
+ * the provider seed MUST run before ensureEditorContainerUp, because the editor
1697
+ * caches config.provider at startup and reports it as currentProvider; a post-start
1698
+ * seed leaves the running editor (and therefore the dashboard badge) on the default
1699
+ * "claude" until a config reload. */
1168
1700
  async function seedAgentApiKey(cfg) {
1701
+ // The provider seed is unconditional — `buildEditorLocalEnvPatch` always
1702
+ // returns a patch carrying `provider`, so the chosen agent always lands on
1703
+ // the VM. The API-key seed is opportunistic: when the matching key isn't set
1704
+ // locally the patch carries `provider` only, and we still print the
1705
+ // interactive-auth fallback line so the operator knows to authenticate the
1706
+ // agent on the VM later (Gemini's normal path).
1169
1707
  const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
1170
- if (!patch) {
1708
+ const hasKey = Boolean(patch.env);
1709
+ if (!hasKey) {
1171
1710
  console.error(
1172
- `\nNo ${agentEnvVarName(cfg.agent)} set locally — skipping agent auth seed.\n` +
1711
+ `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
1173
1712
  `Authenticate ${cfg.agent} later with:\n` +
1174
1713
  ` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
1175
1714
  `--tunnel-through-iap -- ${cfg.agent} auth login\n`,
1176
1715
  );
1177
- return;
1178
1716
  }
1179
1717
  const repoName = resolveProject(cfg).name;
1180
1718
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
1181
- console.error(`Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`);
1719
+ const seedLabel = hasKey
1720
+ ? `Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`
1721
+ : `Seeding provider '${cfg.agent}' into ${remotePath}...`;
1722
+ console.error(seedLabel);
1182
1723
 
1183
1724
  const read = runGcloud(
1184
1725
  buildGcloudArgs("ssh-probe", cfg, {
@@ -1211,6 +1752,75 @@ async function seedAgentApiKey(cfg) {
1211
1752
  if (write.status !== 0) {
1212
1753
  throw new Error(`Failed to write ${remotePath} on VM`);
1213
1754
  }
1755
+
1756
+ await ensureAgentCliInstalled(cfg);
1757
+ }
1758
+
1759
+ /** Install the selected agent's CLI inside the editor container on the live VM */
1760
+ async function ensureAgentCliInstalled(cfg) {
1761
+ const agent = cfg.agent;
1762
+ const pkg = agentNpmPackage(agent);
1763
+ const container = process.env.CONTAINER || "codeyam-editor-editor-1";
1764
+
1765
+ // First, check if the container is running on the VM host.
1766
+ // If the container is not running, we do nothing and return.
1767
+ const checkProbe = runGcloud(
1768
+ buildGcloudArgs("ssh-probe", cfg, {
1769
+ command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
1770
+ }),
1771
+ { stdio: ["ignore", "pipe", "ignore"] },
1772
+ );
1773
+
1774
+ const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
1775
+ if (!isRunning) {
1776
+ console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
1777
+ return;
1778
+ }
1779
+
1780
+ console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
1781
+
1782
+ const containerScript = [
1783
+ "#!/usr/bin/env bash",
1784
+ "set -euo pipefail",
1785
+ `BINARY="${agent}"`,
1786
+ `PACKAGE="${pkg}"`,
1787
+ `AGENT="${agent}"`,
1788
+ "",
1789
+ "if command -v \"$BINARY\" >/dev/null 2>&1; then",
1790
+ " if [ \"$AGENT\" = \"claude\" ]; then",
1791
+ " if [ -x /usr/local/bin/claude ]; then",
1792
+ " echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
1793
+ " exit 0",
1794
+ " fi",
1795
+ " else",
1796
+ " echo \" ✓ $BINARY already installed on PATH\"",
1797
+ " exit 0",
1798
+ " fi",
1799
+ "fi",
1800
+ "",
1801
+ "echo \"Installing $PACKAGE globally in container...\"",
1802
+ "if [ \"$AGENT\" = \"claude\" ]; then",
1803
+ " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
1804
+ " npm install -g \"$PACKAGE\"",
1805
+ " ln -sf /usr/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude /usr/local/bin/claude",
1806
+ "else",
1807
+ " npm install -g \"$PACKAGE\"",
1808
+ "fi",
1809
+ "echo \" ✓ Successfully installed $BINARY\"",
1810
+ ].join("\n");
1811
+
1812
+ const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
1813
+
1814
+ const installCmd = runGcloud(
1815
+ buildGcloudArgs("ssh-probe", cfg, {
1816
+ command: hostCommand,
1817
+ }),
1818
+ { stdio: ["ignore", "inherit", "inherit"] }
1819
+ );
1820
+
1821
+ if (installCmd.status !== 0) {
1822
+ throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
1823
+ }
1214
1824
  }
1215
1825
 
1216
1826
  async function openTunnel(cfg, appPort = VITE_PORT) {
@@ -1268,24 +1878,55 @@ async function cmdUp(args) {
1268
1878
  assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
1269
1879
 
1270
1880
  await ensureFirewallRule(cfg);
1271
- await ensureInstance(cfg);
1272
- await ensureBootstrapped(cfg);
1273
- await ensureProjectBootstrapped(cfg);
1274
- // Resolve the app's dev-server port from the VM's now-bootstrapped config so
1275
- // the previewOrigin seed and the laptop tunnel both target it (not 5173).
1276
- const appPort = await readVmAppPort(cfg);
1277
- await seedPreviewOrigin(cfg, appPort);
1278
- // Write the GitHub token BEFORE compose up so the credentials file exists
1279
- // when the bind-mount happens — bind-mounting a missing file would silently
1280
- // create a directory instead and the agent's first `git pull` would fail.
1281
- await seedGithubCredentials(cfg);
1282
- await ensureEditorContainerUp(cfg);
1283
- await seedAgentApiKey(cfg);
1284
- // Roster the VM into the fleet dashboard NOW that it's provisioned and the
1285
- // editor container is up. Runs BEFORE the --no-tunnel early-return so the
1286
- // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1287
- // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1288
- rosterProvisionedVm(cfg.instanceName);
1881
+ const { createdThisRun } = await ensureInstance(cfg);
1882
+
1883
+ try {
1884
+ await ensureBootstrapped(cfg);
1885
+ await ensureProjectBootstrapped(cfg);
1886
+ // Resolve the app's dev-server port from the VM's now-bootstrapped config so
1887
+ // the previewOrigin seed and the laptop tunnel both target it (not 5173).
1888
+ const appPort = await readVmAppPort(cfg);
1889
+ await seedPreviewOrigin(cfg, appPort);
1890
+ // Write the GitHub token BEFORE compose up so the credentials file exists
1891
+ // when the bind-mount happens — bind-mounting a missing file would silently
1892
+ // create a directory instead and the agent's first `git pull` would fail.
1893
+ await seedGithubCredentials(cfg);
1894
+ await seedAgentApiKey(cfg);
1895
+ await ensureEditorContainerUp(cfg);
1896
+ // Roster the VM into the fleet dashboard NOW that it's provisioned and the
1897
+ // editor container is up. Runs BEFORE the --no-tunnel early-return so the
1898
+ // dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
1899
+ // with the dashboard's onSuccess roster — a double-add is a harmless no-op).
1900
+ rosterProvisionedVm(cfg.instanceName);
1901
+ // Terminal gate: provisioning is NOT "done" until the editor container is
1902
+ // verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
1903
+ // own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
1904
+ // us — a run reported success with no container). A failure here is a loud
1905
+ // non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
1906
+ await assertEditorContainerRunning(cfg);
1907
+ } catch (err) {
1908
+ if (createdThisRun) {
1909
+ console.error(
1910
+ `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
1911
+ );
1912
+ try {
1913
+ console.error(`Deleting instance ${cfg.instanceName}...`);
1914
+ const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
1915
+ if (del.status !== 0) {
1916
+ console.error(
1917
+ ` Warning: failed to delete instance ${cfg.instanceName} during abort teardown. ` +
1918
+ `Please delete it manually with 'npm run cloud:destroy'.`,
1919
+ );
1920
+ }
1921
+ } catch (delErr) {
1922
+ console.error(
1923
+ ` Warning: error deleting instance ${cfg.instanceName} during abort teardown: ${delErr.message}. ` +
1924
+ `Please delete it manually with 'npm run cloud:destroy'.`,
1925
+ );
1926
+ }
1927
+ }
1928
+ throw err;
1929
+ }
1289
1930
  // --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
1290
1931
  // per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
1291
1932
  // hanging on a tunnel, and — critically for parallel multi-add — avoids N
@@ -1466,7 +2107,10 @@ async function cloudMain() {
1466
2107
  module.exports = {
1467
2108
  // pure helpers
1468
2109
  agentEnvVarName,
2110
+ agentNpmPackage,
1469
2111
  AGENT_ENV_VARS,
2112
+ AGENT_NPM_PACKAGES,
2113
+ ensureAgentCliInstalled,
1470
2114
  KNOWN_FLAGS,
1471
2115
  FIREWALL_RULE_NAME,
1472
2116
  INSTANCE_TAG,
@@ -1485,12 +2129,15 @@ module.exports = {
1485
2129
  rosterProvisionedVm,
1486
2130
  resolveConfiguredAppPort,
1487
2131
  resolveSeedPreviewOrigin,
2132
+ previewSameOriginSafeFromStack,
2133
+ isRottedManagedPreviewOrigin,
1488
2134
  resolveEditorConfig,
1489
2135
  validateEditorVariant,
1490
2136
  assertVariantProvisionable,
1491
2137
  editorImageBootstrapArgs,
1492
2138
  reconcileProjectExclusivity,
1493
2139
  resolveGhcrToken,
2140
+ resolveGhcrPushToken,
1494
2141
  resolveProject,
1495
2142
  clientProjectBootstrapArgs,
1496
2143
  cloudConfigPath,
@@ -1498,12 +2145,28 @@ module.exports = {
1498
2145
  saveCloudConfig,
1499
2146
  buildEditorLocalEnvPatch,
1500
2147
  buildEditorLocalSeedWriteCommand,
2148
+ seedPreviewOrigin,
1501
2149
  buildGcloudArgs,
1502
2150
  sshScriptCommand,
2151
+ bootstrapRunnerScript,
2152
+ bootstrapLaunchCommand,
2153
+ bootstrapFollowCommand,
2154
+ bootstrapStatusCommand,
2155
+ parseBootstrapStatus,
2156
+ decideBootstrapOutcome,
2157
+ projectBootstrapDoneMarker,
2158
+ projectBootstrapDoneProbeCommand,
2159
+ SSH_KEEPALIVE_FLAGS,
1503
2160
  resolveSetupForwardEnv,
1504
2161
  FORWARD_ENV_KEYS_ENV,
1505
2162
  parseEditorComposeState,
1506
2163
  fetchInstanceState,
2164
+ formatGcloudFailure,
2165
+ isTransientSshFailure,
2166
+ runGcloudSshWithRetry,
2167
+ ensureInstance,
2168
+ ensureBootstrapped,
2169
+ assertEditorContainerRunning,
1507
2170
  // side-effecting handlers
1508
2171
  cmdUp,
1509
2172
  cmdTunnel,