@pleri/olam-cli 0.1.180 → 0.1.185

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 (209) hide show
  1. package/dist/agent-stream/agent-sdk-to-chunks.js +44 -30
  2. package/dist/ask/checkout.d.ts +19 -0
  3. package/dist/ask/checkout.d.ts.map +1 -0
  4. package/dist/ask/checkout.js +40 -0
  5. package/dist/ask/checkout.js.map +1 -0
  6. package/dist/ask/knowledge-pack-builder.d.ts +72 -0
  7. package/dist/ask/knowledge-pack-builder.d.ts.map +1 -0
  8. package/dist/ask/knowledge-pack-builder.js +91 -0
  9. package/dist/ask/knowledge-pack-builder.js.map +1 -0
  10. package/dist/ask/knowledge-pack.generated.d.ts +8 -0
  11. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -0
  12. package/dist/ask/knowledge-pack.generated.js +1947 -0
  13. package/dist/ask/knowledge-pack.generated.js.map +1 -0
  14. package/dist/ask/one-shot.d.ts +21 -0
  15. package/dist/ask/one-shot.d.ts.map +1 -0
  16. package/dist/ask/one-shot.js +50 -0
  17. package/dist/ask/one-shot.js.map +1 -0
  18. package/dist/ask/repl.d.ts +30 -0
  19. package/dist/ask/repl.d.ts.map +1 -0
  20. package/dist/ask/repl.js +109 -0
  21. package/dist/ask/repl.js.map +1 -0
  22. package/dist/ask/sdk-client.d.ts +87 -0
  23. package/dist/ask/sdk-client.d.ts.map +1 -0
  24. package/dist/ask/sdk-client.js +118 -0
  25. package/dist/ask/sdk-client.js.map +1 -0
  26. package/dist/ask/system-prompt.d.ts +30 -0
  27. package/dist/ask/system-prompt.d.ts.map +1 -0
  28. package/dist/ask/system-prompt.js +31 -0
  29. package/dist/ask/system-prompt.js.map +1 -0
  30. package/dist/commands/ask.d.ts +27 -0
  31. package/dist/commands/ask.d.ts.map +1 -0
  32. package/dist/commands/ask.js +63 -0
  33. package/dist/commands/ask.js.map +1 -0
  34. package/dist/commands/auth-list-json.d.ts +53 -0
  35. package/dist/commands/auth-list-json.d.ts.map +1 -0
  36. package/dist/commands/auth-list-json.js +47 -0
  37. package/dist/commands/auth-list-json.js.map +1 -0
  38. package/dist/commands/auth.d.ts.map +1 -1
  39. package/dist/commands/auth.js +80 -19
  40. package/dist/commands/auth.js.map +1 -1
  41. package/dist/commands/config.d.ts.map +1 -1
  42. package/dist/commands/config.js +93 -0
  43. package/dist/commands/config.js.map +1 -1
  44. package/dist/commands/destroy.d.ts +41 -0
  45. package/dist/commands/destroy.d.ts.map +1 -1
  46. package/dist/commands/destroy.js +81 -33
  47. package/dist/commands/destroy.js.map +1 -1
  48. package/dist/commands/dispatch-resolve.d.ts +54 -0
  49. package/dist/commands/dispatch-resolve.d.ts.map +1 -0
  50. package/dist/commands/dispatch-resolve.js +105 -0
  51. package/dist/commands/dispatch-resolve.js.map +1 -0
  52. package/dist/commands/dispatch.d.ts.map +1 -1
  53. package/dist/commands/dispatch.js +40 -9
  54. package/dist/commands/dispatch.js.map +1 -1
  55. package/dist/commands/doctor.js +11 -11
  56. package/dist/commands/doctor.js.map +1 -1
  57. package/dist/commands/flywheel/k5-validate.d.ts +31 -0
  58. package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
  59. package/dist/commands/flywheel/k5-validate.js +80 -19
  60. package/dist/commands/flywheel/k5-validate.js.map +1 -1
  61. package/dist/commands/keys-list-json.d.ts +55 -0
  62. package/dist/commands/keys-list-json.d.ts.map +1 -0
  63. package/dist/commands/keys-list-json.js +54 -0
  64. package/dist/commands/keys-list-json.js.map +1 -0
  65. package/dist/commands/keys.d.ts.map +1 -1
  66. package/dist/commands/keys.js +6 -0
  67. package/dist/commands/keys.js.map +1 -1
  68. package/dist/commands/kg-classify.d.ts.map +1 -1
  69. package/dist/commands/kg-classify.js +20 -0
  70. package/dist/commands/kg-classify.js.map +1 -1
  71. package/dist/commands/kg-doctor.d.ts +67 -6
  72. package/dist/commands/kg-doctor.d.ts.map +1 -1
  73. package/dist/commands/kg-doctor.js +126 -46
  74. package/dist/commands/kg-doctor.js.map +1 -1
  75. package/dist/commands/lanes-list-json.d.ts +69 -0
  76. package/dist/commands/lanes-list-json.d.ts.map +1 -0
  77. package/dist/commands/lanes-list-json.js +42 -0
  78. package/dist/commands/lanes-list-json.js.map +1 -0
  79. package/dist/commands/lanes.d.ts.map +1 -1
  80. package/dist/commands/lanes.js +18 -7
  81. package/dist/commands/lanes.js.map +1 -1
  82. package/dist/commands/list.d.ts +27 -0
  83. package/dist/commands/list.d.ts.map +1 -1
  84. package/dist/commands/list.js +67 -19
  85. package/dist/commands/list.js.map +1 -1
  86. package/dist/commands/memory/status.d.ts +18 -0
  87. package/dist/commands/memory/status.d.ts.map +1 -1
  88. package/dist/commands/memory/status.js +38 -2
  89. package/dist/commands/memory/status.js.map +1 -1
  90. package/dist/commands/memory-service-container.d.ts +44 -0
  91. package/dist/commands/memory-service-container.d.ts.map +1 -1
  92. package/dist/commands/memory-service-container.js +49 -0
  93. package/dist/commands/memory-service-container.js.map +1 -1
  94. package/dist/commands/plans-list-json.d.ts +77 -0
  95. package/dist/commands/plans-list-json.d.ts.map +1 -0
  96. package/dist/commands/plans-list-json.js +61 -0
  97. package/dist/commands/plans-list-json.js.map +1 -0
  98. package/dist/commands/plans.d.ts.map +1 -1
  99. package/dist/commands/plans.js +10 -0
  100. package/dist/commands/plans.js.map +1 -1
  101. package/dist/commands/ps.d.ts +32 -0
  102. package/dist/commands/ps.d.ts.map +1 -1
  103. package/dist/commands/ps.js +34 -0
  104. package/dist/commands/ps.js.map +1 -1
  105. package/dist/commands/repos-list-json.d.ts +58 -0
  106. package/dist/commands/repos-list-json.d.ts.map +1 -0
  107. package/dist/commands/repos-list-json.js +45 -0
  108. package/dist/commands/repos-list-json.js.map +1 -0
  109. package/dist/commands/repos.d.ts +1 -1
  110. package/dist/commands/repos.d.ts.map +1 -1
  111. package/dist/commands/repos.js +12 -2
  112. package/dist/commands/repos.js.map +1 -1
  113. package/dist/commands/runbooks.d.ts +32 -0
  114. package/dist/commands/runbooks.d.ts.map +1 -1
  115. package/dist/commands/runbooks.js +79 -22
  116. package/dist/commands/runbooks.js.map +1 -1
  117. package/dist/commands/services.d.ts +47 -1
  118. package/dist/commands/services.d.ts.map +1 -1
  119. package/dist/commands/services.js +59 -33
  120. package/dist/commands/services.js.map +1 -1
  121. package/dist/commands/skills-source.d.ts.map +1 -1
  122. package/dist/commands/skills-source.js +77 -2
  123. package/dist/commands/skills-source.js.map +1 -1
  124. package/dist/commands/skills.d.ts +27 -0
  125. package/dist/commands/skills.d.ts.map +1 -1
  126. package/dist/commands/skills.js +17 -2
  127. package/dist/commands/skills.js.map +1 -1
  128. package/dist/commands/upgrade-history.d.ts +0 -2
  129. package/dist/commands/upgrade-history.d.ts.map +1 -1
  130. package/dist/commands/upgrade-history.js +0 -6
  131. package/dist/commands/upgrade-history.js.map +1 -1
  132. package/dist/commands/upgrade-lock.d.ts +0 -9
  133. package/dist/commands/upgrade-lock.d.ts.map +1 -1
  134. package/dist/commands/upgrade-lock.js +1 -1
  135. package/dist/commands/upgrade-lock.js.map +1 -1
  136. package/dist/commands/workspace-list-json.d.ts +73 -0
  137. package/dist/commands/workspace-list-json.d.ts.map +1 -0
  138. package/dist/commands/workspace-list-json.js +59 -0
  139. package/dist/commands/workspace-list-json.js.map +1 -0
  140. package/dist/commands/workspace.d.ts.map +1 -1
  141. package/dist/commands/workspace.js +7 -1
  142. package/dist/commands/workspace.js.map +1 -1
  143. package/dist/commands/world-snapshot.d.ts +13 -0
  144. package/dist/commands/world-snapshot.d.ts.map +1 -1
  145. package/dist/commands/world-snapshot.js +81 -1
  146. package/dist/commands/world-snapshot.js.map +1 -1
  147. package/dist/commands/yolo.d.ts +0 -4
  148. package/dist/commands/yolo.d.ts.map +1 -1
  149. package/dist/commands/yolo.js +2 -2
  150. package/dist/commands/yolo.js.map +1 -1
  151. package/dist/image-digests.json +8 -8
  152. package/dist/index.js +6097 -2563
  153. package/dist/index.js.map +1 -1
  154. package/dist/lib/anthropic-base-url-file.d.ts +37 -0
  155. package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
  156. package/dist/lib/anthropic-base-url-file.js +46 -0
  157. package/dist/lib/anthropic-base-url-file.js.map +1 -0
  158. package/dist/lib/auth-remote.d.ts +9 -0
  159. package/dist/lib/auth-remote.d.ts.map +1 -1
  160. package/dist/lib/auth-remote.js +19 -4
  161. package/dist/lib/auth-remote.js.map +1 -1
  162. package/dist/lib/cf-access-token.d.ts +32 -0
  163. package/dist/lib/cf-access-token.d.ts.map +1 -0
  164. package/dist/lib/cf-access-token.js +52 -0
  165. package/dist/lib/cf-access-token.js.map +1 -0
  166. package/dist/lib/config.d.ts +17 -3
  167. package/dist/lib/config.d.ts.map +1 -1
  168. package/dist/lib/config.js +28 -4
  169. package/dist/lib/config.js.map +1 -1
  170. package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
  171. package/dist/lib/k8s-bootstrap.js +13 -1
  172. package/dist/lib/k8s-bootstrap.js.map +1 -1
  173. package/dist/lib/k8s-secret-render.d.ts +2 -0
  174. package/dist/lib/k8s-secret-render.d.ts.map +1 -1
  175. package/dist/lib/k8s-secret-render.js +27 -0
  176. package/dist/lib/k8s-secret-render.js.map +1 -1
  177. package/dist/lib/kubectl-context.d.ts +49 -0
  178. package/dist/lib/kubectl-context.d.ts.map +1 -1
  179. package/dist/lib/kubectl-context.js +64 -2
  180. package/dist/lib/kubectl-context.js.map +1 -1
  181. package/dist/lib/peripheral-registry.d.ts +1 -1
  182. package/dist/lib/peripheral-registry.d.ts.map +1 -1
  183. package/dist/lib/peripheral-registry.js +13 -0
  184. package/dist/lib/peripheral-registry.js.map +1 -1
  185. package/dist/lib/upgrade-kubernetes.d.ts +13 -0
  186. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  187. package/dist/lib/upgrade-kubernetes.js +42 -9
  188. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  189. package/dist/mcp-server.js +2624 -1041
  190. package/hermes-bundle/version.json +1 -1
  191. package/host-cp/k8s/manifests/30-configmap.yaml +11 -6
  192. package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
  193. package/host-cp/k8s/manifests/50-deployment.yaml +15 -1
  194. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  195. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  196. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  197. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  198. package/host-cp/k8s/templates/chunks-postgres-secret-template.yaml +24 -0
  199. package/host-cp/k8s/templates/plan-chat-service-secret-template.yaml +35 -0
  200. package/host-cp/observability/trace-summary.mjs +267 -0
  201. package/host-cp/src/bootstrap-selective.mjs +30 -28
  202. package/host-cp/src/host-stream.mjs +52 -0
  203. package/host-cp/src/plan-chat-service.mjs +99 -74
  204. package/host-cp/src/redirect.mjs +7 -0
  205. package/host-cp/src/router.mjs +168 -0
  206. package/host-cp/src/serve-only-config.mjs +85 -0
  207. package/host-cp/src/server.mjs +482 -217
  208. package/host-cp/src/world-services.mjs +136 -0
  209. package/package.json +4 -2
@@ -45,13 +45,14 @@ import {
45
45
  createNdjsonSpanSink,
46
46
  attachBetaResponseEvents,
47
47
  } from '../observability/ndjson-span-sink.mjs';
48
- import { betaResponseEmitter } from '@olam/auth-client';
48
+ import { betaResponseEmitter, cfAccessHeaders } from '@olam/auth-client';
49
49
  import { attemptRecovery, findScenarioForKind } from '../recovery/index.mjs';
50
50
  import { detectHaltChunk } from './halt-detect.mjs';
51
51
  import { evaluateRedirect, applyRedirect } from './redirect.mjs';
52
52
  import { spawnUpgraderContainer } from './upgrade-spawner.mjs';
53
53
  import { isPlanningPath } from './bootstrap-selective.mjs';
54
54
  import { parseProxyPath, perWorldBase, proxyToWorld } from './proxy.mjs';
55
+ import { fetchWorldServices as fetchWorldServicesImpl } from './world-services.mjs';
55
56
  import { resolveHostCpEngine } from './engine-identity.mjs';
56
57
  import { StartupToken } from './auth.mjs';
57
58
  import { SseGate, isSsePath, wireRelease } from './sse-gate.mjs';
@@ -94,7 +95,9 @@ import {
94
95
  } from './routes/process-port.mjs';
95
96
  import { instrumentHandler, renderMetrics } from './metrics.mjs';
96
97
  import { handleDispatchFromEmail } from './lib/email-dispatch.mjs';
98
+ import { handleDispatchFromLinear } from './lib/linear-dispatch.mjs';
97
99
  import { emitTierSuggestion } from '../dispatch/auto-tier-scheduler.mjs';
100
+ import { isServeOnly, isOrchestrationRoute, ORCHESTRATION_UNAVAILABLE } from './serve-only-config.mjs';
98
101
 
99
102
  // ── Deployment-mode detection ─────────────────────────────────────
100
103
  //
@@ -113,6 +116,17 @@ const HOST_CP_MODE = process.env.OLAM_HOST_CP_MODE
113
116
  ?? (fs.existsSync('/.dockerenv') ? 'container' : 'bare');
114
117
  const WORLD_HOST = HOST_CP_MODE === 'container' ? 'host.docker.internal' : '127.0.0.1';
115
118
 
119
+ // SERVE-ONLY mode (host-cp-gke-serve-only-mode Phase A). When
120
+ // OLAM_HOST_CP_SERVE_ONLY=true, host-cp serves plan-chat-spa + host-native
121
+ // `/api/*` only: NO docker transport connect, NO world discovery, NO
122
+ // PlanOrchestrator docker wiring, NO pr-merge-poller docker/repo deps.
123
+ // World-orchestration routes return a structured 503. Defaults OFF — FULL
124
+ // (local docker/k3d) mode is byte-for-byte unchanged. See
125
+ // ./serve-only-config.mjs for the pure gate decision (unit-tested there;
126
+ // server.mjs can't be imported in a test because it binds a port + connects
127
+ // docker at module load).
128
+ const SERVE_ONLY = isServeOnly(process.env);
129
+
116
130
  // Container-engine identity, surfaced to olam-cli via the X-Olam-Engine
117
131
  // response header on /health. Resolution lives in engine-identity.mjs so
118
132
  // unit tests can import the pure function without triggering server startup.
@@ -177,6 +191,18 @@ const OLAM_EMAIL_ATTACHMENTS_ROOT =
177
191
  (HOST_CP_MODE === 'container'
178
192
  ? '/data/email-attachments'
179
193
  : path.join(os.homedir(), '.olam', 'email-attachments'));
194
+ // Linear-webhook trigger (POST /v1/dispatch-from-linear).
195
+ // OLAM_LINEAR_WEBHOOK_SECRET — shared secret set in Linear webhook settings.
196
+ // OLAM_LINEAR_WORLD_ID — optional; when set, route events to this worldId
197
+ // instead of spawning a new world each time.
198
+ // See docs/architecture/linear-as-trigger.md.
199
+ const OLAM_LINEAR_WEBHOOK_SECRET = process.env.OLAM_LINEAR_WEBHOOK_SECRET ?? '';
200
+ const OLAM_LINEAR_WORLD_ID = process.env.OLAM_LINEAR_WORLD_ID ?? '';
201
+
202
+ // In-flight delivery IDs for deduplication. Linear retries on non-2xx;
203
+ // we MUST return 2xx for duplicates so retries terminate. Never use 409.
204
+ const linearInFlight = new Set();
205
+
180
206
  const WORLD_NAMES_PATH =
181
207
  process.env.OLAM_WORLD_NAMES_PATH ??
182
208
  (HOST_CP_MODE === 'container'
@@ -232,9 +258,28 @@ async function refreshVersionSnapshot() {
232
258
  }
233
259
  }
234
260
 
235
- // Kick off an initial check immediately, then poll every 60s.
236
- refreshVersionSnapshot();
237
- const versionPollTimer = setInterval(refreshVersionSnapshot, VERSION_POLL_INTERVAL_MS);
261
+ // SERVE-ONLY: the version snapshot polls the operator-repo HEAD + docker
262
+ // image SHAs every 60s — neither exists on a managed cluster (buildVersionSnapshot
263
+ // is fail-soft and would return all-'unknown', but the docker fetches are futile).
264
+ // Seed a static all-'unknown' snapshot so GET /api/version/status returns 200
265
+ // 'unknown' (not 503 pending) and skip the poll. clearInterval(null) is a no-op.
266
+ const UNKNOWN_VERSION_SNAPSHOT = {
267
+ hostCp: { running: process.env.OLAM_BUILD_SHA ?? 'unknown', latest: 'unknown', upgradeAvailable: false },
268
+ authService: { running: 'unknown', latest: 'unknown', upgradeAvailable: false },
269
+ devbox: { running: 'unknown', latest: 'unknown', upgradeAvailable: false },
270
+ operatorHead: 'unknown',
271
+ checkedAt: new Date().toISOString(),
272
+ cliVersion: process.env.OLAM_CLI_VERSION ?? 'unknown',
273
+ };
274
+
275
+ let versionPollTimer = null;
276
+ if (SERVE_ONLY) {
277
+ versionSnapshot = UNKNOWN_VERSION_SNAPSHOT;
278
+ } else {
279
+ // Kick off an initial check immediately, then poll every 60s.
280
+ refreshVersionSnapshot();
281
+ versionPollTimer = setInterval(refreshVersionSnapshot, VERSION_POLL_INTERVAL_MS);
282
+ }
238
283
 
239
284
  // ── World registry — persistent + admin-managed ───────────────────────
240
285
  //
@@ -254,6 +299,61 @@ const REGISTRY_PATH =
254
299
  ? '/data/host-cp-registry.json'
255
300
  : path.join(os.homedir(), '.olam', 'host-cp-registry.json'));
256
301
 
302
+ /**
303
+ * Read the cloud-mode Anthropic proxy URL configured by the operator.
304
+ *
305
+ * Resolution order mirrors packages/adapters/src/shared/anthropic-base-url.ts
306
+ * and packages/auth-client/src/cloud-mode.ts:
307
+ * 1. OLAM_ANTHROPIC_BASE_URL env var
308
+ * 2. ~/.olam/anthropic-base-url file
309
+ * 3. ANTHROPIC_BASE_URL env var
310
+ * 4. '' (empty — skip injection)
311
+ *
312
+ * Called on each plan-creation request (not cached at startup) so operators
313
+ * can update the file without restarting host-cp.
314
+ *
315
+ * @returns {string}
316
+ */
317
+ function readAnthropicBaseUrl() {
318
+ const fromOlamEnv = process.env['OLAM_ANTHROPIC_BASE_URL'];
319
+ if (fromOlamEnv && fromOlamEnv.length > 0) return fromOlamEnv.trim();
320
+
321
+ try {
322
+ const file = path.join(os.homedir(), '.olam', 'anthropic-base-url');
323
+ const content = fs.readFileSync(file, 'utf-8').trim();
324
+ if (content.length > 0) return content;
325
+ } catch {
326
+ // file absent — fall through
327
+ }
328
+
329
+ const fromShellEnv = process.env['ANTHROPIC_BASE_URL'];
330
+ if (fromShellEnv && fromShellEnv.length > 0) return fromShellEnv.trim();
331
+
332
+ return '';
333
+ }
334
+
335
+ /**
336
+ * Resolve the default `repoUrl` to inject into cloud-dispatch bodies. The
337
+ * cloud Sandbox coding loop clones this repo into `/workspace/repo` before
338
+ * running claude. Source order: OLAM_DOGFOOD_REPO_URL env, then
339
+ * ~/.olam/dogfood-repo-url file (one bare URL, chmod 600). Absent → empty
340
+ * string → no injection (dispatch runs text-only, the pre-coding-default
341
+ * behaviour). Mirrors readAnthropicBaseUrl() so operators have ONE pattern
342
+ * for cloud-dispatch defaults.
343
+ */
344
+ function readDogfoodRepoUrl() {
345
+ const fromOlamEnv = process.env['OLAM_DOGFOOD_REPO_URL'];
346
+ if (fromOlamEnv && fromOlamEnv.length > 0) return fromOlamEnv.trim();
347
+ try {
348
+ const file = path.join(os.homedir(), '.olam', 'dogfood-repo-url');
349
+ const content = fs.readFileSync(file, 'utf-8').trim();
350
+ if (content.length > 0) return content;
351
+ } catch {
352
+ // file absent — fall through
353
+ }
354
+ return '';
355
+ }
356
+
257
357
  /** @type {Record<string, number>} */
258
358
  let WORLDS = {};
259
359
 
@@ -414,7 +514,11 @@ const prPoller = createPrMergePoller({
414
514
  pollIntervalMs: PR_POLL_INTERVAL_MS,
415
515
  gracePeriodMs: MERGE_GRACE_MS,
416
516
  });
417
- prPoller.start();
517
+ // SERVE-ONLY: pr-merge-poller polls GH for merged PRs then destroys worlds
518
+ // via docker. No docker on a managed cluster — don't start the poll loop.
519
+ // (The poller object is still constructed so the shutdown handler's
520
+ // prPoller.stop() stays a no-op; start() is the docker/repo-touching part.)
521
+ if (!SERVE_ONLY) prPoller.start();
418
522
 
419
523
  // ── Worlds-DB reconcile loop ────────────────────────────────────
420
524
  //
@@ -422,24 +526,31 @@ prPoller.start();
422
526
  // (e.g., host-cp started after `olam create`). This reconciler bridges
423
527
  // that gap: it reads worlds.db and registers any running worlds that
424
528
  // aren't already in WORLDS.
425
- const worldsDbReconciler = startWorldsDbReconciler({
426
- dbPath: WORLDS_DB_PATH,
427
- dockerHost: DOCKER_HOST,
428
- worldHost: WORLD_HOST,
429
- getRegistry: () => WORLDS,
430
- onWorldAdded: (id, port) => {
431
- WORLDS = { ...WORLDS, [id]: port };
432
- persistRegistry();
433
- },
434
- onWorldRemoved: (id) => {
435
- if (id in WORLDS) {
436
- const next = { ...WORLDS };
437
- delete next[id];
438
- WORLDS = next;
439
- persistRegistry();
440
- }
441
- },
442
- });
529
+ //
530
+ // SERVE-ONLY: reconciliation reads worlds.db + probes docker for each
531
+ // world's host port. No worlds.db / docker on a managed cluster — skip it;
532
+ // WORLDS stays empty. `null` sentinel keeps the shutdown handler's
533
+ // `worldsDbReconciler?.stop()` a safe no-op.
534
+ const worldsDbReconciler = SERVE_ONLY
535
+ ? null
536
+ : startWorldsDbReconciler({
537
+ dbPath: WORLDS_DB_PATH,
538
+ dockerHost: DOCKER_HOST,
539
+ worldHost: WORLD_HOST,
540
+ getRegistry: () => WORLDS,
541
+ onWorldAdded: (id, port) => {
542
+ WORLDS = { ...WORLDS, [id]: port };
543
+ persistRegistry();
544
+ },
545
+ onWorldRemoved: (id) => {
546
+ if (id in WORLDS) {
547
+ const next = { ...WORLDS };
548
+ delete next[id];
549
+ WORLDS = next;
550
+ persistRegistry();
551
+ }
552
+ },
553
+ });
443
554
 
444
555
  // ── Plan orchestrator (Phase 1 spike) ─────────────────────────────────────
445
556
  //
@@ -533,7 +644,10 @@ function scheduleServersSnapshot() {
533
644
  }, SERVERS_SNAPSHOT_DEBOUNCE_MS);
534
645
  }
535
646
 
536
- const stopEvents = subscribeDockerEvents({
647
+ // SERVE-ONLY: docker-events subscription opens a long-poll against the
648
+ // docker /events stream — no docker on a managed cluster. Skip the
649
+ // subscription; `stopEvents` is a no-op so the shutdown handler is safe.
650
+ const stopEvents = SERVE_ONLY ? () => {} : subscribeDockerEvents({
537
651
  dockerHost: DOCKER_HOST,
538
652
  onWorldRestart: (worldId) => {
539
653
  cache.invalidate(worldId);
@@ -895,6 +1009,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
895
1009
  header_name: 'authorization',
896
1010
  header_format: 'Bearer <token>',
897
1011
  hint: 'SPA: set document.cookie = `olam_host_cp_token=${token}; path=/; samesite=strict` then fetch (`/api/world/...`) freely.',
1012
+ cloud_enabled: Boolean(process.env.OLAM_CLOUD_URL && process.env.OLAM_SHOWCASE_PASSWORD),
898
1013
  });
899
1014
  }
900
1015
 
@@ -927,6 +1042,18 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
927
1042
  // Anything that doesn't match a static file falls through to the auth
928
1043
  // gate + 404 below (preserves the JSON-error contract for unknown
929
1044
  // /api/* paths).
1045
+ //
1046
+ // Phase A serve-only: world-ORCHESTRATION routes degrade to a structured
1047
+ // 503 BEFORE static-serve. This must run pre-static so (a) a GET
1048
+ // /v1/worlds/<id>/status can't be served the SPA HTML shell, and (b) a
1049
+ // POST /api/worlds/<id>/tunnels / DELETE /api/worlds/<id> mutation can't
1050
+ // execute (no docker on a managed cluster; honest degradation, not a
1051
+ // hollow shell). Method-aware: bare GET /api/worlds (list) is NOT blocked
1052
+ // (returns []). No-op in full mode (SERVE_ONLY false). See CP3 finding.
1053
+ if (SERVE_ONLY && isOrchestrationRoute(url.pathname, req.method)) {
1054
+ return jsonReply(res, 503, ORCHESTRATION_UNAVAILABLE);
1055
+ }
1056
+
930
1057
  if (req.method === 'GET' || req.method === 'HEAD') {
931
1058
  const served = await tryServeStatic(req, res, url.pathname);
932
1059
  if (served) return;
@@ -1690,6 +1817,13 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1690
1817
  }
1691
1818
  }
1692
1819
 
1820
+ // SERVE-ONLY: every `/api/world/<id>/...` route is world orchestration —
1821
+ // it needs docker (proxy to a per-world CP, ttyd, secret fetch, progress
1822
+ // probe). On a managed cluster there's no docker + WORLDS is empty, so all
1823
+ // (serve-only world-orchestration 503 is handled earlier, pre-static, by
1824
+ // the isOrchestrationRoute guard — it covers /api/world/, /api/worlds/<id>,
1825
+ // and /v1/worlds/ for all methods, so no per-route guard is needed here.)
1826
+
1693
1827
  // GET /api/world/<id>/progress — phase ladder progress for inbox row.
1694
1828
  const progressMatch = /^\/api\/world\/([^/?#]+)\/progress\/?$/.exec(url.pathname);
1695
1829
  if (progressMatch && req.method === 'GET') {
@@ -2349,6 +2483,62 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2349
2483
  return;
2350
2484
  }
2351
2485
 
2486
+ // POST /v1/dispatch-from-linear — Linear webhook receiver.
2487
+ //
2488
+ // Linear POSTs a JSON payload and an HMAC-SHA256 signature in
2489
+ // X-Linear-Signature. host-cp validates the signature, deduplicates
2490
+ // by webhookId (Linear retries on non-2xx — duplicates MUST return 202),
2491
+ // and either routes to a pinned world (OLAM_LINEAR_WORLD_ID) or returns
2492
+ // a spawn_pending descriptor for the MCP/CLI layer.
2493
+ //
2494
+ // Dedup rule: HTTP 202 { action: 'deduplicated' } — NEVER 409.
2495
+ // Body cap: 1 MiB (Linear payloads are small JSON, no attachments).
2496
+ if (url.pathname === '/v1/dispatch-from-linear' && req.method === 'POST') {
2497
+ const chunks = [];
2498
+ let size = 0;
2499
+ const MAX_BODY = 1 * 1024 * 1024;
2500
+ let aborted = false;
2501
+ req.on('data', (chunk) => {
2502
+ size += chunk.length;
2503
+ if (size > MAX_BODY) {
2504
+ aborted = true;
2505
+ jsonReply(res, 413, { error: 'body_too_large', maxBytes: MAX_BODY });
2506
+ req.destroy();
2507
+ return;
2508
+ }
2509
+ chunks.push(chunk);
2510
+ });
2511
+ req.on('end', async () => {
2512
+ if (aborted) return;
2513
+ const rawBody = Buffer.concat(chunks);
2514
+ const signature = req.headers['x-linear-signature'] ?? '';
2515
+ let payload;
2516
+ try {
2517
+ payload = JSON.parse(rawBody.toString('utf8') || '{}');
2518
+ } catch (err) {
2519
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
2520
+ }
2521
+ try {
2522
+ const result = await handleDispatchFromLinear({
2523
+ rawBody,
2524
+ payload,
2525
+ signature,
2526
+ secret: OLAM_LINEAR_WEBHOOK_SECRET,
2527
+ worlds: WORLDS,
2528
+ inFlight: linearInFlight,
2529
+ targetWorldId: OLAM_LINEAR_WORLD_ID || undefined,
2530
+ });
2531
+ return jsonReply(res, result.status, result.body);
2532
+ } catch (err) {
2533
+ return jsonReply(res, 500, {
2534
+ error: 'dispatch_failed',
2535
+ message: err instanceof Error ? err.message : String(err),
2536
+ });
2537
+ }
2538
+ });
2539
+ return;
2540
+ }
2541
+
2352
2542
  if (url.pathname === '/api/cloud-dispatch' && req.method === 'POST') {
2353
2543
  const cloudUrl = process.env.OLAM_CLOUD_URL;
2354
2544
  const showcasePw = process.env.OLAM_SHOWCASE_PASSWORD;
@@ -2374,6 +2564,47 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2374
2564
  // current SPA model; A11 vault-sync can refine the mapping).
2375
2565
  const planId = parsed.session_id ?? 'default';
2376
2566
  const basicAuth = Buffer.from(`operator:${showcasePw}`).toString('base64');
2567
+
2568
+ // SPA shape → plan-DO shape normalisation: the SPA posts a chat-shaped
2569
+ // body ({ messages: [{ role, content }] }) while plan-DO's /v1/dispatch
2570
+ // expects a top-level `prompt` string. Synthesise `prompt` from
2571
+ // `messages` when it is absent so both call shapes reach plan-DO intact.
2572
+ // Existing curl/CLI callers that already supply `prompt` are unaffected.
2573
+ if (Array.isArray(parsed.messages) && !parsed.prompt) {
2574
+ parsed = {
2575
+ ...parsed,
2576
+ prompt: parsed.messages.map((m) => m?.content ?? '').filter(Boolean).join('\n\n'),
2577
+ };
2578
+ // Keep `messages` too — plan-DO may use it for multi-turn fidelity later.
2579
+ }
2580
+
2581
+ // Gap 3: enrich the dispatch body with the operator's anthropicBaseUrl
2582
+ // so plan-DO can propagate it to spawned CF Sandbox child worlds.
2583
+ // Only injected when not already set by the SPA (SPA has no auth-worker
2584
+ // config knowledge — host-cp is the sole injection point).
2585
+ const anthropicBaseUrl = readAnthropicBaseUrl();
2586
+ // Sibling injection: default repoUrl for the cloud Sandbox coding loop.
2587
+ // The SPA has no repo-config knowledge today (Decision 2 in
2588
+ // olam-builds-olam-cloud-dogfood: no SPA repo-selector); host-cp is the
2589
+ // injection point. Source order: OLAM_DOGFOOD_REPO_URL env, then
2590
+ // ~/.olam/dogfood-repo-url file. Absent → no injection (the dispatch
2591
+ // runs text-only, like before this change).
2592
+ const dogfoodRepoUrl = readDogfoodRepoUrl();
2593
+ let enrichedObj = null;
2594
+ if (anthropicBaseUrl && !parsed.anthropicBaseUrl) enrichedObj = { ...(enrichedObj ?? parsed), anthropicBaseUrl };
2595
+ if (dogfoodRepoUrl && !parsed.repoUrl) enrichedObj = { ...(enrichedObj ?? parsed), repoUrl: dogfoodRepoUrl };
2596
+ // Use `parsed` (not raw `body`) as the no-enrichment fallback so that the
2597
+ // messages→prompt normalisation above is always forwarded even when no
2598
+ // env-var enrichments are applied.
2599
+ const enriched = enrichedObj ? JSON.stringify(enrichedObj) : JSON.stringify(parsed);
2600
+
2601
+ // Phase H h2: attach CF Access service-token headers when configured
2602
+ // (machine-to-machine auth). Additive alongside Basic auth. CF Access
2603
+ // headers are validated at the EDGE of origins behind a CF Access app
2604
+ // (e.g. auth-worker.kaluga.co). They are inert on same-account service-
2605
+ // binding hops (plan-DO) because those bypass the CF Access edge; a CF
2606
+ // Access app in front of plan-DO would still not receive service-binding
2607
+ // traffic. See docs/runbooks/cf-access-service-token.md.
2377
2608
  const upstream = await fetch(
2378
2609
  `${cloudUrl.replace(/\/+$/, '')}/v1/dispatch?plan_id=${encodeURIComponent(planId)}`,
2379
2610
  {
@@ -2381,8 +2612,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2381
2612
  headers: {
2382
2613
  'Authorization': `Basic ${basicAuth}`,
2383
2614
  'content-type': 'application/json',
2615
+ ...cfAccessHeaders(),
2384
2616
  },
2385
- body,
2617
+ body: enriched,
2386
2618
  },
2387
2619
  );
2388
2620
  const upstreamBody = await upstream.text();
@@ -2398,6 +2630,72 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2398
2630
  }
2399
2631
  }
2400
2632
 
2633
+ // /api/plans/create — Gap 3 plan-creation handshake (Phase H h2 v1 dogfood).
2634
+ //
2635
+ // Accepts a plan-creation request from the SPA, enriches it with the
2636
+ // operator's anthropicBaseUrl from ~/.olam/anthropic-base-url, and forwards
2637
+ // it to plan-DO's /v1/plans/create so plan-DO stores the bearer URL for
2638
+ // subsequent dispatches + spawned CF Sandbox child worlds.
2639
+ //
2640
+ // Config:
2641
+ // OLAM_CLOUD_URL — plan-DO deployed URL (e.g. https://plan-agent-do.workers.dev)
2642
+ // OLAM_SHOWCASE_PASSWORD — showcase Basic auth password
2643
+ //
2644
+ // Returns 503 when cloud is not configured — operators using local Docker
2645
+ // mode skip this; the SPA treats 503 as a non-fatal degraded state.
2646
+ if (url.pathname === '/api/plans/create' && req.method === 'POST') {
2647
+ const cloudUrl = process.env.OLAM_CLOUD_URL;
2648
+ const showcasePw = process.env.OLAM_SHOWCASE_PASSWORD;
2649
+ if (!cloudUrl || !showcasePw) {
2650
+ return jsonReply(res, 503, {
2651
+ error: 'cloud_not_configured',
2652
+ message: 'OLAM_CLOUD_URL + OLAM_SHOWCASE_PASSWORD not set; plan-DO bearer propagation skipped.',
2653
+ });
2654
+ }
2655
+ try {
2656
+ const reqChunks = [];
2657
+ for await (const c of req) reqChunks.push(c);
2658
+ let parsed = {};
2659
+ try { parsed = JSON.parse(Buffer.concat(reqChunks).toString('utf8') || '{}'); } catch {
2660
+ // Non-fatal: body is optional — caller may POST with no body to trigger
2661
+ // bearer registration without additional plan metadata.
2662
+ }
2663
+
2664
+ // Enrich with anthropicBaseUrl from the host config.
2665
+ const anthropicBaseUrl = readAnthropicBaseUrl();
2666
+ const planId = parsed.planId ?? parsed.session_id ?? `plan-${Date.now()}`;
2667
+ const requestBody = { ...parsed, planId, ...(anthropicBaseUrl ? { anthropicBaseUrl } : {}) };
2668
+
2669
+ const basicAuth = Buffer.from(`operator:${showcasePw}`).toString('base64');
2670
+ // Phase H h2: attach CF Access service-token headers when configured.
2671
+ // See the /api/cloud-dispatch handler above + the runbook for why these
2672
+ // are additive (kept alongside Basic) and edge-validated only on origins
2673
+ // behind a CF Access app — inert on same-account service-binding hops.
2674
+ const upstream = await fetch(
2675
+ `${cloudUrl.replace(/\/+$/, '')}/v1/plans/create?plan_id=${encodeURIComponent(planId)}`,
2676
+ {
2677
+ method: 'POST',
2678
+ headers: {
2679
+ 'Authorization': `Basic ${basicAuth}`,
2680
+ 'content-type': 'application/json',
2681
+ ...cfAccessHeaders(),
2682
+ },
2683
+ body: JSON.stringify(requestBody),
2684
+ },
2685
+ );
2686
+ const upstreamBody = await upstream.text();
2687
+ res.statusCode = upstream.status;
2688
+ res.setHeader('content-type', upstream.headers.get('content-type') ?? 'application/json');
2689
+ res.setHeader('cache-control', 'no-store');
2690
+ return res.end(upstreamBody);
2691
+ } catch (err) {
2692
+ return jsonReply(res, 502, {
2693
+ error: 'plans_create_proxy_failed',
2694
+ message: err.message,
2695
+ });
2696
+ }
2697
+ }
2698
+
2401
2699
  // GET /api/worlds/:id/processes
2402
2700
  // GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world)
2403
2701
  // Handler: routes/process-port.mjs → handleListProcesses
@@ -2795,148 +3093,54 @@ function handleAuthEvents(req, res) {
2795
3093
  //
2796
3094
  // Fetch port bindings for a world's container via docker-socket-proxy
2797
3095
  // inspect. Returns [{name, host_port, internal_port, url}] tagged with
2798
- // well-known internal ports.
2799
-
2800
- const WELL_KNOWN_PORTS = {
2801
- 3000: 'atlas-core (Rails)',
2802
- 5175: 'diner-app (Vite)',
2803
- 7681: 'Terminal (ttyd)',
2804
- 7682: 'Terminal Shell (ttyd)',
2805
- 8080: 'Per-world CP',
2806
- };
2807
-
2808
- /**
2809
- * Quick liveness probe against a service URL. Returns true if the
2810
- * service responds with ANY HTTP response (1xx-5xx) — we don't care
2811
- * about status codes because each app has its own conventions (Vite
2812
- * 200s on /, ttyd may 401, Rails may 500 on /, the per-world CP 200s).
2813
- * What matters is that something is listening.
2814
- *
2815
- * Probed from inside the host-cp container so we use HOST_FOR_WORLD
2816
- * (host.docker.internal on macOS/Windows, 172.17.0.1 on Linux) — the
2817
- * SPA's own 127.0.0.1:<port> URL is unreachable from container-side.
2818
- *
2819
- * Tight 800ms timeout. Worst case: 4 services × 800ms in parallel ≤ 1s
2820
- * added to the /api/worlds response — acceptable for a 4s poll cycle.
2821
- */
2822
- async function probeServiceLive(hostPort) {
2823
- const probeUrl = `http://${HOST_FOR_WORLD}:${hostPort}/`;
2824
- try {
2825
- const res = await fetch(probeUrl, {
2826
- method: 'HEAD',
2827
- signal: AbortSignal.timeout(800),
2828
- redirect: 'manual',
2829
- });
2830
- return res.status > 0;
2831
- } catch {
2832
- // ECONNREFUSED, timeout, DNS — anything counts as not-live. Try
2833
- // GET as a fallback because some servers (e.g. ttyd) close on HEAD
2834
- // and we don't want false negatives from picky upstream behavior.
2835
- try {
2836
- const res2 = await fetch(probeUrl, {
2837
- method: 'GET',
2838
- signal: AbortSignal.timeout(800),
2839
- redirect: 'manual',
2840
- });
2841
- return res2.status > 0;
2842
- } catch {
2843
- return false;
2844
- }
2845
- }
2846
- }
2847
-
2848
- /**
2849
- * Get the running container's port bindings from socket-proxy + map
2850
- * each to a clickable URL. Each service is then probed in parallel
2851
- * for actual reachability — the docker port mapping just tells us
2852
- * what's CONFIGURED; the probe confirms what's actually LISTENING.
2853
- *
2854
- * Returns [] on any docker-inspect failure (container missing, socket-
2855
- * proxy down) so the API still returns a valid worlds list.
2856
- *
2857
- * @param {string} worldId
2858
- * @returns {Promise<Array<{name: string, host_port: number, internal_port: number, url: string, live: boolean}>>}
2859
- */
2860
- async function fetchWorldServices(worldId) {
2861
- const containerName = `olam-${worldId}-devbox`;
2862
- let data;
2863
- try {
2864
- if (DOCKER_HOST === 'docker-cli') {
2865
- // Bare-node mode: shell out to `docker inspect` instead of HTTP.
2866
- // Same fix pattern as fetchContainerSecret (PR #108). Without
2867
- // this, the services array is always empty in bare-node and the
2868
- // SPA can't find the ttyd host port → terminal renders blank.
2869
- const { spawnSync } = await import('node:child_process');
2870
- const result = spawnSync(
2871
- 'docker',
2872
- ['inspect', containerName],
2873
- { encoding: 'utf-8', timeout: 2000 },
2874
- );
2875
- if (result.status !== 0) return [];
2876
- const arr = JSON.parse(result.stdout || '[]');
2877
- data = Array.isArray(arr) && arr.length > 0 ? arr[0] : null;
2878
- if (!data) return [];
2879
- } else {
2880
- const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
2881
- const res = await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/json`, {
2882
- signal: AbortSignal.timeout(2000),
2883
- });
2884
- if (!res.ok) return [];
2885
- data = await res.json();
2886
- }
2887
- const ports = data?.NetworkSettings?.Ports ?? {};
2888
- const draft = [];
2889
- for (const [internal, bindings] of Object.entries(ports)) {
2890
- if (!Array.isArray(bindings) || bindings.length === 0) continue;
2891
- const internalPort = parseInt(internal.split('/')[0], 10);
2892
- const hostPort = parseInt(bindings[0].HostPort, 10);
2893
- if (!Number.isFinite(internalPort) || !Number.isFinite(hostPort)) continue;
2894
- draft.push({
2895
- name: WELL_KNOWN_PORTS[internalPort] ?? `App (port ${internalPort})`,
2896
- host_port: hostPort,
2897
- internal_port: internalPort,
2898
- url: `http://127.0.0.1:${hostPort}`,
2899
- });
2900
- }
2901
-
2902
- // Probe each service in parallel for actual reachability. Adds a
2903
- // `live: boolean` field. The UI dims chips for non-live services
2904
- // so operators can see what's configured-but-down vs configured-
2905
- // and-up at a glance.
2906
- const liveResults = await Promise.all(
2907
- draft.map((s) => probeServiceLive(s.host_port)),
2908
- );
2909
- const services = draft.map((s, i) => ({ ...s, live: liveResults[i] }));
2910
-
2911
- // Stable order: well-known ports first (CP, then Rails/Vite, then terminal).
2912
- services.sort((a, b) => a.internal_port - b.internal_port);
2913
- return services;
2914
- } catch {
2915
- return [];
2916
- }
3096
+ // well-known internal ports. The probe + enrichment logic lives in
3097
+ // ./world-services.mjs (extracted for isolated unit testing); this thin
3098
+ // wrapper binds the host-specific HOST_FOR_WORLD / DOCKER_HOST module
3099
+ // constants so callers keep the single-arg `fetchWorldServices(worldId)`
3100
+ // signature (used at the createLocalWorldsSource wiring above).
3101
+ function fetchWorldServices(worldId) {
3102
+ return fetchWorldServicesImpl(worldId, {
3103
+ hostForWorld: HOST_FOR_WORLD,
3104
+ dockerHost: DOCKER_HOST,
3105
+ });
2917
3106
  }
2918
3107
 
2919
3108
  // ── Static file serving (Phase F-2-D dogfood fix) ──────────────────
2920
3109
  //
2921
- // SPA dist/ is at /app/dist/ inside the container (see Dockerfile).
2922
- // In bare-node mode, the SPA build lives in packages/control-plane/public
2923
- // (where the workspace's `npm run build` writes it). The legacy
2924
- // packages/host-cp/dist used to be hand-tarballed but can drift out of
2925
- // sync with the index.html→bundle hash mapping; prefer public/ when it
2926
- // exists so a stale dist doesn't 404 on /assets/<hash>.js.
2927
-
3110
+ // SPA dist/ is at /app/dist/ inside the container (see Dockerfile; the
3111
+ // build stages plan-chat-spa's dist/client there as of Phase E5).
3112
+ // In bare-node mode, the SPA build lives in
3113
+ // packages/plan-chat-spa/dist/client (where `vite build` writes it as of
3114
+ // the Phase E5 ATOMIC SERVING CUTOVER plan-chat-spa supersedes
3115
+ // control-plane as host-cp's sole served SPA). The legacy
3116
+ // control-plane/public candidates are retained below as a fallback so a
3117
+ // host-cp running against a not-yet-rebuilt worktree still finds *a* SPA;
3118
+ // they are last-resort, ordered after the plan-chat-spa + host-cp/dist
3119
+ // candidates. The legacy packages/host-cp/dist used to be hand-tarballed
3120
+ // but can drift out of sync with the index.html→bundle hash mapping;
3121
+ // prefer the freshly-built dist/client when it exists.
3122
+
3123
+ // Phase E5 (ATOMIC SERVING CUTOVER) — FAIL-CLOSED candidate list.
3124
+ // Every candidate now resolves to a plan-chat-spa build. The retired
3125
+ // control-plane/public candidates were REMOVED (per /codex:rescue on the
3126
+ // cutover): keeping them meant a missing/stale plan-chat-spa build would
3127
+ // silently fall back to serving the OLD control-plane shell and look
3128
+ // superficially healthy. Now an absent plan-chat-spa dist serves nothing
3129
+ // (ENOENT → SPA-shell 404) — a loud failure, not a silent wrong-SPA serve.
3130
+ // - /app/dist — container (Dockerfile stages plan-chat-spa here)
3131
+ // - packages/plan-chat-spa/dist/client — bare-node local (vite build output)
3132
+ // - packages/host-cp/dist — stage-host-cp-spa.sh output (also plan-chat-spa)
2928
3133
  const DIST_DIR = (() => {
2929
3134
  const candidates = [
2930
3135
  '/app/dist',
2931
- path.resolve(process.cwd(), 'packages/control-plane/public'),
2932
- path.resolve(process.cwd(), '../control-plane/public'),
2933
- path.resolve(process.cwd(), 'dist'),
3136
+ path.resolve(process.cwd(), 'packages/plan-chat-spa/dist/client'),
3137
+ path.resolve(process.cwd(), '../plan-chat-spa/dist/client'),
2934
3138
  path.resolve(process.cwd(), 'packages/host-cp/dist'),
2935
3139
  ];
2936
3140
  for (const c of candidates) {
2937
3141
  if (fs.existsSync(c) && fs.existsSync(path.join(c, 'index.html'))) return c;
2938
3142
  }
2939
- return '/app/dist'; // fallback; readFile will surface ENOENT
3143
+ return '/app/dist'; // fallback; readFile will surface ENOENT (fail-closed)
2940
3144
  })();
2941
3145
 
2942
3146
  const SPA_ROUTES = new Set(['/', '/worlds', '/workspaces', '/inbox', '/repos', '/runbooks', '/plan']);
@@ -3159,6 +3363,19 @@ const _spaCacheByKey = new Map();
3159
3363
  // and the token-comparison check skips reload when the cookie
3160
3364
  // already matches (so non-rotation 401s — e.g. genuine auth
3161
3365
  // failures — don't cause a refresh loop).
3366
+ // Phase E5 (ATOMIC SERVING CUTOVER): BOOTSTRAP_SCRIPT is NO LONGER
3367
+ // injected into the served SPA shell. host-cp now serves plan-chat-spa
3368
+ // exclusively, whose bundle re-homes the cookie-bootstrap +
3369
+ // world-fetch-rewrite + 401-recover shim (packages/plan-chat-spa/src/lib/
3370
+ // worldFetch.ts, installed at the top of src/main.tsx — Phase C). The
3371
+ // const is RETAINED, defined-but-unreferenced-in-render, because
3372
+ // scripts/audit-worker-bootstrap-parity.mjs extracts the `HN` (and `WP`)
3373
+ // arrays out of this literal via extractHN()/extractWP() and machine-gates
3374
+ // them byte-equal against worldFetch.ts's HOST_NATIVE_PREFIXES /
3375
+ // WORLD_PREFIXES. Deleting this const would make extractHN return null →
3376
+ // audit FAIL. Keep it as the canonical HN/WP-array parity source until
3377
+ // that audit is repointed at worldFetch.ts directly (follow-up).
3378
+ // eslint-disable-next-line no-unused-vars -- retained as HN/WP parity-audit source
3162
3379
  const BOOTSTRAP_SCRIPT = `<script>(function(){function ck(){var m=document.cookie.match(/olam_host_cp_token=([^;]+)/);return m?m[1]:'';}function sw(t){document.cookie='olam_host_cp_token='+t+'; path=/; samesite=strict';}try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);sw(d.token);}}catch(e){console.error('[host-cp bootstrap]',e);}var reloading=false;function recover(){if(reloading)return;try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);if(d.token&&ck()!==d.token){reloading=true;sw(d.token);console.warn('[host-cp auth recover] token rotated; reloading');location.reload();}}}catch(e){console.error('[host-cp auth recover]',e);}}var HN=['/api/bootstrap','/api/worlds','/api/projects','/api/workspaces','/api/workspaces/match','/api/repos','/api/runbooks','/api/auth','/api/host-stream','/api/plan-chat','/api/plan/agent-runtime','/health'];var WP=['/api/','/session/','/hooks/','/dispatch','/lanes','/codex/','/review/'];function sr(p){if(typeof p!=='string')return false;if(p.startsWith('/api/world/'))return false;for(var i=0;i<HN.length;i++){var n=HN[i];if(p===n||p.startsWith(n+'?')||p.startsWith(n+'/'))return false;}for(var j=0;j<WP.length;j++){var w=WP[j];if(p===w||p===w.replace(/\\/$/,'')||p.startsWith(w)||p.startsWith(w.replace(/\\/$/,'')+'?')||p.startsWith(w.replace(/\\/$/,'')+'/'))return true;}return false;}function wid(){var p=location.pathname;var m=p.match(/^\\/(world|inbox|session)\\/([^/?#]+)/);if(m)return m[2];if(/^\\/(?:worlds?|workspaces?|world|sandbox|session|inbox|plan|design|repos|runbooks|assets|api|health|favicon)($|\\/|\\?)/.test(p))return null;var r=p.match(/^\\/([a-z][a-z0-9-]+)(?:\\/|$|\\?)/);return r?r[1]:null;}function rw(p){var w=wid();return w?'/api/world/'+w+p:p;}var of=window.fetch.bind(window);window.fetch=function(input,init){var pr;if(typeof input==='string'&&sr(input))pr=of(rw(input),init);else if(input&&typeof input.url==='string'&&sr(input.url))pr=of(new Request(rw(input.url),input),init);else pr=of(input,init);return pr.then(function(res){if(res&&res.status===401)recover();return res;});};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);var es=new OE(s,i);es.addEventListener('error',function(){if(es.readyState===OE.CLOSED)recover();});return es;};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
3163
3380
 
3164
3381
  /**
@@ -3181,35 +3398,60 @@ function buildPlanChatBearerInjection() {
3181
3398
  }
3182
3399
  }
3183
3400
 
3184
- // Phase D1 — Selective BOOTSTRAP_SCRIPT no-op.
3401
+ /**
3402
+ * Build the cloud-enabled flag injection. plan-chat-spa's CloudToggleChip
3403
+ * (`getCloudAvailability` in `lib/plan-config.ts`) reads
3404
+ * `window.__OLAM_CLOUD_ENABLED__` to decide whether the Cloud toggle is
3405
+ * live; absent → the chip stays disabled and only links to the setup doc.
3406
+ *
3407
+ * The toggle should be live exactly when host-cp can actually proxy
3408
+ * `/api/cloud-dispatch` → plan-DO — which is the SAME condition the
3409
+ * cloud-dispatch handler guards with its `503 cloud_dispatch_not_configured`:
3410
+ * BOTH `OLAM_CLOUD_URL` and `OLAM_SHOWCASE_PASSWORD` set. Mirror it exactly
3411
+ * so the UI never offers a toggle that would 503 on dispatch.
3412
+ */
3413
+ function buildCloudEnabledInjection() {
3414
+ const enabled = Boolean(
3415
+ process.env.OLAM_CLOUD_URL && process.env.OLAM_SHOWCASE_PASSWORD,
3416
+ );
3417
+ return enabled ? `<script>window.__OLAM_CLOUD_ENABLED__=true;</script>` : '';
3418
+ }
3419
+
3420
+ // Phase E5 (ATOMIC SERVING CUTOVER) — BOOTSTRAP_SCRIPT no longer injected.
3185
3421
  //
3186
- // Planning paths use plan-chat-spa's own readBearer() resolver
3187
- // (lib/bearer.ts) which reads window.__OLAM_PLAN_CHAT_BEARER__ injected
3188
- // inline OR falls back to the URL hash channel. They DO NOT need the
3189
- // host-cp bootstrap's cookie+fetch-rewrite shim. Non-planning surfaces
3190
- // (/workspaces, /repos, /runbooks, /design, /inbox, /world/:id/editor,
3191
- // /world/:id/events) still rely on bootstrap-injected cookie + the
3192
- // monkey-patched fetch/EventSource that rewrites world-scoped paths
3193
- // to /api/world/<id>/... keep injecting for them until Phase E
3194
- // migrates each to a bootstrap-free pattern.
3422
+ // host-cp now serves plan-chat-spa exclusively. plan-chat-spa's own
3423
+ // bundle re-homes BOTH auth paths:
3424
+ // - readBearer() (lib/bearer.ts) reads window.__OLAM_PLAN_CHAT_BEARER__
3425
+ // injected inline below (the `bearerInjection`) OR falls back to the
3426
+ // URL-hash channel.
3427
+ // - the cookie-bootstrap + world-fetch-rewrite + 401-recover shim
3428
+ // (lib/worldFetch.ts, installed at the top of src/main.tsx) handles
3429
+ // the cookie + path-rewrite duties that host-cp's BOOTSTRAP_SCRIPT
3430
+ // used to perform.
3431
+ // So the served shell injects ONLY the bearer; the bootstrap shim is
3432
+ // dropped. isPlanningPath() (bootstrap-selective.mjs) is now a wildcard
3433
+ // (true for every string path) — this function relies on that to never
3434
+ // inject BOOTSTRAP_SCRIPT.
3195
3435
  //
3196
- // Reversal: edit BOOTSTRAP_NOOP_PLANNING_PATHS in bootstrap-selective.mjs
3197
- // to [] to restore universal injection. Single-line change.
3436
+ // Reversal: re-narrow isPlanningPath() in bootstrap-selective.mjs (see
3437
+ // the revert-seam note there) and restore the `bootstrapPart` branch
3438
+ // below if a surface ever needs host-cp's bootstrap again.
3198
3439
  //
3199
- // Per K1 SCP-3 + phase-d-tasks D1 acceptance.
3440
+ // Per K1 SCP-3 + phase-d-tasks D1 + phase-e-tasks E2 acceptance.
3200
3441
 
3201
3442
  async function renderSpaShell(filePath, pathname) {
3202
3443
  const stat = fs.statSync(filePath);
3203
3444
  const bearerInjection = buildPlanChatBearerInjection();
3204
- // Path-selective: planning paths skip the bootstrap shim entirely
3205
- // (plan-chat-spa's readBearer handles auth); non-planning paths
3206
- // retain it (Phase E will migrate them).
3445
+ const cloudInjection = buildCloudEnabledInjection();
3446
+ // Phase E5: BOOTSTRAP_SCRIPT is never injected — plan-chat-spa's own
3447
+ // worldFetch.ts shim owns the cookie-bootstrap + path-rewrite contract.
3448
+ // We still assert the wildcard invariant so a future re-narrowing of
3449
+ // isPlanningPath() surfaces here loudly rather than silently shipping a
3450
+ // mixed shell.
3207
3451
  const skipBootstrap = isPlanningPath(pathname);
3208
- const bootstrapPart = skipBootstrap ? '' : BOOTSTRAP_SCRIPT;
3209
- // Cache key includes bearer length AND the bootstrap-presence bit so
3210
- // /plan and /workspaces don't share a cached shell.
3211
- const cacheKey =
3212
- stat.mtimeMs + ':' + bearerInjection.length + ':' + (skipBootstrap ? '0' : '1');
3452
+ // Cache key includes bearer length (the only per-render-varying input
3453
+ // now that bootstrap injection is constant-empty).
3454
+ const cacheKey = stat.mtimeMs + ':' + bearerInjection.length + ':' + cloudInjection.length;
3213
3455
  const cached = _spaCacheByKey.get(cacheKey);
3214
3456
  if (cached !== undefined) return cached;
3215
3457
  let html = fs.readFileSync(filePath, 'utf-8');
@@ -3219,15 +3461,11 @@ async function renderSpaShell(filePath, pathname) {
3219
3461
  // which 404s. Rewrite to absolute `/assets/` so all SPA shell paths
3220
3462
  // (/, /worlds, /workspaces, /world/<id>) reference the same bundle.
3221
3463
  html = html.replace(/(href|src)="\.\/assets\//g, '$1="/assets/');
3222
- // Inject right after <head> so the bootstrap runs before any other
3223
- // script tag on the page. Bearer injection runs after the host-cp
3224
- // bootstrap so window.__OLAM_PLAN_CHAT_BEARER__ is set before the
3225
- // SPA bundle reads it. On planning paths the bootstrap is empty —
3226
- // bearer injection still runs (plan-chat-spa reads it directly).
3227
- html = html.replace(
3228
- /<head>/i,
3229
- `<head>\n ${bootstrapPart}\n ${bearerInjection}`,
3230
- );
3464
+ // Inject the bearer right after <head> so window.__OLAM_PLAN_CHAT_BEARER__
3465
+ // is set before the SPA bundle reads it. No bootstrap shim see the
3466
+ // block comment above (Phase E5 cutover).
3467
+ void skipBootstrap; // wildcard invariant: always true; documents intent
3468
+ html = html.replace(/<head>/i, `<head>\n ${bearerInjection}${cloudInjection}`);
3231
3469
  _spaCacheByKey.set(cacheKey, html);
3232
3470
  return html;
3233
3471
  }
@@ -3303,28 +3541,41 @@ server.on('upgrade', (req, clientSocket, head) => {
3303
3541
  }
3304
3542
  });
3305
3543
 
3306
- // Probe persisted tunnels on startup; mark unreachable ones stale.
3307
- tunnelManager.probeAllOnStartup().catch((err) => {
3308
- console.error(`tunnel startup probe failed: ${err.message}`);
3309
- });
3544
+ // SERVE-ONLY: everything below this point through reconcileWorldsWithDocker
3545
+ // is world-orchestration observability — tunnel probes, the worlds.db /
3546
+ // docker snapshot loops, the per-world activity tracker, and the boot-time
3547
+ // reconcile. None of it has a docker daemon / worlds.db / world tunnels on a
3548
+ // managed cluster. Skip it all in serve-only; the snapshot timers + tracker
3549
+ // stay unstarted so the shutdown handler's `?.`-guarded stops are safe.
3550
+ if (!SERVE_ONLY) {
3551
+ // Probe persisted tunnels on startup; mark unreachable ones stale.
3552
+ tunnelManager.probeAllOnStartup().catch((err) => {
3553
+ console.error(`tunnel startup probe failed: ${err.message}`);
3554
+ });
3310
3555
 
3311
- // Start the 1-Hz worlds.db hash-diff loop after the server boots so
3312
- // the initial broadcast happens once the route is reachable.
3313
- startWorldsSnapshotLoop();
3314
- // Phase B-bonus: start tunnel + listening snapshot loops. Both
3315
- // hash-debounce so idle windows produce zero broadcasts.
3316
- startTunnelsSnapshotLoop();
3317
- startListeningSnapshotLoop();
3556
+ // Start the 1-Hz worlds.db hash-diff loop after the server boots so
3557
+ // the initial broadcast happens once the route is reachable.
3558
+ startWorldsSnapshotLoop();
3559
+ // Phase B-bonus: start tunnel + listening snapshot loops. Both
3560
+ // hash-debounce so idle windows produce zero broadcasts.
3561
+ startTunnelsSnapshotLoop();
3562
+ startListeningSnapshotLoop();
3563
+ }
3318
3564
 
3319
3565
  // Closes #965: live thought_count + total_cost_usd updates from each
3320
3566
  // active world's Claude session JSONL. Periodic (60s default) so Rico's
3321
3567
  // scheduling loop can read fresh values from the `worlds` table and
3322
3568
  // SPAs can subscribe to the `world.activity.tick` event. Fail-soft per
3323
3569
  // world: missing/malformed JSONL never crashes the loop.
3324
- const worldActivityTracker = startWorldActivityTracker({
3325
- dbPath: WORLDS_DB_PATH,
3326
- broadcaster: hostStream,
3327
- });
3570
+ //
3571
+ // SERVE-ONLY: reads worlds.db (absent on a managed cluster). `null` sentinel
3572
+ // keeps the shutdown handler's `worldActivityTracker?.stop()` a no-op.
3573
+ const worldActivityTracker = SERVE_ONLY
3574
+ ? null
3575
+ : startWorldActivityTracker({
3576
+ dbPath: WORLDS_DB_PATH,
3577
+ broadcaster: hostStream,
3578
+ });
3328
3579
 
3329
3580
  // ── Phase 1a / B1 (PR3): engine-select + await-before-listen ─────
3330
3581
  //
@@ -3343,14 +3594,20 @@ const worldActivityTracker = startWorldActivityTracker({
3343
3594
  // resolve through the same async branch for symmetry — the call-site
3344
3595
  // migration to engine.* methods is a downstream task; today the engine
3345
3596
  // instance is held for /health diagnostic + future use.
3346
- const hostCpEngine = await (async () => {
3347
- if (HOST_CP_ENGINE === 'kubernetes') {
3348
- const { createKubernetesEngine } = await import('./engines/kubernetes.mjs');
3349
- return createKubernetesEngine({ env: process.env });
3350
- }
3351
- const { createDockerEngine } = await import('./engines/docker.mjs');
3352
- return createDockerEngine({ dockerHost: DOCKER_HOST });
3353
- })();
3597
+ // SERVE-ONLY: don't resolve a real container engine — there's no docker
3598
+ // daemon to talk to and the KubernetesEngine factory runs a context-
3599
+ // allowlist guard that has no managed-cluster meaning here. Use a minimal
3600
+ // inert engine descriptor so /health still reports an engine name.
3601
+ const hostCpEngine = SERVE_ONLY
3602
+ ? { engineName: 'serve-only', context: undefined }
3603
+ : await (async () => {
3604
+ if (HOST_CP_ENGINE === 'kubernetes') {
3605
+ const { createKubernetesEngine } = await import('./engines/kubernetes.mjs');
3606
+ return createKubernetesEngine({ env: process.env });
3607
+ }
3608
+ const { createDockerEngine } = await import('./engines/docker.mjs');
3609
+ return createDockerEngine({ dockerHost: DOCKER_HOST });
3610
+ })();
3354
3611
 
3355
3612
  // ── Boot-time worlds.db ↔ docker reconciler (issue #963) ─────────────
3356
3613
  //
@@ -3359,17 +3616,24 @@ const hostCpEngine = await (async () => {
3359
3616
  // world is running/active but the container is gone, mark it 'orphaned'.
3360
3617
  // Fail-soft: docker unreachable or DB unavailable → log + continue boot.
3361
3618
  // Runs BEFORE server.listen() so the first request sees reconciled state.
3362
- try {
3363
- await reconcileWorldsWithDocker({
3364
- dbPath: WORLDS_DB_PATH,
3365
- listContainerNames: () => defaultListContainerNames(DOCKER_API_BASE, console.log),
3366
- });
3367
- } catch (err) {
3368
- console.error(`[boot-reconciler] unexpected error (continuing boot): ${err.message}`);
3619
+ //
3620
+ // SERVE-ONLY: no worlds.db / docker container list on a managed cluster.
3621
+ if (!SERVE_ONLY) {
3622
+ try {
3623
+ await reconcileWorldsWithDocker({
3624
+ dbPath: WORLDS_DB_PATH,
3625
+ listContainerNames: () => defaultListContainerNames(DOCKER_API_BASE, console.log),
3626
+ });
3627
+ } catch (err) {
3628
+ console.error(`[boot-reconciler] unexpected error (continuing boot): ${err.message}`);
3629
+ }
3369
3630
  }
3370
3631
 
3371
3632
  server.listen(PORT, '0.0.0.0', () => {
3372
3633
  console.log(`olam-host-cp B3 listening on :${PORT}`);
3634
+ if (SERVE_ONLY) {
3635
+ console.log(' [serve-only] OLAM_HOST_CP_SERVE_ONLY=true — SPA + host-native /api/* only; world orchestration disabled (/api/world/* → 503 orchestration_unavailable).');
3636
+ }
3373
3637
  console.log(` DOCKER_HOST=${DOCKER_HOST}`);
3374
3638
  console.log(` cache TTL=${TTL_SEC}s`);
3375
3639
  console.log(` worlds known: ${Object.keys(WORLDS).join(', ') || '(none)'}`);
@@ -3404,11 +3668,12 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
3404
3668
  console.log(`received ${sig}, shutting down`);
3405
3669
  stopEvents();
3406
3670
  prPoller.stop();
3407
- worldsDbReconciler.stop();
3671
+ // worldsDbReconciler + worldActivityTracker are null in SERVE-ONLY mode.
3672
+ worldsDbReconciler?.stop();
3408
3673
  stopWorldsSnapshotLoop();
3409
3674
  stopTunnelsSnapshotLoop();
3410
3675
  stopListeningSnapshotLoop();
3411
- worldActivityTracker.stop();
3676
+ worldActivityTracker?.stop();
3412
3677
  if (serversSnapshotTimer) { clearTimeout(serversSnapshotTimer); serversSnapshotTimer = null; }
3413
3678
  hostStream.close();
3414
3679
  if (ndjsonSpanSink) ndjsonSpanSink.close().catch(() => {});