@pleri/olam-cli 0.1.7

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 (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. package/package.json +38 -0
@@ -0,0 +1,2215 @@
1
+ #!/usr/bin/env node
2
+ // Phase F-2-B (B3): host CP HTTP server.
3
+ //
4
+ // Replaces the B2 placeholder. Wires together:
5
+ // - secret-cache.mjs — 5min TTL Map-based per-world secret cache
6
+ // - container-secret-fetcher.mjs — docker-socket-proxy exec to read secrets
7
+ // - docker-events.mjs — restart/stop event subscriber
8
+ // - proxy.mjs — JSON proxy + verbatim header passthrough
9
+ //
10
+ // Routes:
11
+ // - GET /health server diagnostics
12
+ // - ANY /api/world/<id>/<route...> proxied to per-world CP with
13
+ // X-Olam-Secret injected
14
+ // - other paths (B4 ships static SPA + auth gate; B6 ships /api/projects,
15
+ // /api/workspaces, /api/workspaces/match)
16
+ //
17
+ // World registry lookup (worldId → host port + container name) is a
18
+ // stub for B3 — B6 wires the real `~/.olam/worlds.db` reader. For now,
19
+ // the lookup table is populated from env (OLAM_HOST_CP_WORLDS_JSON) for
20
+ // local dev, and the M2 ship gate test (B10) will run with a single
21
+ // world registered via this env var until B6 lands.
22
+
23
+ import http from 'node:http';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import url from 'node:url';
28
+ import { execFile } from 'node:child_process';
29
+ import { promisify } from 'node:util';
30
+
31
+ const execFileAsync = promisify(execFile);
32
+ import { SecretCache } from './secret-cache.mjs';
33
+ import { computeProgress } from './world-progress.mjs';
34
+ import { createPrCache } from './pr-cache.mjs';
35
+ import { fetchContainerSecret } from './container-secret-fetcher.mjs';
36
+ import { subscribeDockerEvents } from './docker-events.mjs';
37
+ import { parseProxyPath, perWorldBase, proxyToWorld } from './proxy.mjs';
38
+ import { StartupToken } from './auth.mjs';
39
+ import { SseGate, isSsePath, wireRelease } from './sse-gate.mjs';
40
+ import {
41
+ loadWorkspaces,
42
+ workspacesForApi,
43
+ projectsFromWorkspaces,
44
+ matchWorkspacesByProjects,
45
+ } from './workspace-catalog.mjs';
46
+ import {
47
+ createWorldNamesStore,
48
+ inferNameFromTask,
49
+ normalizeName,
50
+ } from './world-names-store.mjs';
51
+ import { createLocalWorldsSource } from './local-worlds-source.mjs';
52
+ import { createPylonWorldsSource } from './pylon-worlds-source.mjs';
53
+ import { composeWorldsSources } from './compose-worlds-sources.mjs';
54
+ import { createWorldPrStateStore } from './world-pr-state.mjs';
55
+ import { PlanOrchestrator } from './plan-orchestrator.mjs';
56
+ import { createPrMergePoller } from './pr-merge-poller.mjs';
57
+ import { parse as parseYaml } from 'yaml';
58
+ import { startWorldsDbReconciler } from './worlds-db-source.mjs';
59
+ import { authSecretHint } from './auth-secret-hint.mjs';
60
+ import * as tunnelManager from './world-tunnel-manager.mjs';
61
+ import { getProcessSnapshot, subscribeToProcesses } from './process-poller.mjs';
62
+ import { buildVersionSnapshot } from './version-status.mjs';
63
+
64
+ // ── Deployment-mode detection ─────────────────────────────────────
65
+ //
66
+ // host-cp runs either as a Docker container (via `olam host-cp start` /
67
+ // compose.yaml) or as a bare-node process on the operator's host (for
68
+ // active development on host-cp itself). The two modes differ in how
69
+ // per-world CPs are reachable:
70
+ //
71
+ // container → host.docker.internal:<port> (Docker Desktop / Linux host-gateway)
72
+ // bare → 127.0.0.1:<port> (loopback — same machine)
73
+ //
74
+ // Override: OLAM_HOST_CP_MODE=container|bare
75
+ // Auto-detect: /.dockerenv is created by the docker runtime on container start.
76
+
77
+ const HOST_CP_MODE = process.env.OLAM_HOST_CP_MODE
78
+ ?? (fs.existsSync('/.dockerenv') ? 'container' : 'bare');
79
+ const WORLD_HOST = HOST_CP_MODE === 'container' ? 'host.docker.internal' : '127.0.0.1';
80
+
81
+ const PORT = parseInt(process.env.OLAM_HOST_CP_PORT ?? '19000', 10);
82
+ // In container mode the host-cp talks to the docker daemon via the
83
+ // socket-proxy sidecar (the proxy enforces the read-only API allow-list).
84
+ // In bare-node mode there's no socket-proxy on the host; we shell out to
85
+ // `docker exec` directly via child_process. The sentinel `docker-cli`
86
+ // triggers that path in fetchContainerSecret. (B5 below; closes the
87
+ // secret_fetch_failed bare-node bug class — see ~/.claude/plans/bare-node-mode-safeguards.md.)
88
+ const DOCKER_HOST = process.env.DOCKER_HOST
89
+ ?? (HOST_CP_MODE === 'container' ? 'tcp://docker-socket-proxy:2375' : 'docker-cli');
90
+ const TTL_SEC = parseInt(process.env.OLAM_SECRET_CACHE_TTL_SEC ?? '300', 10);
91
+ const HOST_FOR_WORLD = process.env.OLAM_HOST_FOR_WORLD ?? WORLD_HOST;
92
+ const TOKEN_PATH = process.env.OLAM_HOST_CP_TOKEN_PATH ?? '/data/host-cp.token';
93
+ const AUTH_SERVICE_URL = process.env.OLAM_AUTH_SERVICE_URL ?? `http://${HOST_FOR_WORLD}:9999`;
94
+ const AUTH_SERVICE_SECRET = process.env.OLAM_AUTH_SECRET ?? '';
95
+ const SSE_CAP = parseInt(process.env.OLAM_SSE_MAX_CONCURRENT ?? '50', 10);
96
+ const WORKSPACES_DIR = process.env.OLAM_WORKSPACES_DIR ?? '/data/workspaces';
97
+ const WORLD_NAMES_PATH =
98
+ process.env.OLAM_WORLD_NAMES_PATH ??
99
+ (HOST_CP_MODE === 'container'
100
+ ? '/data/world-names.json'
101
+ : path.join(os.homedir(), '.olam', 'world-names.json'));
102
+ const PR_STATE_PATH = process.env.OLAM_WORLD_PR_STATE_PATH ?? '/data/world-pr-state.json';
103
+ const GH_HOSTS_PATH = process.env.OLAM_GH_HOSTS_PATH ?? '/gh-config/hosts.yml';
104
+ const PR_POLL_INTERVAL_MS = parseInt(process.env.OLAM_PR_POLL_INTERVAL_MS ?? '300000', 10);
105
+ const MERGE_GRACE_MS = parseInt(process.env.OLAM_MERGE_GRACE_MS ?? '600000', 10);
106
+ const PROGRESS_CACHE_MS = parseInt(process.env.OLAM_PROGRESS_CACHE_MS ?? '5000', 10);
107
+ const STARTED_AT = Date.now();
108
+ const WORLDS_DB_PATH =
109
+ process.env.OLAM_WORLDS_DB ??
110
+ (HOST_CP_MODE === 'container'
111
+ ? '/data/worlds.db'
112
+ : path.join(os.homedir(), '.olam', 'worlds.db'));
113
+ const WORLD_TUNNELS_PATH =
114
+ process.env.OLAM_WORLD_TUNNELS_PATH ??
115
+ (HOST_CP_MODE === 'container'
116
+ ? '/data/world-tunnels.json'
117
+ : path.join(os.homedir(), '.olam', 'world-tunnels.json'));
118
+
119
+ // Inject deployment-mode config into the tunnel manager so it doesn't
120
+ // need to re-derive HOST_CP_MODE or container-specific path literals.
121
+ tunnelManager.configure({ hostForWorld: HOST_FOR_WORLD, tunnelsPath: WORLD_TUNNELS_PATH });
122
+
123
+ // ── Version detection (Phase 1 of self-upgrade) ──────────────────────────
124
+ //
125
+ // Polls operator's local repo HEAD every 60s and compares to the SHA baked
126
+ // into each component image at build time. Detection only — no auto-upgrade.
127
+ // OLAM_REPO_PATH overrides the default /operator-repo mount path.
128
+ const VERSION_POLL_INTERVAL_MS = parseInt(
129
+ process.env.OLAM_VERSION_POLL_INTERVAL_MS ?? '60000', 10,
130
+ );
131
+
132
+ // Derive docker API base from DOCKER_HOST (already computed above).
133
+ const DOCKER_API_BASE =
134
+ DOCKER_HOST === 'docker-cli'
135
+ ? 'http://localhost:2375' // bare-node: no socket proxy, docker API unavailable
136
+ : DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
137
+
138
+ /** @type {import('./version-status.mjs').VersionSnapshot | null} */
139
+ let versionSnapshot = null;
140
+
141
+ async function refreshVersionSnapshot() {
142
+ try {
143
+ versionSnapshot = await buildVersionSnapshot({
144
+ authServiceUrl: AUTH_SERVICE_URL,
145
+ dockerApiBase: DOCKER_API_BASE,
146
+ });
147
+ } catch (err) {
148
+ console.error(`[version] snapshot refresh failed: ${err.message}`);
149
+ }
150
+ }
151
+
152
+ // Kick off an initial check immediately, then poll every 60s.
153
+ refreshVersionSnapshot();
154
+ const versionPollTimer = setInterval(refreshVersionSnapshot, VERSION_POLL_INTERVAL_MS);
155
+
156
+ // ── World registry — persistent + admin-managed ───────────────────────
157
+ //
158
+ // Three sources, merged in priority order (later overrides earlier):
159
+ // 1. OLAM_HOST_CP_WORLDS_JSON env var (legacy stub, manual override)
160
+ // 2. ~/.olam/host-cp-registry.json (mounted at /data; persistent across
161
+ // container restarts, edited by `olam host-cp register/deregister`
162
+ // and the auto-register hook in `olam create`).
163
+ // 3. POST /api/admin/registry runtime updates (writes through to file 2).
164
+ //
165
+ // Format of registry file: JSON object mapping worldId → host port.
166
+ // { "amber-fox-1234": 20780, "atlas-audit-9999": 19180 }
167
+
168
+ const REGISTRY_PATH =
169
+ process.env.OLAM_HOST_CP_REGISTRY_PATH ??
170
+ (HOST_CP_MODE === 'container'
171
+ ? '/data/host-cp-registry.json'
172
+ : path.join(os.homedir(), '.olam', 'host-cp-registry.json'));
173
+
174
+ /** @type {Record<string, number>} */
175
+ let WORLDS = {};
176
+
177
+ function loadRegistryFromEnv() {
178
+ try {
179
+ const raw = process.env.OLAM_HOST_CP_WORLDS_JSON;
180
+ if (!raw) return {};
181
+ const parsed = JSON.parse(raw);
182
+ return parsed && typeof parsed === 'object' ? parsed : {};
183
+ } catch (err) {
184
+ console.error(`OLAM_HOST_CP_WORLDS_JSON parse error: ${err.message}`);
185
+ return {};
186
+ }
187
+ }
188
+
189
+ function loadRegistryFromFile() {
190
+ try {
191
+ if (!fs.existsSync(REGISTRY_PATH)) return {};
192
+ const raw = fs.readFileSync(REGISTRY_PATH, 'utf-8');
193
+ if (!raw.trim()) return {};
194
+ const parsed = JSON.parse(raw);
195
+ return parsed && typeof parsed === 'object' ? parsed : {};
196
+ } catch (err) {
197
+ console.error(`registry file parse error at ${REGISTRY_PATH}: ${err.message}`);
198
+ return {};
199
+ }
200
+ }
201
+
202
+ function persistRegistry() {
203
+ try {
204
+ const tmp = `${REGISTRY_PATH}.tmp`;
205
+ fs.writeFileSync(tmp, JSON.stringify(WORLDS, null, 2) + '\n');
206
+ fs.renameSync(tmp, REGISTRY_PATH);
207
+ } catch (err) {
208
+ console.error(`registry write failed: ${err.message}`);
209
+ }
210
+ }
211
+
212
+ WORLDS = { ...loadRegistryFromEnv(), ...loadRegistryFromFile() };
213
+
214
+ // ── Wire dependencies ────────────────────────────────────────────────
215
+
216
+ const cache = new SecretCache({ ttlSec: TTL_SEC });
217
+
218
+ // Phase F-2-B (B4): startup-token auth. Generated once on boot if no
219
+ // token file exists; reused if the lifecycle CLI (F-2-D) wrote one
220
+ // before the container started. SPA reads via /api/bootstrap (unauthed
221
+ // for single-user-local; T4 mitigation documented in auth.mjs).
222
+ const auth = new StartupToken({ tokenPath: TOKEN_PATH });
223
+ auth.ensure();
224
+
225
+ // World-names store: persists human-friendly names per world. Container
226
+ // id stays immutable; name is what the UI shows as the world's heading.
227
+ // File path is mounted at /data/world-names.json (host: ~/.olam/world-names.json).
228
+ const worldNames = createWorldNamesStore(WORLD_NAMES_PATH);
229
+
230
+ // Phase E2 (olam-dogfood-vision): LocalWorldsSource — wraps today's
231
+ // dockerode-driven enumeration in a WorldsSource-shaped object so E4's
232
+ // composition layer can fan out across multiple sources. Deps are
233
+ // injected as functions so the source reads fresh state per list()
234
+ // (post-create/destroy mutations are visible immediately) and to avoid
235
+ // the module-cycle that direct imports would create.
236
+ // getWorldsRegistry — fresh WORLDS map per call
237
+ // getWorldName — operator-set friendly name or null
238
+ // fetchWorldServices — same probe path as the pre-E2 inline handler
239
+ const localSource = createLocalWorldsSource({
240
+ getWorldsRegistry: () => WORLDS,
241
+ getWorldName: (id) => worldNames.get(id),
242
+ fetchWorldServices,
243
+ });
244
+
245
+ // Phase E4 (olam-dogfood-vision): worlds-source composition.
246
+ //
247
+ // `OLAM_HOST_CP_PYLON_ENABLED=1|true` adds the Pylon source to the
248
+ // composition chain. Until the @pleri/pylon SDK is wired (E3 stub),
249
+ // the pylon source returns []; enabling it is a strict no-op over
250
+ // local-only behavior — it just exercises the composition path.
251
+ const PYLON_ENABLED = ['1', 'true'].includes(
252
+ String(process.env.OLAM_HOST_CP_PYLON_ENABLED ?? '').toLowerCase(),
253
+ );
254
+
255
+ /**
256
+ * Builds the composed WorldsSource array in PRECEDENCE ORDER.
257
+ *
258
+ * ORDER MATTERS — composeWorldsSources dedupes by id and the LAST
259
+ * source to claim an id wins. Cloud must come AFTER local so Pylon-
260
+ * managed metadata overrides local docker stubs when an id is
261
+ * resident in both. Reordering this array silently changes which
262
+ * source's `status`/`services`/`name` values surface in the SPA — a
263
+ * regression surface that would otherwise be invisible to a code-
264
+ * reviewer skimming the call site.
265
+ *
266
+ * The function form (rather than a top-level const) makes the
267
+ * convention explicit at every call site and gives tests a clean
268
+ * seam for asserting precedence without re-instantiating server
269
+ * state.
270
+ *
271
+ * Audit follow-up (Phase E CP3 HIGH-3): extracted from inline
272
+ * `worldsSources` const so the precedence contract is named, not
273
+ * smuggled in as array-literal order.
274
+ *
275
+ * @param {{ pylonEnabled: boolean }} flags
276
+ * @returns {import('./worlds-source.mjs').WorldsSource[]}
277
+ * Sources in precedence order (last wins on dedup).
278
+ */
279
+ function buildWorldsSources({ pylonEnabled }) {
280
+ /** @type {import('./worlds-source.mjs').WorldsSource[]} */
281
+ const sources = [localSource];
282
+ if (pylonEnabled) {
283
+ // APPEND, never prepend — cloud must come after local for
284
+ // dedup-cloud-wins to hold.
285
+ sources.push(createPylonWorldsSource({ enabled: true }));
286
+ }
287
+ return sources;
288
+ }
289
+
290
+ const worldsSources = buildWorldsSources({ pylonEnabled: PYLON_ENABLED });
291
+
292
+ // ── PR merge auto-destroy ─────────────────────────────────────────────
293
+
294
+ const prStateStore = createWorldPrStateStore(PR_STATE_PATH);
295
+
296
+ async function resolveGhToken() {
297
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
298
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
299
+ try {
300
+ const raw = fs.readFileSync(GH_HOSTS_PATH, 'utf-8');
301
+ const parsed = parseYaml(raw);
302
+ return parsed?.['github.com']?.oauth_token ?? null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ const prPoller = createPrMergePoller({
309
+ prStateStore,
310
+ getGhToken: resolveGhToken,
311
+ destroyWorld: async (worldId) => {
312
+ tunnelManager.killWorld(worldId);
313
+ const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
314
+ const containerName = `olam-${worldId}-devbox`;
315
+ try {
316
+ await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/stop`, {
317
+ method: 'POST',
318
+ signal: AbortSignal.timeout(10000),
319
+ });
320
+ } catch (err) {
321
+ console.error(`[pr-merge-poller] container stop failed for ${worldId}:`, err.message);
322
+ }
323
+ if (worldId in WORLDS) {
324
+ const next = { ...WORLDS };
325
+ delete next[worldId];
326
+ WORLDS = next;
327
+ persistRegistry();
328
+ }
329
+ },
330
+ pollIntervalMs: PR_POLL_INTERVAL_MS,
331
+ gracePeriodMs: MERGE_GRACE_MS,
332
+ });
333
+ prPoller.start();
334
+
335
+ // ── Worlds-DB reconcile loop ────────────────────────────────────
336
+ //
337
+ // When host-cp runs bare-node, the CLI's auto-register may not have fired
338
+ // (e.g., host-cp started after `olam create`). This reconciler bridges
339
+ // that gap: it reads worlds.db and registers any running worlds that
340
+ // aren't already in WORLDS.
341
+ const worldsDbReconciler = startWorldsDbReconciler({
342
+ dbPath: WORLDS_DB_PATH,
343
+ dockerHost: DOCKER_HOST,
344
+ worldHost: WORLD_HOST,
345
+ getRegistry: () => WORLDS,
346
+ onWorldAdded: (id, port) => {
347
+ WORLDS = { ...WORLDS, [id]: port };
348
+ persistRegistry();
349
+ },
350
+ onWorldRemoved: (id) => {
351
+ if (id in WORLDS) {
352
+ const next = { ...WORLDS };
353
+ delete next[id];
354
+ WORLDS = next;
355
+ persistRegistry();
356
+ }
357
+ },
358
+ });
359
+
360
+ // ── Plan orchestrator (Phase 1 spike) ─────────────────────────────────────
361
+ //
362
+ // Single-persona brainstorm conversation backed by @mariozechner/pi-coding-agent.
363
+ // Uses the Olam auth-service vault (same credentials as the rest of host-cp).
364
+ // No ANTHROPIC_API_KEY required — tokens are fetched from auth-service on demand.
365
+ const planOrchestrator = new PlanOrchestrator({
366
+ authServiceUrl: AUTH_SERVICE_URL,
367
+ authServiceSecret: AUTH_SERVICE_SECRET,
368
+ });
369
+
370
+ /**
371
+ * Guard: return 503 when auth-service has no active claude credential.
372
+ * Async — calls auth-service with a 5s timeout.
373
+ *
374
+ * @param {import('node:http').ServerResponse} res
375
+ * @returns {Promise<boolean>}
376
+ */
377
+ async function requirePlanCredential(res) {
378
+ const ok = await planOrchestrator.hasCredential();
379
+ if (!ok) {
380
+ jsonReply(res, 503, { error: 'no_claude_credential', message: 'No active Claude credential in vault. Add one via Settings → Credentials.' });
381
+ return false;
382
+ }
383
+ return true;
384
+ }
385
+
386
+ // ── World-progress caches (Phase A — feat/inbox-row-progress) ───
387
+ //
388
+ // `prCache` memoizes GitHub PR/check results per branch with a 30s TTL
389
+ // so each row poll doesn't hit the GH API. `progressCache` is a 5s TTL
390
+ // on the assembled progress envelope (thoughts + git + pr) so a 5s SPA
391
+ // poll across N worlds is at most N file-read+1-gh-call per cycle.
392
+ const prCache = createPrCache();
393
+ const progressCache = new Map(); // worldId → {fetchedAt, data}
394
+ /** @type {{ data: unknown[], fetchedAt: number } | null} */
395
+ let prListCacheEntry = null; // /api/prs — 60s TTL global PR list for Cmd+K
396
+
397
+ // Phase F-2-B (B5): SSE concurrent-connection cap (P4 mitigation). Caps
398
+ // at OLAM_SSE_MAX_CONCURRENT (default 50). Above the cap returns
399
+ // 503 Retry-After: 30 so the SPA can back off + the per-world CP isn't
400
+ // flooded with proxy connections.
401
+ const sseGate = new SseGate({ maxConcurrent: SSE_CAP });
402
+
403
+ // Subscribe to docker events on boot. Unsubscribed at shutdown.
404
+ const stopEvents = subscribeDockerEvents({
405
+ dockerHost: DOCKER_HOST,
406
+ onWorldRestart: (worldId) => cache.invalidate(worldId),
407
+ });
408
+
409
+ /**
410
+ * Resolve worldId → secret. Cache hit returns immediately; miss fetches
411
+ * from the docker-socket-proxy + caches.
412
+ *
413
+ * @param {string} worldId
414
+ * @returns {Promise<string>}
415
+ */
416
+ async function getSecret(worldId) {
417
+ const cached = cache.get(worldId);
418
+ if (cached !== null) return cached;
419
+ const secret = await fetchContainerSecret({
420
+ worldId,
421
+ dockerHost: DOCKER_HOST,
422
+ });
423
+ cache.set(worldId, secret);
424
+ return secret;
425
+ }
426
+
427
+ // ── HTTP server ──────────────────────────────────────────────────────
428
+
429
+ const server = http.createServer(async (req, res) => {
430
+ const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
431
+
432
+ // /health: fast diagnostics, no auth, no proxying. Docker healthcheck
433
+ // hits this; SPA pre-load may also poll. Stays unauth so the container
434
+ // healthcheck doesn't need to know the token.
435
+ if (url.pathname === '/health') {
436
+ return jsonReply(res, 200, {
437
+ status: 'ok',
438
+ phase: 'B4',
439
+ uptime_seconds: Math.floor((Date.now() - STARTED_AT) / 1000),
440
+ cache: {
441
+ worlds: cache.worldIds(),
442
+ ttl_sec: TTL_SEC,
443
+ },
444
+ mode: {
445
+ deployment: HOST_CP_MODE,
446
+ world_host: HOST_FOR_WORLD,
447
+ },
448
+ registry: {
449
+ worlds_known: Object.keys(WORLDS),
450
+ source: 'env: OLAM_HOST_CP_WORLDS_JSON (B6 will wire worlds.db)',
451
+ },
452
+ auth: {
453
+ token_path: TOKEN_PATH,
454
+ token_present: Boolean(auth.token),
455
+ },
456
+ sse: sseGate.stats(),
457
+ });
458
+ }
459
+
460
+ // /api/bootstrap: SPA reads the token at load time. Unauthed because
461
+ // anything local that can hit 127.0.0.1:19000 can also read the token
462
+ // file directly (same OS-level privilege boundary). Single-user-only;
463
+ // multi-user mode (Phase G+) will swap this for cookie-with-Secure.
464
+ if (url.pathname === '/api/bootstrap') {
465
+ return jsonReply(res, 200, {
466
+ token: auth.token,
467
+ cookie_name: 'olam_host_cp_token',
468
+ header_name: 'authorization',
469
+ header_format: 'Bearer <token>',
470
+ hint: 'SPA: set document.cookie = `olam_host_cp_token=${token}; path=/; samesite=strict` then fetch (`/api/world/...`) freely.',
471
+ });
472
+ }
473
+
474
+ // Phase F-2-D dogfood fix: serve the SPA dist/ for non-API GET requests
475
+ // BEFORE auth gate. The SPA itself is the auth gate — it loads, fetches
476
+ // /api/bootstrap (unauthed), sets the cookie, then makes authed API calls.
477
+ // Without static-file serving the SPA can never load and the operator
478
+ // sees raw 401 JSON in the browser.
479
+ //
480
+ // Routes hit this branch:
481
+ // / → dist/index.html
482
+ // /worlds → dist/index.html (SPA routing)
483
+ // /workspaces → dist/index.html
484
+ // /world/<id> → dist/index.html
485
+ // /assets/*.js → dist/assets/*.js
486
+ // /favicon.ico → dist/favicon.ico (if present)
487
+ //
488
+ // Anything that doesn't match a static file falls through to the auth
489
+ // gate + 404 below (preserves the JSON-error contract for unknown
490
+ // /api/* paths).
491
+ if (req.method === 'GET' || req.method === 'HEAD') {
492
+ const served = await tryServeStatic(req, res, url.pathname);
493
+ if (served) return;
494
+ }
495
+
496
+ // ALL OTHER ROUTES require auth. Reject with 401 if neither cookie
497
+ // nor Bearer header matches.
498
+ if (!auth.isAuthorized(req)) {
499
+ res.writeHead(401, {
500
+ 'Content-Type': 'application/json; charset=utf-8',
501
+ 'WWW-Authenticate': 'Bearer realm="olam-host-cp"',
502
+ });
503
+ return res.end(JSON.stringify({
504
+ error: 'unauthorized',
505
+ message: 'Set cookie olam_host_cp_token=<value> or Authorization: Bearer <value>. Read /api/bootstrap to get the token.',
506
+ }));
507
+ }
508
+
509
+ // /api/version/status: returns the current version snapshot (baked SHA
510
+ // vs operator's local HEAD). No auth required beyond the existing gate
511
+ // (already applied above). Phase 1 only — detection, no auto-upgrade.
512
+ if (url.pathname === '/api/version/status' && req.method === 'GET') {
513
+ if (!versionSnapshot) {
514
+ return jsonReply(res, 503, { error: 'version_check_pending', message: 'Version snapshot not yet available — try again in a moment.' });
515
+ }
516
+ return jsonReply(res, 200, versionSnapshot);
517
+ }
518
+
519
+ // /api/worlds (B7 + dogfood + E2/E4): list worlds via composed
520
+ // sources. With Pylon disabled (default), `worldsSources` =
521
+ // [localSource] and the response is identical to E2 — just the
522
+ // local-source's `list()` output. With OLAM_HOST_CP_PYLON_ENABLED,
523
+ // pylonSource is appended; on id collision, cloud wins (later
524
+ // source overrides earlier in the array). Until the SDK lands the
525
+ // pylon source returns [], so enabling it is currently a no-op.
526
+ if (url.pathname === '/api/worlds' && req.method === 'GET') {
527
+ const worlds = await composeWorldsSources(worldsSources);
528
+ const enriched = worlds.map((w) => {
529
+ const pr = prStateStore.get(w.id);
530
+ if (!pr) return w;
531
+ const graceExpiresAt = pr.pr_merged_at
532
+ ? new Date(new Date(pr.pr_merged_at).getTime() + MERGE_GRACE_MS).toISOString()
533
+ : null;
534
+ return {
535
+ ...w,
536
+ pr_url: pr.pr_url,
537
+ pr_state: pr.pr_state,
538
+ pr_merged_at: pr.pr_merged_at ?? null,
539
+ grace_expires_at: graceExpiresAt,
540
+ auto_destroy_on_merge: pr.auto_destroy_on_merge,
541
+ };
542
+ });
543
+ return jsonReply(res, 200, enriched);
544
+ }
545
+
546
+ // PATCH /api/worlds/<id> — rename a world. Container id is immutable;
547
+ // this only updates the persisted display name. Body: { name: string }.
548
+ // 200 on success, 400 on bad input, 404 if the world isn't in the
549
+ // registry. Empty/whitespace name removes the entry (revert to id).
550
+ const renameMatch = url.pathname.match(/^\/api\/worlds\/([^/]+)\/?$/);
551
+ if (renameMatch && req.method === 'PATCH') {
552
+ const worldId = decodeURIComponent(renameMatch[1]);
553
+ if (!(worldId in WORLDS)) {
554
+ return jsonReply(res, 404, {
555
+ error: 'unknown_world',
556
+ worldId,
557
+ message: 'world not in registry',
558
+ });
559
+ }
560
+ let bodyRaw = '';
561
+ req.setEncoding('utf-8');
562
+ req.on('data', (chunk) => { bodyRaw += chunk; });
563
+ req.on('end', () => {
564
+ let parsed;
565
+ try {
566
+ parsed = JSON.parse(bodyRaw || '{}');
567
+ } catch (err) {
568
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
569
+ }
570
+ // Allow null/empty to clear the name.
571
+ if (parsed?.name === null || parsed?.name === '') {
572
+ worldNames.remove(worldId);
573
+ return jsonReply(res, 200, { id: worldId, name: null });
574
+ }
575
+ const normalized = normalizeName(parsed?.name);
576
+ if (normalized === null) {
577
+ return jsonReply(res, 400, {
578
+ error: 'invalid_name',
579
+ message: 'PATCH body must include {name: string} (non-empty after trim) or {name: null} to clear.',
580
+ });
581
+ }
582
+ try {
583
+ const stored = worldNames.set(worldId, normalized);
584
+ jsonReply(res, 200, { id: worldId, name: stored });
585
+ } catch (err) {
586
+ jsonReply(res, 500, { error: 'rename_failed', message: err.message });
587
+ }
588
+ });
589
+ return;
590
+ }
591
+
592
+ // GET /api/worlds/<id>/pr — per-world PR state for the inbox row badge.
593
+ // Returns { pr_number, pr_url, pr_state } sourced from prStateStore.
594
+ // 200 with all-null fields when no PR is tracked for this world.
595
+ const worldPrGetMatch = url.pathname.match(/^\/api\/worlds\/([^/]+)\/pr\/?$/);
596
+ if (worldPrGetMatch && req.method === 'GET') {
597
+ const worldId = decodeURIComponent(worldPrGetMatch[1]);
598
+ const pr = prStateStore.get(worldId);
599
+ if (!pr) {
600
+ return jsonReply(res, 200, { pr_number: null, pr_url: null, pr_state: null });
601
+ }
602
+ const prState = pr.pr_state === 'merged_destroyed' ? 'merged' : (pr.pr_state ?? null);
603
+ return jsonReply(res, 200, {
604
+ pr_number: pr.pr_number ?? null,
605
+ pr_url: pr.pr_url ?? null,
606
+ pr_state: prState,
607
+ });
608
+ }
609
+
610
+ // POST /api/worlds — Phase D4 (olam-dogfood-vision).
611
+ //
612
+ // Architectural decision (deviation from D4 plan, audited at phase
613
+ // boundary): host-cp does NOT directly invoke WorldManager.createWorld
614
+ // because:
615
+ // - host-cp runs in a slim Docker container (node:22-slim) without
616
+ // @olam/core's dependency tree (better-sqlite3 native bindings,
617
+ // ~hundreds of LOC of git/docker/auth orchestration).
618
+ // - Bringing @olam/core into the container would bloat the image
619
+ // and tightly couple host-cp to per-platform native bindings.
620
+ // - The MCP layer (Phase D1) and the CLI BOTH already have
621
+ // WorldManager via @olam/core. They run on the operator's host
622
+ // where native bindings + git/docker just work.
623
+ //
624
+ // So this endpoint validates the contract shape and returns a
625
+ // delegation payload pointing the caller at the MCP tool
626
+ // (`olam.create_from_prompt`) or the `olam create` CLI. The SPA's
627
+ // hypothetical CreateWorldModal — currently unwired — would consume
628
+ // this response and surface the CLI command to the operator.
629
+ //
630
+ // The shape `{useMcpTool, command, args, reason}` lets future
631
+ // automation (e.g., a Claude Code session triggered from the SPA)
632
+ // act on the response programmatically.
633
+ if (url.pathname === '/api/worlds' && req.method === 'POST') {
634
+ let bodyRaw = '';
635
+ req.setEncoding('utf-8');
636
+ req.on('data', (chunk) => { bodyRaw += chunk; });
637
+ return req.on('end', () => {
638
+ let body;
639
+ try {
640
+ body = JSON.parse(bodyRaw || '{}');
641
+ } catch (err) {
642
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
643
+ }
644
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
645
+ return jsonReply(res, 400, {
646
+ error: 'invalid_body',
647
+ message: 'POST /api/worlds requires a JSON object body',
648
+ });
649
+ }
650
+ // Contract validation: at least one of (repos, workspace) required.
651
+ const hasRepos = Array.isArray(body.repos) && body.repos.length > 0;
652
+ const hasWorkspace = typeof body.workspace === 'string' && body.workspace.length > 0;
653
+ if (!hasRepos && !hasWorkspace) {
654
+ return jsonReply(res, 400, {
655
+ error: 'invalid_body',
656
+ message: 'POST /api/worlds requires either `repos: string[]` or `workspace: string` to identify the workspace',
657
+ });
658
+ }
659
+ // D-phase audit follow-up (HIGH-2): dropped the `cliCommand`
660
+ // joined-string field. The previous shape was render-ready for
661
+ // SPA copy-paste, but body.task / body.repos[i] could contain
662
+ // shell metacharacters (`;`, `&&`, `$()`, backticks) that would
663
+ // become injection-by-copy-paste once the SPA modal wires up.
664
+ // Callers wanting a CLI invocation must now compose it
665
+ // themselves with proper shell-quoting (Node's `child_process`
666
+ // arg array, or a `shell-quote` package, or hand-quoted) using
667
+ // the structured `mcpToolArgs` below.
668
+ return jsonReply(res, 200, {
669
+ useMcpTool: 'olam.create_from_prompt',
670
+ mcpToolArgs: {
671
+ prompt: typeof body.task === 'string' ? body.task : undefined,
672
+ repos: hasRepos ? body.repos : undefined,
673
+ workspace: hasWorkspace ? body.workspace : undefined,
674
+ name: typeof body.name === 'string' ? body.name : undefined,
675
+ autoCodexReview: body.autoCodexReview === true,
676
+ },
677
+ reason: 'host-cp does not bridge WorldManager directly. Use the MCP tool olam.create_from_prompt (Phase D1) or invoke `olam create` from the CLI on the operator host (composing the command with proper shell-quoting from mcpToolArgs). The world will auto-register with host-cp post-creation (Phase F-2-D PR #50).',
678
+ });
679
+ });
680
+ }
681
+
682
+ // ── Admin registry: programmatic register/deregister ──────────────
683
+ //
684
+ // POST /api/admin/registry body: {id: string, port: number}
685
+ // → upsert into registry, persist to /data/host-cp-registry.json,
686
+ // return updated WORLDS map.
687
+ //
688
+ // DELETE /api/admin/registry/<id>
689
+ // → remove world from registry, persist, return updated WORLDS map.
690
+ //
691
+ // GET /api/admin/registry
692
+ // → return current WORLDS map (for diagnostics + idempotency checks).
693
+ //
694
+ // Used by the `olam host-cp register/deregister` CLI commands and
695
+ // auto-called by `olam create` / `olam destroy`.
696
+ if (url.pathname === '/api/admin/registry' && req.method === 'GET') {
697
+ return jsonReply(res, 200, { worlds: WORLDS });
698
+ }
699
+ if (url.pathname === '/api/admin/registry' && req.method === 'POST') {
700
+ let body = '';
701
+ req.setEncoding('utf-8');
702
+ req.on('data', (chunk) => { body += chunk; });
703
+ req.on('end', () => {
704
+ let parsed;
705
+ try {
706
+ parsed = JSON.parse(body || '{}');
707
+ } catch (err) {
708
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
709
+ }
710
+ const id = typeof parsed?.id === 'string' ? parsed.id.trim() : null;
711
+ const port = typeof parsed?.port === 'number' ? parsed.port : null;
712
+ if (!id || !port || !Number.isFinite(port)) {
713
+ return jsonReply(res, 400, {
714
+ error: 'invalid_payload',
715
+ message: 'POST body must include {id: string, port: number}',
716
+ });
717
+ }
718
+ WORLDS = { ...WORLDS, [id]: port };
719
+ persistRegistry();
720
+ jsonReply(res, 200, { worlds: WORLDS });
721
+ });
722
+ return;
723
+ }
724
+ const adminDelete = /^\/api\/admin\/registry\/([^/?#]+)$/.exec(url.pathname);
725
+ if (adminDelete && req.method === 'DELETE') {
726
+ const id = decodeURIComponent(adminDelete[1]);
727
+ // Kill tunnels before removing from registry so no cloudflared procs orphan.
728
+ tunnelManager.killWorld(id);
729
+ if (id in WORLDS) {
730
+ const next = { ...WORLDS };
731
+ delete next[id];
732
+ WORLDS = next;
733
+ persistRegistry();
734
+ }
735
+ return jsonReply(res, 200, { worlds: WORLDS });
736
+ }
737
+
738
+ // ── Cloudflare Tunnels API ────────────────────────────────────────
739
+ //
740
+ // GET /api/worlds/:id/tunnels list tunnel state per service
741
+ // POST /api/worlds/:id/tunnels start cloudflared per service
742
+ // DEL /api/worlds/:id/tunnels/:name stop one service's tunnel
743
+ // GET /api/worlds/:id/tunnels/status live probe results
744
+ //
745
+ // Delegates to world-tunnel-manager.mjs which owns the process Map
746
+ // and persists state to world-tunnels.json (same sidecar pattern as
747
+ // world-pr-state.json).
748
+
749
+ const tunnelsBase = /^\/api\/worlds\/([^/?#]+)\/tunnels(\/([^/?#]+))?$/.exec(url.pathname);
750
+ if (tunnelsBase) {
751
+ const worldId = decodeURIComponent(tunnelsBase[1]);
752
+ const serviceSegment = tunnelsBase[3] ? decodeURIComponent(tunnelsBase[3]) : null;
753
+
754
+ if (req.method === 'GET' && !serviceSegment) {
755
+ return jsonReply(res, 200, tunnelManager.getWorldTunnels(worldId));
756
+ }
757
+
758
+ if (req.method === 'GET' && serviceSegment === 'status') {
759
+ const tunnels = tunnelManager.getWorldTunnels(worldId);
760
+ const results = await Promise.all(
761
+ tunnels
762
+ .filter((t) => t.url)
763
+ .map(async (t) => {
764
+ try {
765
+ const r = await fetch(t.url, { signal: AbortSignal.timeout(3000) });
766
+ return { name: t.name, url: t.url, reachable: r.ok };
767
+ } catch {
768
+ return { name: t.name, url: t.url, reachable: false };
769
+ }
770
+ }),
771
+ );
772
+ return jsonReply(res, 200, results);
773
+ }
774
+
775
+ if (req.method === 'POST' && !serviceSegment) {
776
+ let body = '';
777
+ req.setEncoding('utf-8');
778
+ req.on('data', (chunk) => { body += chunk; });
779
+ req.on('end', async () => {
780
+ let parsed;
781
+ try { parsed = JSON.parse(body || '{}'); } catch (err) {
782
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
783
+ }
784
+ if (!Array.isArray(parsed?.services) || parsed.services.length === 0) {
785
+ return jsonReply(res, 400, {
786
+ error: 'invalid_payload',
787
+ message: 'POST body must include {services: [{name, port}]}',
788
+ });
789
+ }
790
+ // Infra ports must never be published — filter both client-side (PublishModal)
791
+ // and here so direct API callers cannot tunnel the terminal or per-world CP.
792
+ const INFRA_PORTS = new Set([7681, 8080]);
793
+ const validServices = parsed.services
794
+ .filter((svc) => typeof svc.name === 'string')
795
+ .map((svc) => ({ name: svc.name, port: Number(svc.port) }))
796
+ .filter((svc) => Number.isFinite(svc.port) && svc.port > 0 && !INFRA_PORTS.has(svc.port));
797
+ const results = await Promise.all(
798
+ validServices.map(async (svc) => {
799
+ try {
800
+ const tunnelUrl = await tunnelManager.startTunnel(worldId, svc.name, svc.port);
801
+ return { name: svc.name, port: svc.port, url: tunnelUrl, status: 'running' };
802
+ } catch (err) {
803
+ if (err.name === 'AlreadyStartingError') {
804
+ return { name: svc.name, port: svc.port, url: null, status: 'already_starting', message: err.message };
805
+ }
806
+ return { name: svc.name, port: svc.port, url: null, status: 'error', error: err.message };
807
+ }
808
+ }),
809
+ );
810
+ const anyAlreadyStarting = results.some((r) => r.status === 'already_starting');
811
+ jsonReply(res, anyAlreadyStarting ? 409 : 200, results);
812
+ });
813
+ return;
814
+ }
815
+
816
+ if (req.method === 'DELETE' && serviceSegment && serviceSegment !== 'status') {
817
+ tunnelManager.stopTunnel(worldId, serviceSegment);
818
+ res.writeHead(204);
819
+ res.end();
820
+ return;
821
+ }
822
+ }
823
+
824
+ // /api/workspaces (B6 + dogfood): list workspaces from
825
+ // ~/.olam/workspaces/*.yaml. Returns the CF-Worker-compatible
826
+ // envelope `{workspaces: WorkspaceSummary[]}` so the existing SPA's
827
+ // useWorkspaces hook unmarshals correctly. Sensitive keys redacted
828
+ // by workspacesForApi for any future field that holds them.
829
+ if (url.pathname === '/api/workspaces' && req.method === 'GET') {
830
+ const ws = loadWorkspaces(WORKSPACES_DIR);
831
+ const summaries = workspacesForApi(ws).map((w) => ({
832
+ name: w.name,
833
+ repoCount: Array.isArray(w.repos) ? w.repos.length : 0,
834
+ // M5 dogfood follow-up: Phase D's `olam_create_from_prompt` MCP
835
+ // tool + `olam create --from-prompt` CLI both call this endpoint
836
+ // to assemble a workspace catalog for inference. They expect
837
+ // `projects: string[]` per entry; without it, decideWorkspaceMatch
838
+ // gets an empty catalog and everything degenerates to "create-
839
+ // new" or low-confidence picker. Project names are NOT sensitive
840
+ // (workspace YAMLs are operator-curated), so emitting them here
841
+ // is safe and additive — existing SPA consumers ignore the
842
+ // extra field.
843
+ projects: Array.isArray(w.repos)
844
+ ? w.repos.map((r) => r.name).filter((n) => typeof n === 'string')
845
+ : [],
846
+ updatedAt: w.updatedAt,
847
+ }));
848
+ return jsonReply(res, 200, { workspaces: summaries });
849
+ }
850
+
851
+ // /api/projects (B6 + D13): deduplicated project union across all workspaces.
852
+ // SPA's create-world flow consumes this for the project-multi-select.
853
+ if (url.pathname === '/api/projects' && req.method === 'GET') {
854
+ const ws = loadWorkspaces(WORKSPACES_DIR);
855
+ return jsonReply(res, 200, projectsFromWorkspaces(ws));
856
+ }
857
+
858
+ // /api/workspaces/match (B6 + D13 + T14): exact set-equality match.
859
+ // Body: { projects: string[] }. Returns 0/1/N matching workspaces for
860
+ // the SPA's 3-state chip UX ([NEW] / pre-selected / dropdown).
861
+ if (url.pathname === '/api/workspaces/match' && req.method === 'POST') {
862
+ let bodyRaw = '';
863
+ req.setEncoding('utf-8');
864
+ req.on('data', (chunk) => { bodyRaw += chunk; });
865
+ req.on('end', () => {
866
+ let parsed;
867
+ try {
868
+ parsed = JSON.parse(bodyRaw || '{}');
869
+ } catch (err) {
870
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
871
+ }
872
+ const projects = Array.isArray(parsed?.projects) ? parsed.projects.filter((p) => typeof p === 'string') : null;
873
+ if (!projects) {
874
+ return jsonReply(res, 400, {
875
+ error: 'missing_projects',
876
+ message: 'POST body must include {projects: string[]}',
877
+ });
878
+ }
879
+ const ws = loadWorkspaces(WORKSPACES_DIR);
880
+ const matches = matchWorkspacesByProjects(ws, projects);
881
+ jsonReply(res, 200, {
882
+ projects,
883
+ matches: workspacesForApi(matches),
884
+ count: matches.length,
885
+ });
886
+ });
887
+ return;
888
+ }
889
+
890
+ // ── Host-level auth proxy (/api/auth/*) ───────────────────────────
891
+ //
892
+ // Proxies to the olam-auth container (port 9999). These are distinct from
893
+ // the per-world /api/auth/* routes — they authenticate at the host level so
894
+ // all new worlds inherit credentials without re-authenticating.
895
+ //
896
+ // GET /api/auth/status → normalized {claude:{authenticated,email?}, codex:{authenticated}}
897
+ // POST /api/auth/claude/trigger → start PKCE, returns {loginUrl, state}
898
+ // POST /api/auth/claude/complete → body:{state,code}, completes exchange
899
+ // POST /api/auth/codex/trigger → same for codex
900
+ // POST /api/auth/codex/complete → same for codex
901
+
902
+ if (url.pathname === '/api/auth/status' && req.method === 'GET') {
903
+ try {
904
+ const upstream = await authServiceFetch('GET', '/credentials/status');
905
+ const raw = await upstream.json();
906
+ const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
907
+ const claudeAcc = accounts.find((a) => a.provider === 'claude' && a.tokenValid);
908
+ const codexAcc = accounts.find((a) => a.provider === 'codex' && a.tokenValid);
909
+ return jsonReply(res, 200, {
910
+ claude: claudeAcc
911
+ ? { authenticated: true, email: claudeAcc.email }
912
+ : { authenticated: false },
913
+ codex: codexAcc
914
+ ? { authenticated: true, email: codexAcc.email }
915
+ : { authenticated: false },
916
+ });
917
+ } catch (err) {
918
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
919
+ }
920
+ }
921
+
922
+ // Compat aliases: /api/auth/trigger and /api/auth/complete (no
923
+ // claude/ prefix) are what the legacy useAuth.ts hook calls when it's
924
+ // mounted at host-cp scope (no /world/<id> in the URL, so the
925
+ // bootstrap script doesn't rewrite to a per-world path). The new
926
+ // useHostAuth.ts hook calls /api/auth/claude/trigger directly. Both
927
+ // forward to the same upstream /credentials/add. Without this alias
928
+ // the host-level auth modal 404s.
929
+ if ((url.pathname === '/api/auth/trigger' || url.pathname === '/api/auth/claude/trigger') && req.method === 'POST') {
930
+ try {
931
+ const upstream = await authServiceFetch('POST', '/credentials/add', { provider: 'claude', label: 'host-claude' });
932
+ const data = await upstream.json();
933
+ return jsonReply(res, upstream.status, data);
934
+ } catch (err) {
935
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
936
+ }
937
+ }
938
+
939
+ if ((url.pathname === '/api/auth/complete' || url.pathname === '/api/auth/claude/complete') && req.method === 'POST') {
940
+ try {
941
+ const body = await readRequestBody(req);
942
+ const upstream = await authServiceFetch('POST', '/credentials/complete', body);
943
+ const data = await upstream.json();
944
+ return jsonReply(res, upstream.status, data);
945
+ } catch (err) {
946
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
947
+ }
948
+ }
949
+
950
+ if (url.pathname === '/api/auth/codex/trigger' && req.method === 'POST') {
951
+ try {
952
+ const upstream = await authServiceFetch('POST', '/credentials/add', { provider: 'codex', label: 'host-codex' });
953
+ const data = await upstream.json();
954
+ return jsonReply(res, upstream.status, data);
955
+ } catch (err) {
956
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
957
+ }
958
+ }
959
+
960
+ if (url.pathname === '/api/auth/codex/complete' && req.method === 'POST') {
961
+ try {
962
+ const body = await readRequestBody(req);
963
+ const upstream = await authServiceFetch('POST', '/credentials/complete', body);
964
+ const data = await upstream.json();
965
+ return jsonReply(res, upstream.status, data);
966
+ } catch (err) {
967
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
968
+ }
969
+ }
970
+
971
+ // ── Multi-credential vault (PR #79 surface) ─────────────────────────
972
+ //
973
+ // The auth-service's `/credentials/status` already returns the full
974
+ // vault — `{accounts:[{id, accountLabel, email, provider, plan, state,
975
+ // tokenValid, expiresIn, rateLimited, rateLimitResetsAt, lastUsed,
976
+ // lastRefreshed, usage}, …]}`. We re-shape it here into the contract
977
+ // the SPA's CredentialFleet expects (camelCase, flat usage block) so
978
+ // future auth-service refactors stay invisible to the dashboard.
979
+ //
980
+ // GET /api/auth/credentials → {credentials:[…]}
981
+ // POST /api/auth/credentials/add → body:{provider,label}
982
+ // POST /api/auth/credentials/<id>/disable
983
+ // POST /api/auth/credentials/<id>/enable
984
+ // DELETE /api/auth/credentials/<id>
985
+ // GET /api/auth/events → SSE hotswap stream
986
+
987
+ if (url.pathname === '/api/auth/credentials' && req.method === 'GET') {
988
+ try {
989
+ const upstream = await authServiceFetch('GET', '/credentials/status');
990
+ const raw = await upstream.json();
991
+ const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
992
+ return jsonReply(res, upstream.status, { credentials: accounts.map(normalizeCredential) });
993
+ } catch (err) {
994
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
995
+ }
996
+ }
997
+
998
+ if (url.pathname === '/api/auth/credentials/add' && req.method === 'POST') {
999
+ try {
1000
+ const body = await readRequestBody(req);
1001
+ const provider = body && typeof body === 'object' && body.provider ? String(body.provider) : 'claude';
1002
+ const label = body && typeof body === 'object' && body.label ? String(body.label) : '';
1003
+ if (!label) return jsonReply(res, 400, { error: 'label_required' });
1004
+ const upstream = await authServiceFetch('POST', '/credentials/add', { provider, label });
1005
+ const data = await upstream.json();
1006
+ return jsonReply(res, upstream.status, data);
1007
+ } catch (err) {
1008
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1009
+ }
1010
+ }
1011
+
1012
+ if (url.pathname === '/api/auth/credentials/complete' && req.method === 'POST') {
1013
+ try {
1014
+ const body = await readRequestBody(req);
1015
+ const upstream = await authServiceFetch('POST', '/credentials/complete', body);
1016
+ const data = await upstream.json();
1017
+ return jsonReply(res, upstream.status, data);
1018
+ } catch (err) {
1019
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1020
+ }
1021
+ }
1022
+
1023
+ const credActionMatch = /^\/api\/auth\/credentials\/([^/]+)\/(disable|enable)$/.exec(url.pathname);
1024
+ if (credActionMatch && req.method === 'POST') {
1025
+ const id = decodeURIComponent(credActionMatch[1]);
1026
+ const action = credActionMatch[2];
1027
+ try {
1028
+ const upstream = await authServiceFetch('POST', `/credentials/${encodeURIComponent(id)}/${action}`);
1029
+ const data = await upstream.json();
1030
+ return jsonReply(res, upstream.status, data);
1031
+ } catch (err) {
1032
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1033
+ }
1034
+ }
1035
+
1036
+ const credDeleteMatch = /^\/api\/auth\/credentials\/([^/]+)$/.exec(url.pathname);
1037
+ if (credDeleteMatch && req.method === 'DELETE') {
1038
+ const id = decodeURIComponent(credDeleteMatch[1]);
1039
+ try {
1040
+ const upstream = await authServiceFetch('DELETE', `/credentials/${encodeURIComponent(id)}`);
1041
+ const data = await upstream.json();
1042
+ return jsonReply(res, upstream.status, data);
1043
+ } catch (err) {
1044
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1045
+ }
1046
+ }
1047
+
1048
+ if (url.pathname === '/api/auth/events' && req.method === 'GET') {
1049
+ handleAuthEvents(req, res);
1050
+ return;
1051
+ }
1052
+
1053
+ // ── Per-world credential telemetry routes ─────────────────────────────
1054
+ //
1055
+ // These are called by the in-world HTTPS proxy when it intercepts
1056
+ // api.anthropic.com responses. Authentication is via the same
1057
+ // Authorization: Bearer <OLAM_HOST_CP_TOKEN> that all API routes use.
1058
+ //
1059
+ // POST /api/worlds/:worldId/credentials/:account/rate-limited
1060
+ // → forward to auth-service /credentials/:id/rate-limited
1061
+ // POST /api/worlds/:worldId/credentials/:account/invalidate
1062
+ // → forward to auth-service /credentials/:id/invalidate
1063
+ // POST /api/worlds/:worldId/usage-cap-hit
1064
+ // → forward to auth-service /credentials/:id/usage-cap-hit
1065
+ // (account id comes from the request body)
1066
+
1067
+ const worldCredActionMatch = /^\/api\/worlds\/([^/?#]+)\/credentials\/([^/?#]+)\/(rate-limited|invalidate)$/.exec(url.pathname);
1068
+ if (worldCredActionMatch && req.method === 'POST') {
1069
+ const worldId = decodeURIComponent(worldCredActionMatch[1]);
1070
+ const account = decodeURIComponent(worldCredActionMatch[2]);
1071
+ const action = worldCredActionMatch[3];
1072
+ try {
1073
+ const body = await readRequestBody(req).catch(() => ({}));
1074
+ const upstream = await authServiceFetch(
1075
+ 'POST',
1076
+ `/credentials/${encodeURIComponent(account)}/${action}`,
1077
+ body,
1078
+ );
1079
+ const data = await upstream.json().catch(() => ({}));
1080
+ console.log(`[worlds/${worldId}] credential ${action}: account="${account}" → ${upstream.status}`);
1081
+ return jsonReply(res, upstream.status, data);
1082
+ } catch (err) {
1083
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1084
+ }
1085
+ }
1086
+
1087
+ const worldUsageCapMatch = /^\/api\/worlds\/([^/?#]+)\/usage-cap-hit$/.exec(url.pathname);
1088
+ if (worldUsageCapMatch && req.method === 'POST') {
1089
+ const worldId = decodeURIComponent(worldUsageCapMatch[1]);
1090
+ try {
1091
+ const body = await readRequestBody(req).catch(() => ({}));
1092
+ // The proxy includes `account` in the body (from ~/.claude/.olam-account-id).
1093
+ // Forward to auth-service if account is provided; log audit entry regardless.
1094
+ console.log(`[worlds/${worldId}] usage-cap-hit: account="${body?.account ?? 'unknown'}" reason="${body?.reason ?? 'unknown'}" match="${body?.match ?? ''}"`);
1095
+ if (body && typeof body === 'object' && body.account) {
1096
+ const account = String(body.account);
1097
+ const upstream = await authServiceFetch(
1098
+ 'POST',
1099
+ `/credentials/${encodeURIComponent(account)}/usage-cap-hit`,
1100
+ { resetsAt: body.resetsAt, reason: body.reason, match: body.match },
1101
+ );
1102
+ const data = await upstream.json().catch(() => ({}));
1103
+ return jsonReply(res, upstream.status, data);
1104
+ }
1105
+ // No account: log telemetry but can't update vault state.
1106
+ return jsonReply(res, 200, { ok: true, forwarded: false, reason: 'no_account_in_body' });
1107
+ } catch (err) {
1108
+ return jsonReply(res, 502, { error: 'auth_service_unavailable', message: err.message });
1109
+ }
1110
+ }
1111
+
1112
+ // GET /api/world/<id>/progress — phase ladder progress for inbox row.
1113
+ const progressMatch = /^\/api\/world\/([^/?#]+)\/progress\/?$/.exec(url.pathname);
1114
+ if (progressMatch && req.method === 'GET') {
1115
+ const worldId = decodeURIComponent(progressMatch[1]);
1116
+ if (!(worldId in WORLDS)) {
1117
+ return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
1118
+ }
1119
+ const now = Date.now();
1120
+ const cached = progressCache.get(worldId);
1121
+ if (cached && now - cached.fetchedAt < PROGRESS_CACHE_MS) {
1122
+ return jsonReply(res, 200, cached.data);
1123
+ }
1124
+ const data = await computeProgress(worldId, {
1125
+ worldsDbPath: WORLDS_DB_PATH,
1126
+ prCache,
1127
+ prStateStore,
1128
+ getGhToken: resolveGhToken,
1129
+ });
1130
+ progressCache.set(worldId, { fetchedAt: Date.now(), data });
1131
+ return jsonReply(res, 200, data);
1132
+ }
1133
+
1134
+ // /api/world/<id>/* → proxy to per-world CP with X-Olam-Secret injected.
1135
+ const parsed = parseProxyPath(url.pathname);
1136
+ if (parsed) {
1137
+ const { worldId, subPath } = parsed;
1138
+ const port = WORLDS[worldId];
1139
+ if (port === undefined) {
1140
+ return jsonReply(res, 404, {
1141
+ error: 'unknown_world',
1142
+ worldId,
1143
+ message: 'world not in registry; B6 will wire worlds.db. For dev, set OLAM_HOST_CP_WORLDS_JSON.',
1144
+ });
1145
+ }
1146
+
1147
+ // Phase F-2-D dogfood: synthesize /session/<id>/state. The CF Worker
1148
+ // had a Durable Object tracking phase progression (created → syncing
1149
+ // → cloning → configuring → warming-claude → ready). The per-world
1150
+ // CP doesn't have a phase machine — by the time it's serving HTTP,
1151
+ // the world is already provisioned + apps booted (or about to). We
1152
+ // short-circuit and return `ready` so useWorldPhase advances past
1153
+ // the default "created" + the SPA renders the dispatch UI without
1154
+ // false progression noise.
1155
+ //
1156
+ // The match is anchored: /session/<exact-worldId>/state. Anything
1157
+ // else falls through to the proxy.
1158
+ const stateMatch = subPath.match(/^\/session\/([^/?#]+)\/state(?:\?|$|#)/);
1159
+ if (stateMatch && stateMatch[1] === worldId && req.method === 'GET') {
1160
+ return jsonReply(res, 200, {
1161
+ phase: 'ready',
1162
+ sessionId: worldId,
1163
+ detail: 'world container running (docker-mode synthesized state)',
1164
+ setupLog: [],
1165
+ });
1166
+ }
1167
+
1168
+ // /api/world/<id>/ttyd/* → proxy directly to ttyd port (NOT the
1169
+ // per-world CP). The per-world CP serves its own SPA fallback for
1170
+ // unknown paths, which would clobber ttyd's HTML and produce
1171
+ // nonsensical /assets/<spa-hash>.css MIME errors.
1172
+ if (subPath.startsWith('/ttyd/') || subPath === '/ttyd') {
1173
+ const portOffset = port - 19080;
1174
+ const ttydPort = 17681 + portOffset;
1175
+ const ttydBase = `http://${HOST_FOR_WORLD}:${ttydPort}`;
1176
+ const ttydSubPath = (subPath === '/ttyd' ? '/' : subPath.slice('/ttyd'.length)) + url.search;
1177
+ proxyToWorld({ req, res, subPath: ttydSubPath, targetBase: ttydBase, secret: '' });
1178
+ return;
1179
+ }
1180
+
1181
+ let secret;
1182
+ try {
1183
+ secret = await getSecret(worldId);
1184
+ } catch (err) {
1185
+ return jsonReply(res, 502, {
1186
+ error: 'secret_fetch_failed',
1187
+ worldId,
1188
+ message: err.message,
1189
+ });
1190
+ }
1191
+ const targetBase = perWorldBase(port, HOST_FOR_WORLD);
1192
+ // Preserve query string + hash on the upstream URL.
1193
+ const subPathWithQuery = subPath + url.search;
1194
+
1195
+ // SSE paths get gated by the concurrent-connection cap (P4). If
1196
+ // we're at cap, the gate writes 503 and returns null; we bail.
1197
+ // Below the cap, we acquire a slot and wire release on close/finish.
1198
+ if (isSsePath(subPath)) {
1199
+ const slot = sseGate.acquire(res);
1200
+ if (!slot) return; // 503 already written
1201
+ wireRelease(res, slot.release);
1202
+ }
1203
+
1204
+ proxyToWorld({ req, res, subPath: subPathWithQuery, targetBase, secret });
1205
+ return;
1206
+ }
1207
+
1208
+ // POST /api/admin/world-pr — record PR association for a world
1209
+ if (url.pathname === '/api/admin/world-pr' && req.method === 'POST') {
1210
+ if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1211
+ let body = '';
1212
+ req.setEncoding('utf-8');
1213
+ req.on('data', (c) => { body += c; });
1214
+ req.on('end', () => {
1215
+ let parsed;
1216
+ try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
1217
+ const { worldId, prUrl, prNumber, prRepo } = parsed ?? {};
1218
+ if (!worldId || !prUrl) return jsonReply(res, 400, { error: 'worldId and prUrl required' });
1219
+ prStateStore.set(worldId, {
1220
+ pr_url: prUrl,
1221
+ pr_number: prNumber ?? null,
1222
+ pr_repo: prRepo ?? null,
1223
+ pr_created_at: new Date().toISOString(),
1224
+ pr_state: 'open',
1225
+ pr_merged_at: null,
1226
+ auto_destroy_on_merge: true,
1227
+ });
1228
+ jsonReply(res, 200, { worldId, pr_state: 'open' });
1229
+ });
1230
+ return;
1231
+ }
1232
+
1233
+ // PATCH /api/admin/world-pr/:worldId — update PR settings (e.g. disable auto-destroy)
1234
+ const worldPrPatch = /^\/api\/admin\/world-pr\/([^/?#]+)$/.exec(url.pathname);
1235
+ if (worldPrPatch && req.method === 'PATCH') {
1236
+ if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1237
+ const worldId = decodeURIComponent(worldPrPatch[1]);
1238
+ let body = '';
1239
+ req.setEncoding('utf-8');
1240
+ req.on('data', (c) => { body += c; });
1241
+ req.on('end', () => {
1242
+ let parsed;
1243
+ try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
1244
+ const existing = prStateStore.get(worldId);
1245
+ if (!existing) return jsonReply(res, 404, { error: 'world not in PR state store' });
1246
+ const updates = {};
1247
+ if (typeof parsed.autoDestroyOnMerge === 'boolean') updates.auto_destroy_on_merge = parsed.autoDestroyOnMerge;
1248
+ prStateStore.set(worldId, updates);
1249
+ jsonReply(res, 200, prStateStore.get(worldId));
1250
+ });
1251
+ return;
1252
+ }
1253
+
1254
+ // DELETE /api/worlds/:worldId — immediate destroy
1255
+ const worldDestroyMatch = /^\/api\/worlds\/([^/?#]+)$/.exec(url.pathname);
1256
+ if (worldDestroyMatch && req.method === 'DELETE') {
1257
+ if (!auth.isAuthorized(req)) { return res.writeHead(401).end(); }
1258
+ const worldId = decodeURIComponent(worldDestroyMatch[1]);
1259
+ tunnelManager.killWorld(worldId);
1260
+ const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
1261
+ const containerName = `olam-${worldId}-devbox`;
1262
+ try {
1263
+ await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/stop`, {
1264
+ method: 'POST',
1265
+ signal: AbortSignal.timeout(10000),
1266
+ });
1267
+ } catch (err) {
1268
+ console.error(`[destroy] container stop failed for ${worldId}:`, err.message);
1269
+ }
1270
+ if (worldId in WORLDS) {
1271
+ const next = { ...WORLDS };
1272
+ delete next[worldId];
1273
+ WORLDS = next;
1274
+ persistRegistry();
1275
+ }
1276
+ prStateStore.set(worldId, { pr_state: 'merged_destroyed' });
1277
+ return jsonReply(res, 200, { worldId, destroyed: true });
1278
+ }
1279
+
1280
+ // ── Plan API (Phase 2: multi-persona) ────────────────────────────────────
1281
+ //
1282
+ // GET /api/plan/personas — list personas
1283
+ // POST /api/plan/conversations — create conversation
1284
+ // GET /api/plan/conversations — list conversations
1285
+ // GET /api/plan/conversations/:id — get conversation + tree
1286
+ // POST /api/plan/conversations/:id/turns — submit turn (+ personaOverride)
1287
+ // POST /api/plan/conversations/:id/handoff — trigger handoff
1288
+ // GET /api/plan/conversations/:id/stream — SSE stream
1289
+
1290
+ if (url.pathname === '/api/plan/personas' && req.method === 'GET') {
1291
+ if (!await requirePlanCredential(res)) return;
1292
+ return jsonReply(res, 200, planOrchestrator.listPersonas());
1293
+ }
1294
+
1295
+ if (url.pathname === '/api/plan/conversations' && req.method === 'POST') {
1296
+ if (!await requirePlanCredential(res)) return;
1297
+ let body;
1298
+ try { body = await readRequestBody(req); } catch (err) {
1299
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1300
+ }
1301
+ const title = (body && typeof body === 'object' && typeof body.title === 'string')
1302
+ ? body.title.trim() || null
1303
+ : null;
1304
+ try {
1305
+ const conv = planOrchestrator.createConversation({ title });
1306
+ return jsonReply(res, 201, conv);
1307
+ } catch (err) {
1308
+ return jsonReply(res, 500, { error: 'create_failed', message: err.message });
1309
+ }
1310
+ }
1311
+
1312
+ if (url.pathname === '/api/plan/conversations' && req.method === 'GET') {
1313
+ if (!await requirePlanCredential(res)) return;
1314
+ try {
1315
+ return jsonReply(res, 200, planOrchestrator.listConversations());
1316
+ } catch (err) {
1317
+ return jsonReply(res, 500, { error: 'list_failed', message: err.message });
1318
+ }
1319
+ }
1320
+
1321
+ const planConvMatch = /^\/api\/plan\/conversations\/([^/?#]+)$/.exec(url.pathname);
1322
+ if (planConvMatch && req.method === 'GET') {
1323
+ if (!await requirePlanCredential(res)) return;
1324
+ const id = decodeURIComponent(planConvMatch[1]);
1325
+ try {
1326
+ const conv = planOrchestrator.getConversation(id);
1327
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id });
1328
+ // Return persisted turns alongside conversation metadata.
1329
+ const turns = planOrchestrator.getTurns(id);
1330
+ return jsonReply(res, 200, { ...conv, turns });
1331
+ } catch (err) {
1332
+ return jsonReply(res, 500, { error: 'get_failed', message: err.message });
1333
+ }
1334
+ }
1335
+
1336
+ const planTurnMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/turns$/.exec(url.pathname);
1337
+ if (planTurnMatch && req.method === 'POST') {
1338
+ if (!await requirePlanCredential(res)) return;
1339
+ const conversationId = decodeURIComponent(planTurnMatch[1]);
1340
+ let body;
1341
+ try { body = await readRequestBody(req); } catch (err) {
1342
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1343
+ }
1344
+ const content = (body && typeof body === 'object' && typeof body.content === 'string')
1345
+ ? body.content.trim()
1346
+ : '';
1347
+ if (!content) return jsonReply(res, 400, { error: 'content_required' });
1348
+ const personaOverride = (body && typeof body === 'object' && typeof body.personaOverride === 'string')
1349
+ ? body.personaOverride
1350
+ : undefined;
1351
+ try {
1352
+ const result = await planOrchestrator.submitTurn({ conversationId, content, personaOverride });
1353
+ return jsonReply(res, 202, result);
1354
+ } catch (err) {
1355
+ if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
1356
+ return jsonReply(res, 500, { error: 'submit_failed', message: err.message });
1357
+ }
1358
+ }
1359
+
1360
+ const planHandoffMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/handoff$/.exec(url.pathname);
1361
+ if (planHandoffMatch && req.method === 'POST') {
1362
+ if (!await requirePlanCredential(res)) return;
1363
+ const conversationId = decodeURIComponent(planHandoffMatch[1]);
1364
+ let body;
1365
+ try { body = await readRequestBody(req); } catch (err) {
1366
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1367
+ }
1368
+ const toPersona = body?.toPersona;
1369
+ if (typeof toPersona !== 'string' || !toPersona) {
1370
+ return jsonReply(res, 400, { error: 'toPersona_required' });
1371
+ }
1372
+ const mode = ['full', 'distilled', 'quoted'].includes(body?.mode) ? body.mode : 'full';
1373
+ const selectedTurnIds = Array.isArray(body?.selectedTurnIds) ? body.selectedTurnIds : [];
1374
+ try {
1375
+ const result = await planOrchestrator.handoff({
1376
+ conversationId, toPersona, mode, selectedTurnIds,
1377
+ });
1378
+ return jsonReply(res, 200, result);
1379
+ } catch (err) {
1380
+ if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
1381
+ return jsonReply(res, 500, { error: 'handoff_failed', message: err.message });
1382
+ }
1383
+ }
1384
+
1385
+ const planStreamMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/stream$/.exec(url.pathname);
1386
+ if (planStreamMatch && req.method === 'GET') {
1387
+ if (!await requirePlanCredential(res)) return;
1388
+ const conversationId = decodeURIComponent(planStreamMatch[1]);
1389
+ const conv = planOrchestrator.getConversation(conversationId);
1390
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
1391
+
1392
+ res.writeHead(200, {
1393
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1394
+ 'Cache-Control': 'no-cache, no-transform',
1395
+ 'Connection': 'keep-alive',
1396
+ 'X-Accel-Buffering': 'no',
1397
+ });
1398
+ res.write(':\n\n'); // initial heartbeat
1399
+
1400
+ // Replay any buffered events from the in-flight turn before subscribing.
1401
+ planOrchestrator.drainReplayBuffer(conversationId, res);
1402
+ const cleanup = planOrchestrator.addEventSink(conversationId, res);
1403
+ req.on('close', cleanup);
1404
+ req.on('error', cleanup);
1405
+ return;
1406
+ }
1407
+
1408
+ // ── Phase 4B: lookout agent management ──────────────────────────────────────
1409
+ //
1410
+ // GET /api/plan/conversations/:id/agents list lookout agents
1411
+ // POST /api/plan/conversations/:id/agents/:persona/invite invite as lookout
1412
+ // PATCH /api/plan/conversations/:id/agents/:persona update muted/mode
1413
+ // DELETE /api/plan/conversations/:id/agents/:persona uninvite
1414
+ // POST /api/plan/conversations/:id/sidebar/:signalId/dismiss dismiss signal
1415
+ // POST /api/plan/conversations/:id/sidebar/:signalId/use use signal
1416
+
1417
+ const planAgentsMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/agents$/.exec(url.pathname);
1418
+ if (planAgentsMatch && req.method === 'GET') {
1419
+ if (!await requirePlanCredential(res)) return;
1420
+ const conversationId = decodeURIComponent(planAgentsMatch[1]);
1421
+ return jsonReply(res, 200, planOrchestrator.listLookoutAgents(conversationId));
1422
+ }
1423
+
1424
+ const planAgentInviteMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/agents\/([^/?#]+)\/invite$/.exec(url.pathname);
1425
+ if (planAgentInviteMatch && req.method === 'POST') {
1426
+ if (!await requirePlanCredential(res)) return;
1427
+ const conversationId = decodeURIComponent(planAgentInviteMatch[1]);
1428
+ const personaId = decodeURIComponent(planAgentInviteMatch[2]);
1429
+ const conv = planOrchestrator.getConversation(conversationId);
1430
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
1431
+ const agent = planOrchestrator.inviteLookout(conversationId, personaId);
1432
+ return jsonReply(res, 200, agent);
1433
+ }
1434
+
1435
+ const planAgentMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/agents\/([^/?#]+)$/.exec(url.pathname);
1436
+ if (planAgentMatch && req.method === 'PATCH') {
1437
+ if (!await requirePlanCredential(res)) return;
1438
+ const conversationId = decodeURIComponent(planAgentMatch[1]);
1439
+ const personaId = decodeURIComponent(planAgentMatch[2]);
1440
+ let body;
1441
+ try { body = await readRequestBody(req); } catch (err) {
1442
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1443
+ }
1444
+ const updates = {};
1445
+ if (typeof body?.muted === 'boolean') updates.muted = body.muted;
1446
+ if (typeof body?.mode === 'string') updates.mode = body.mode;
1447
+ const agent = planOrchestrator.updateLookout(conversationId, personaId, updates);
1448
+ if (!agent) return jsonReply(res, 404, { error: 'not_found' });
1449
+ return jsonReply(res, 200, agent);
1450
+ }
1451
+
1452
+ if (planAgentMatch && req.method === 'DELETE') {
1453
+ if (!await requirePlanCredential(res)) return;
1454
+ const conversationId = decodeURIComponent(planAgentMatch[1]);
1455
+ const personaId = decodeURIComponent(planAgentMatch[2]);
1456
+ planOrchestrator.uninviteLookout(conversationId, personaId);
1457
+ return jsonReply(res, 204, null);
1458
+ }
1459
+
1460
+ const planSidebarSignalMatch = /^\/api\/plan\/conversations\/([^/?#]+)\/sidebar\/([^/?#]+)\/(dismiss|use)$/.exec(url.pathname);
1461
+ if (planSidebarSignalMatch && req.method === 'POST') {
1462
+ if (!await requirePlanCredential(res)) return;
1463
+ const conversationId = decodeURIComponent(planSidebarSignalMatch[1]);
1464
+ const signalId = decodeURIComponent(planSidebarSignalMatch[2]);
1465
+ const action = planSidebarSignalMatch[3];
1466
+ const changed = action === 'dismiss'
1467
+ ? planOrchestrator.dismissSignal(conversationId, signalId)
1468
+ : planOrchestrator.useSignal(conversationId, signalId);
1469
+ if (!changed) return jsonReply(res, 404, { error: 'not_found' });
1470
+ return jsonReply(res, 200, { ok: true });
1471
+ }
1472
+
1473
+ // GET /api/worlds/:id/processes — JSON snapshot of in-container processes.
1474
+ // GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world).
1475
+ // Inserted after auth middleware and the /api/worlds/:id/services route.
1476
+ const processesMatch = /^\/api\/worlds\/([^/?#]+)\/processes(\/stream)?\/?$/.exec(url.pathname);
1477
+ if (processesMatch && req.method === 'GET') {
1478
+ const worldId = decodeURIComponent(processesMatch[1]);
1479
+ if (!(worldId in WORLDS)) {
1480
+ return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
1481
+ }
1482
+ const isStream = processesMatch[2] === '/stream';
1483
+ if (isStream) {
1484
+ subscribeToProcesses(worldId, res);
1485
+ return;
1486
+ }
1487
+ const snapshot = await getProcessSnapshot(worldId);
1488
+ return jsonReply(res, 200, snapshot);
1489
+ }
1490
+
1491
+ // GET /api/prs — list recent GitHub PRs for the Cmd+K palette.
1492
+ // Wraps `gh pr list` with a 60s in-process TTL cache so repeated
1493
+ // palette opens don't hammer the GitHub API.
1494
+ if (url.pathname === '/api/prs' && req.method === 'GET') {
1495
+ const now = Date.now();
1496
+ if (prListCacheEntry && now - prListCacheEntry.fetchedAt < 60_000) {
1497
+ return jsonReply(res, 200, prListCacheEntry.data);
1498
+ }
1499
+ try {
1500
+ const { stdout } = await execFileAsync('gh', [
1501
+ 'pr', 'list',
1502
+ '--state', 'all',
1503
+ '--limit', '50',
1504
+ '--json', 'number,title,state,headRefName',
1505
+ ], { timeout: 10_000 });
1506
+ const data = JSON.parse(stdout.trim() || '[]');
1507
+ prListCacheEntry = { data, fetchedAt: Date.now() };
1508
+ return jsonReply(res, 200, data);
1509
+ } catch (err) {
1510
+ console.error('[api/prs] gh pr list failed:', err.message);
1511
+ // Return stale cache if available rather than a hard error.
1512
+ if (prListCacheEntry) return jsonReply(res, 200, prListCacheEntry.data);
1513
+ return jsonReply(res, 500, { error: 'gh_failed', message: err.message });
1514
+ }
1515
+ }
1516
+
1517
+ // Anything else → 404. B4 ships static SPA serving + auth.
1518
+ jsonReply(res, 404, {
1519
+ error: 'not_found',
1520
+ pathname: url.pathname,
1521
+ message: 'B3 ships /health + /api/world/<id>/*. B4-B9 ship the rest.',
1522
+ });
1523
+ });
1524
+
1525
+ /**
1526
+ * @param {import('node:http').ServerResponse} res
1527
+ * @param {number} status
1528
+ * @param {unknown} body
1529
+ */
1530
+ function jsonReply(res, status, body) {
1531
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
1532
+ res.end(JSON.stringify(body));
1533
+ }
1534
+
1535
+ /**
1536
+ * Read the full request body as a parsed JSON object. Rejects on body > 64KB
1537
+ * or invalid JSON. Used by auth proxy endpoints.
1538
+ *
1539
+ * @param {import('node:http').IncomingMessage} req
1540
+ * @returns {Promise<unknown>}
1541
+ */
1542
+ function readRequestBody(req) {
1543
+ return new Promise((resolve, reject) => {
1544
+ let raw = '';
1545
+ let size = 0;
1546
+ req.setEncoding('utf-8');
1547
+ req.on('data', (chunk) => {
1548
+ size += chunk.length;
1549
+ if (size > 65536) { reject(new Error('body too large')); req.destroy(); return; }
1550
+ raw += chunk;
1551
+ });
1552
+ req.on('end', () => {
1553
+ try { resolve(JSON.parse(raw || '{}')); }
1554
+ catch (err) { reject(err); }
1555
+ });
1556
+ req.on('error', reject);
1557
+ });
1558
+ }
1559
+
1560
+ /**
1561
+ * Throttle for the auth-secret-misconfigured hint. We log it ONCE per
1562
+ * process boot — repeated 401s on every poll would spam the operator's
1563
+ * docker-compose-logs view. The flag flips back to false only on
1564
+ * restart, which is fine because the env-var → restart cycle is the
1565
+ * only way the secret changes.
1566
+ */
1567
+ let _authSecretWarned = false;
1568
+
1569
+ /**
1570
+ * Proxy a request to the auth service. Adds X-Olam-Secret header when
1571
+ * AUTH_SERVICE_SECRET is set. Returns the raw fetch Response so callers can
1572
+ * inspect status + body.
1573
+ *
1574
+ * On 401 we log a single, throttled hint that tells the operator
1575
+ * exactly which env var to fix. Pre-fix, an empty OLAM_AUTH_SECRET
1576
+ * silently produced "0 credentials" in the SPA — the only signal was
1577
+ * the operator noticing the missing list.
1578
+ *
1579
+ * @param {'GET' | 'POST' | 'DELETE'} method
1580
+ * @param {string} path e.g. '/credentials/status'
1581
+ * @param {unknown} [body] JSON-serializable body (POST only)
1582
+ * @returns {Promise<Response>}
1583
+ */
1584
+ async function authServiceFetch(method, path, body) {
1585
+ /** @type {HeadersInit} */
1586
+ const headers = { 'Content-Type': 'application/json' };
1587
+ if (AUTH_SERVICE_SECRET) headers['X-Olam-Secret'] = AUTH_SERVICE_SECRET;
1588
+ const res = await fetch(`${AUTH_SERVICE_URL}${path}`, {
1589
+ method,
1590
+ headers,
1591
+ body: body !== undefined ? JSON.stringify(body) : undefined,
1592
+ });
1593
+ if (res.status === 401 && !_authSecretWarned) {
1594
+ _authSecretWarned = true;
1595
+ console.warn(authSecretHint({
1596
+ authServiceUrl: AUTH_SERVICE_URL,
1597
+ hasSecret: AUTH_SERVICE_SECRET.length > 0,
1598
+ }));
1599
+ }
1600
+ return res;
1601
+ }
1602
+
1603
+ // ── Multi-credential helpers ────────────────────────────────────────
1604
+
1605
+ /**
1606
+ * Reshape an auth-service status entry into the dashboard's flat fleet
1607
+ * record. Forward-only: extra upstream fields are dropped so the SPA
1608
+ * doesn't need to know about server-side migration columns.
1609
+ *
1610
+ * @param {Record<string, unknown>} a
1611
+ */
1612
+ function normalizeCredential(a) {
1613
+ /** @type {Record<string, unknown>} */
1614
+ const usage = (a && typeof a.usage === 'object' && a.usage !== null) ? a.usage : {};
1615
+ return {
1616
+ id: a?.id ?? '',
1617
+ label: a?.accountLabel ?? a?.id ?? '',
1618
+ email: a?.email ?? null,
1619
+ provider: a?.provider ?? 'claude',
1620
+ plan: a?.plan ?? null,
1621
+ state: a?.state ?? (a?.tokenValid === false ? 'expired' : 'active'),
1622
+ tokenValid: a?.tokenValid !== false,
1623
+ rateLimitResetsAt: a?.rateLimitResetsAt ?? null,
1624
+ lastUsed: a?.lastUsed ?? null,
1625
+ addedAt: a?.addedAt ?? null,
1626
+ usage: {
1627
+ requestCount5h: Number(usage.requestCount5h ?? 0),
1628
+ windowStartedAt: usage.windowStartedAt ?? null,
1629
+ last429At: usage.last429At ?? null,
1630
+ cumulativeTokens: Number(usage.cumulativeTokens ?? 0),
1631
+ },
1632
+ };
1633
+ }
1634
+
1635
+ /**
1636
+ * SSE stream of multi-credential fleet events. The host-cp polls the
1637
+ * auth-service every 4s and diffs the previous credential states; any
1638
+ * `active → cooldown` transition emits a hotswap event so the dashboard
1639
+ * can toast it. Active → expired and cooldown → active are surfaced too
1640
+ * (the latter for "credential recovered" hints).
1641
+ *
1642
+ * Event shapes (one JSON object per `data:` line):
1643
+ * {type:"hello", credentials:[…]} — initial snapshot
1644
+ * {type:"hotswap", from_label, reason:"rate-limited",
1645
+ * to_label?, resetsAt?}
1646
+ * {type:"recovered", label}
1647
+ * {type:"snapshot", credentials:[…]} — periodic refresh hint
1648
+ *
1649
+ * SSE clients reconnect on close; we don't store-and-forward, so a
1650
+ * brief network blip may drop a transition. The fleet hook treats events
1651
+ * as best-effort — the polling cadence still surfaces the new state.
1652
+ *
1653
+ * @param {import('node:http').IncomingMessage} req
1654
+ * @param {import('node:http').ServerResponse} res
1655
+ */
1656
+ function handleAuthEvents(req, res) {
1657
+ res.writeHead(200, {
1658
+ 'Content-Type': 'text/event-stream',
1659
+ 'Cache-Control': 'no-cache',
1660
+ 'Connection': 'keep-alive',
1661
+ 'X-Accel-Buffering': 'no',
1662
+ });
1663
+
1664
+ /** @type {Map<string, {state: string, label: string}>} */
1665
+ let prev = new Map();
1666
+ let closed = false;
1667
+
1668
+ function send(obj) {
1669
+ if (closed) return;
1670
+ try {
1671
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
1672
+ } catch {
1673
+ closed = true;
1674
+ }
1675
+ }
1676
+
1677
+ async function snapshot() {
1678
+ if (closed) return;
1679
+ try {
1680
+ const upstream = await authServiceFetch('GET', '/credentials/status');
1681
+ const raw = await upstream.json();
1682
+ const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
1683
+ const credentials = accounts.map(normalizeCredential);
1684
+ const next = new Map();
1685
+ for (const c of credentials) next.set(c.id, { state: c.state, label: c.label });
1686
+
1687
+ // First snapshot: send a hello, no diff.
1688
+ if (prev.size === 0 && next.size > 0) {
1689
+ send({ type: 'hello', credentials });
1690
+ } else {
1691
+ // Diff: detect active→cooldown (rate-limit) and cooldown→active
1692
+ // (recovery). Pick the next active credential as the to_label
1693
+ // hint — selectors elsewhere in the system use lowest-usage, but
1694
+ // the dashboard hint only needs *some* active alternative.
1695
+ const alive = credentials.filter((c) => c.state === 'active');
1696
+ for (const c of credentials) {
1697
+ const before = prev.get(c.id);
1698
+ if (!before) continue;
1699
+ if (before.state === 'active' && c.state === 'cooldown') {
1700
+ const alt = alive.find((a) => a.id !== c.id);
1701
+ send({
1702
+ type: 'hotswap',
1703
+ from_label: before.label,
1704
+ from_id: c.id,
1705
+ to_label: alt ? alt.label : null,
1706
+ to_id: alt ? alt.id : null,
1707
+ reason: 'rate-limited',
1708
+ resetsAt: c.rateLimitResetsAt,
1709
+ });
1710
+ } else if (before.state === 'active' && c.state === 'usage-capped') {
1711
+ const alt = alive.find((a) => a.id !== c.id);
1712
+ send({
1713
+ type: 'hotswap',
1714
+ from_label: before.label,
1715
+ from_id: c.id,
1716
+ to_label: alt ? alt.label : null,
1717
+ to_id: alt ? alt.id : null,
1718
+ reason: 'usage-capped',
1719
+ resetsAt: c.rateLimitResetsAt,
1720
+ });
1721
+ } else if ((before.state === 'cooldown' || before.state === 'usage-capped') && c.state === 'active') {
1722
+ send({ type: 'recovered', label: c.label, id: c.id });
1723
+ }
1724
+ }
1725
+ }
1726
+ prev = next;
1727
+ } catch (err) {
1728
+ // Don't tear down the stream on transient auth-service errors —
1729
+ // the next tick may succeed.
1730
+ send({ type: 'error', message: err?.message ?? 'auth_service_error' });
1731
+ }
1732
+ }
1733
+
1734
+ // Heartbeat keeps proxies from killing idle SSE connections.
1735
+ const heartbeat = setInterval(() => {
1736
+ if (closed) return;
1737
+ try { res.write(': heartbeat\n\n'); } catch { closed = true; }
1738
+ }, 25000);
1739
+
1740
+ // Poll cadence: 4s. Faster than the 5s SPA modal poll so the toast
1741
+ // beats the next visual refresh; slower than 1s so we don't hammer the
1742
+ // auth-service when many tabs are open.
1743
+ const ticker = setInterval(() => { void snapshot(); }, 4000);
1744
+ void snapshot();
1745
+
1746
+ req.on('close', () => {
1747
+ closed = true;
1748
+ clearInterval(heartbeat);
1749
+ clearInterval(ticker);
1750
+ });
1751
+ }
1752
+
1753
+ // ── Service enrichment (Phase F-2-D dogfood fix) ───────────────────
1754
+ //
1755
+ // Fetch port bindings for a world's container via docker-socket-proxy
1756
+ // inspect. Returns [{name, host_port, internal_port, url}] tagged with
1757
+ // well-known internal ports.
1758
+
1759
+ const WELL_KNOWN_PORTS = {
1760
+ 3000: 'atlas-core (Rails)',
1761
+ 5175: 'diner-app (Vite)',
1762
+ 7681: 'Terminal (ttyd)',
1763
+ 8080: 'Per-world CP',
1764
+ };
1765
+
1766
+ /**
1767
+ * Quick liveness probe against a service URL. Returns true if the
1768
+ * service responds with ANY HTTP response (1xx-5xx) — we don't care
1769
+ * about status codes because each app has its own conventions (Vite
1770
+ * 200s on /, ttyd may 401, Rails may 500 on /, the per-world CP 200s).
1771
+ * What matters is that something is listening.
1772
+ *
1773
+ * Probed from inside the host-cp container so we use HOST_FOR_WORLD
1774
+ * (host.docker.internal on macOS/Windows, 172.17.0.1 on Linux) — the
1775
+ * SPA's own 127.0.0.1:<port> URL is unreachable from container-side.
1776
+ *
1777
+ * Tight 800ms timeout. Worst case: 4 services × 800ms in parallel ≤ 1s
1778
+ * added to the /api/worlds response — acceptable for a 4s poll cycle.
1779
+ */
1780
+ async function probeServiceLive(hostPort) {
1781
+ const probeUrl = `http://${HOST_FOR_WORLD}:${hostPort}/`;
1782
+ try {
1783
+ const res = await fetch(probeUrl, {
1784
+ method: 'HEAD',
1785
+ signal: AbortSignal.timeout(800),
1786
+ redirect: 'manual',
1787
+ });
1788
+ return res.status > 0;
1789
+ } catch {
1790
+ // ECONNREFUSED, timeout, DNS — anything counts as not-live. Try
1791
+ // GET as a fallback because some servers (e.g. ttyd) close on HEAD
1792
+ // and we don't want false negatives from picky upstream behavior.
1793
+ try {
1794
+ const res2 = await fetch(probeUrl, {
1795
+ method: 'GET',
1796
+ signal: AbortSignal.timeout(800),
1797
+ redirect: 'manual',
1798
+ });
1799
+ return res2.status > 0;
1800
+ } catch {
1801
+ return false;
1802
+ }
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Get the running container's port bindings from socket-proxy + map
1808
+ * each to a clickable URL. Each service is then probed in parallel
1809
+ * for actual reachability — the docker port mapping just tells us
1810
+ * what's CONFIGURED; the probe confirms what's actually LISTENING.
1811
+ *
1812
+ * Returns [] on any docker-inspect failure (container missing, socket-
1813
+ * proxy down) so the API still returns a valid worlds list.
1814
+ *
1815
+ * @param {string} worldId
1816
+ * @returns {Promise<Array<{name: string, host_port: number, internal_port: number, url: string, live: boolean}>>}
1817
+ */
1818
+ async function fetchWorldServices(worldId) {
1819
+ const containerName = `olam-${worldId}-devbox`;
1820
+ let data;
1821
+ try {
1822
+ if (DOCKER_HOST === 'docker-cli') {
1823
+ // Bare-node mode: shell out to `docker inspect` instead of HTTP.
1824
+ // Same fix pattern as fetchContainerSecret (PR #108). Without
1825
+ // this, the services array is always empty in bare-node and the
1826
+ // SPA can't find the ttyd host port → terminal renders blank.
1827
+ const { spawnSync } = await import('node:child_process');
1828
+ const result = spawnSync(
1829
+ 'docker',
1830
+ ['inspect', containerName],
1831
+ { encoding: 'utf-8', timeout: 2000 },
1832
+ );
1833
+ if (result.status !== 0) return [];
1834
+ const arr = JSON.parse(result.stdout || '[]');
1835
+ data = Array.isArray(arr) && arr.length > 0 ? arr[0] : null;
1836
+ if (!data) return [];
1837
+ } else {
1838
+ const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
1839
+ const res = await fetch(`${apiBase}/containers/${encodeURIComponent(containerName)}/json`, {
1840
+ signal: AbortSignal.timeout(2000),
1841
+ });
1842
+ if (!res.ok) return [];
1843
+ data = await res.json();
1844
+ }
1845
+ const ports = data?.NetworkSettings?.Ports ?? {};
1846
+ const draft = [];
1847
+ for (const [internal, bindings] of Object.entries(ports)) {
1848
+ if (!Array.isArray(bindings) || bindings.length === 0) continue;
1849
+ const internalPort = parseInt(internal.split('/')[0], 10);
1850
+ const hostPort = parseInt(bindings[0].HostPort, 10);
1851
+ if (!Number.isFinite(internalPort) || !Number.isFinite(hostPort)) continue;
1852
+ draft.push({
1853
+ name: WELL_KNOWN_PORTS[internalPort] ?? `App (port ${internalPort})`,
1854
+ host_port: hostPort,
1855
+ internal_port: internalPort,
1856
+ url: `http://127.0.0.1:${hostPort}`,
1857
+ });
1858
+ }
1859
+
1860
+ // Probe each service in parallel for actual reachability. Adds a
1861
+ // `live: boolean` field. The UI dims chips for non-live services
1862
+ // so operators can see what's configured-but-down vs configured-
1863
+ // and-up at a glance.
1864
+ const liveResults = await Promise.all(
1865
+ draft.map((s) => probeServiceLive(s.host_port)),
1866
+ );
1867
+ const services = draft.map((s, i) => ({ ...s, live: liveResults[i] }));
1868
+
1869
+ // Stable order: well-known ports first (CP, then Rails/Vite, then terminal).
1870
+ services.sort((a, b) => a.internal_port - b.internal_port);
1871
+ return services;
1872
+ } catch {
1873
+ return [];
1874
+ }
1875
+ }
1876
+
1877
+ // ── Static file serving (Phase F-2-D dogfood fix) ──────────────────
1878
+ //
1879
+ // SPA dist/ is at /app/dist/ inside the container (see Dockerfile).
1880
+ // In bare-node mode, the SPA build lives in packages/control-plane/public
1881
+ // (where the workspace's `npm run build` writes it). The legacy
1882
+ // packages/host-cp/dist used to be hand-tarballed but can drift out of
1883
+ // sync with the index.html→bundle hash mapping; prefer public/ when it
1884
+ // exists so a stale dist doesn't 404 on /assets/<hash>.js.
1885
+
1886
+ const DIST_DIR = (() => {
1887
+ const candidates = [
1888
+ '/app/dist',
1889
+ path.resolve(process.cwd(), 'packages/control-plane/public'),
1890
+ path.resolve(process.cwd(), '../control-plane/public'),
1891
+ path.resolve(process.cwd(), 'dist'),
1892
+ path.resolve(process.cwd(), 'packages/host-cp/dist'),
1893
+ ];
1894
+ for (const c of candidates) {
1895
+ if (fs.existsSync(c) && fs.existsSync(path.join(c, 'index.html'))) return c;
1896
+ }
1897
+ return '/app/dist'; // fallback; readFile will surface ENOENT
1898
+ })();
1899
+
1900
+ const SPA_ROUTES = new Set(['/', '/worlds', '/workspaces', '/inbox']);
1901
+ const SPA_PREFIX = ['/world/', '/worlds/', '/workspaces/', '/inbox/'];
1902
+
1903
+ // Top-level path segments that are NOT world IDs. Mirrors RESERVED_SEGMENTS
1904
+ // in lib/worldId.ts — keep in sync.
1905
+ const RESERVED_TOP_LEVEL = new Set([
1906
+ 'sandbox', 'world', 'inbox', 'worlds', 'workspaces',
1907
+ 'api', 'assets', 'health', 'favicon.ico',
1908
+ 'session', 'hooks', 'dispatch', 'lanes', 'codex', 'review',
1909
+ ]);
1910
+
1911
+ /**
1912
+ * True when `pathname` is a bare world-ID SPA route:
1913
+ * /<id>
1914
+ * /<id>/<tab>
1915
+ * /<id>/sessions/<name>[/<tab>]
1916
+ * where <id> is not a reserved segment.
1917
+ */
1918
+ function isBareWorldSpaPath(pathname) {
1919
+ const m = /^\/([^/?#/]+)(\/.*)?$/.exec(pathname);
1920
+ if (!m) return false;
1921
+ const seg = m[1];
1922
+ if (RESERVED_TOP_LEVEL.has(seg)) return false;
1923
+ // World IDs are lowercase kebab-case.
1924
+ return /^[a-z][a-z0-9-]*$/.test(seg);
1925
+ }
1926
+
1927
+ const MIME_TYPES = {
1928
+ '.html': 'text/html; charset=utf-8',
1929
+ '.js': 'application/javascript; charset=utf-8',
1930
+ '.mjs': 'application/javascript; charset=utf-8',
1931
+ '.css': 'text/css; charset=utf-8',
1932
+ '.json': 'application/json; charset=utf-8',
1933
+ '.svg': 'image/svg+xml',
1934
+ '.png': 'image/png',
1935
+ '.jpg': 'image/jpeg',
1936
+ '.ico': 'image/x-icon',
1937
+ '.woff': 'font/woff',
1938
+ '.woff2': 'font/woff2',
1939
+ };
1940
+
1941
+ /**
1942
+ * Try to serve a static file from dist/. Returns true if served (caller
1943
+ * must `return`); false if no file matched (caller falls through to
1944
+ * auth + 404).
1945
+ *
1946
+ * SPA routing: any path matching SPA_ROUTES or SPA_PREFIX returns
1947
+ * dist/index.html so React Router-style /world/<id> works.
1948
+ *
1949
+ * @param {import('node:http').IncomingMessage} req
1950
+ * @param {import('node:http').ServerResponse} res
1951
+ * @param {string} pathname
1952
+ * @returns {Promise<boolean>}
1953
+ */
1954
+ async function tryServeStatic(req, res, pathname) {
1955
+ // Never serve static for /api/* — those are JSON routes.
1956
+ if (pathname.startsWith('/api/') || pathname === '/health') return false;
1957
+
1958
+ // Devtools speculatively fetch /assets/<bundle>.js.map even when the
1959
+ // bundle has no `//# sourceMappingURL` comment. We don't ship maps in
1960
+ // production, so return 204 (no content) instead of falling through
1961
+ // to the auth gate's 401 — that 401 surfaces as "Source Map loading
1962
+ // errors" in the browser console and clutters debugging.
1963
+ if (pathname.startsWith('/assets/') && pathname.endsWith('.map')) {
1964
+ res.writeHead(204, { 'Cache-Control': 'public, max-age=31536000, immutable' });
1965
+ res.end();
1966
+ return true;
1967
+ }
1968
+
1969
+ // SPA shell paths → dist/index.html.
1970
+ // Inbox routing: in addition to known SPA_ROUTES/SPA_PREFIX, also treat
1971
+ // root-level /<worldId>, /<worldId>/<tab>, /<worldId>/details, and
1972
+ // /<worldId>/sessions/<name>[/<tab>] as SPA shells.
1973
+ const isSpaShell =
1974
+ SPA_ROUTES.has(pathname) ||
1975
+ SPA_PREFIX.some((p) => pathname === p.slice(0, -1) || pathname.startsWith(p)) ||
1976
+ isBareWorldSpaPath(pathname);
1977
+
1978
+ let filePath;
1979
+ if (isSpaShell) {
1980
+ filePath = path.join(DIST_DIR, 'index.html');
1981
+ } else {
1982
+ // Direct asset request (/assets/*.js, /favicon.ico, etc.)
1983
+ // path.join normalises; we then re-check the result is still under DIST_DIR
1984
+ // to defend against path traversal (../../etc/passwd).
1985
+ const requested = path.join(DIST_DIR, pathname);
1986
+ if (!requested.startsWith(DIST_DIR + path.sep) && requested !== DIST_DIR) {
1987
+ return false;
1988
+ }
1989
+ filePath = requested;
1990
+ }
1991
+
1992
+ if (!fs.existsSync(filePath)) {
1993
+ // Asset 404 falls through to the auth + JSON-404 path; for SPA-shell
1994
+ // paths a missing index.html means the build hasn't been staged.
1995
+ return false;
1996
+ }
1997
+
1998
+ const ext = path.extname(filePath);
1999
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
2000
+
2001
+ // SPA shell (index.html) gets the bootstrap script injected so the
2002
+ // browser sets the auth cookie BEFORE React mounts + makes API calls.
2003
+ // Without this the SPA loads but every fetch 401s and the operator
2004
+ // sees "Could not load worlds — HTTP 401".
2005
+ if (isSpaShell) {
2006
+ const html = await renderSpaShell(filePath);
2007
+ res.writeHead(200, {
2008
+ 'Content-Type': 'text/html; charset=utf-8',
2009
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
2010
+ });
2011
+ if (req.method === 'HEAD') {
2012
+ res.end();
2013
+ return true;
2014
+ }
2015
+ res.end(html);
2016
+ return true;
2017
+ }
2018
+
2019
+ // Direct asset serve.
2020
+ const stat = fs.statSync(filePath);
2021
+ res.writeHead(200, {
2022
+ 'Content-Type': contentType,
2023
+ 'Content-Length': String(stat.size),
2024
+ 'Cache-Control': pathname.startsWith('/assets/')
2025
+ ? 'public, max-age=31536000, immutable'
2026
+ : 'no-cache, no-store, must-revalidate',
2027
+ });
2028
+ if (req.method === 'HEAD') {
2029
+ res.end();
2030
+ return true;
2031
+ }
2032
+ fs.createReadStream(filePath).pipe(res);
2033
+ return true;
2034
+ }
2035
+
2036
+ // Memoized injected SPA shell. Read once at first request; serve from
2037
+ // memory thereafter. Cache invalidates on dist/ mtime change so a
2038
+ // rebuilt bundle is picked up without restart.
2039
+ let _spaCache = null;
2040
+ let _spaCacheMtime = 0;
2041
+
2042
+ /**
2043
+ * Bootstrap script injected into the SPA shell. Two responsibilities:
2044
+ *
2045
+ * 1. Synchronously fetch /api/bootstrap, set the cookie. Runs BEFORE
2046
+ * the SPA bundle so React's first render has a valid cookie.
2047
+ *
2048
+ * 2. Monkey-patch fetch + EventSource. When the SPA is at /world/<id>,
2049
+ * world-scoped API calls (`/api/world`, `/api/stream`, `/session/*`,
2050
+ * `/hooks/*`, etc.) get rewritten to `/api/world/<id>/...` so
2051
+ * host-cp's proxy routes them to the right per-world CP. Without
2052
+ * this, every hook in the existing SPA bundle 404s because the
2053
+ * bundle was built for the per-world CP context where `/api/world`
2054
+ * WAS the per-world endpoint.
2055
+ *
2056
+ * Sync XHR is deprecated but reliable for one-time bootstrap before any
2057
+ * other script runs. Modern browsers warn but allow it.
2058
+ *
2059
+ * The patch reads `location.pathname` on EVERY fetch call so it tracks
2060
+ * client-side SPA navigation — though world-card clicks today use full
2061
+ * page reloads (window.location.assign), this is defense-in-depth for
2062
+ * any future React Router migration.
2063
+ */
2064
+ // Bootstrap shim injected into the SPA shell. Two responsibilities:
2065
+ //
2066
+ // 1. Set `olam_host_cp_token` cookie from /api/bootstrap (synchronous XHR
2067
+ // so it lands before React mounts and starts firing requests).
2068
+ //
2069
+ // 2. Monkey-patch `fetch` + `EventSource` so paths the SPA was authored
2070
+ // against the per-world CP for (the SPA bundle is shared with CF
2071
+ // Worker mode) get rewritten under host-cp to `/api/world/<id>/...`
2072
+ // when the page is on `/world/<id>`. host-cp's proxy strips the
2073
+ // prefix and forwards to the per-world CP at the right port.
2074
+ //
2075
+ // Path classification:
2076
+ // - HOST_NATIVE: paths host-cp owns. NEVER rewrite.
2077
+ // - WORLD_PREFIXES: per-world CP paths. Rewrite when on /world/<id>.
2078
+ //
2079
+ // WORLD_PREFIXES intentionally lists every per-world top-level surface
2080
+ // the SPA touches today: /api/* (status, world, thoughts, dispatch...),
2081
+ // /session/* (resume, destroy, completion), /hooks/* (PostToolUse +
2082
+ // thoughts feed), /dispatch (POST prompt → tmux claude-main), /lanes*
2083
+ // (parallel-work coordination, CF-only feature), /codex/* (codex sidecar
2084
+ // auth + status), /review/* (codex review history). Missing entries
2085
+ // would 404 through host-cp returning the SPA shell — observed during
2086
+ // M5 dogfood: clicking Dispatch on /world/<id> hit /dispatch directly,
2087
+ // host-cp had no handler, and the SPA got HTML back instead of JSON.
2088
+ const BOOTSTRAP_SCRIPT = `<script>(function(){try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);document.cookie='olam_host_cp_token='+d.token+'; path=/; samesite=strict';}}catch(e){console.error('[host-cp bootstrap]',e);}var HN=['/api/bootstrap','/api/worlds','/api/projects','/api/workspaces','/api/workspaces/match','/api/auth','/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)\\/([^/?#]+)/);if(m)return m[2];if(/^\\/(?:worlds?|workspaces?|world|sandbox|inbox|plan|design|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){if(typeof input==='string'&&sr(input))return of(rw(input),init);if(input&&typeof input.url==='string'&&sr(input.url))return of(new Request(rw(input.url),input),init);return of(input,init);};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);return new OE(s,i);};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
2089
+
2090
+ async function renderSpaShell(filePath) {
2091
+ const stat = fs.statSync(filePath);
2092
+ if (_spaCache !== null && stat.mtimeMs === _spaCacheMtime) {
2093
+ return _spaCache;
2094
+ }
2095
+ let html = fs.readFileSync(filePath, 'utf-8');
2096
+ // Vite emits relative asset paths (`./assets/...`) so the SPA bundle
2097
+ // is portable across deploy paths. But under host-cp's path-segment
2098
+ // routing, /world/<id> would resolve `./assets/` to `/world/assets/`
2099
+ // which 404s. Rewrite to absolute `/assets/` so all SPA shell paths
2100
+ // (/, /worlds, /workspaces, /world/<id>) reference the same bundle.
2101
+ html = html.replace(/(href|src)="\.\/assets\//g, '$1="/assets/');
2102
+ // Inject right after <head> so the bootstrap runs before any other
2103
+ // script tag on the page.
2104
+ html = html.replace(/<head>/i, `<head>\n ${BOOTSTRAP_SCRIPT}`);
2105
+ _spaCache = html;
2106
+ _spaCacheMtime = stat.mtimeMs;
2107
+ return html;
2108
+ }
2109
+
2110
+ // WebSocket upgrade handler for ttyd's terminal stream.
2111
+ // ttyd opens ws://<host>/ws after loading; host-cp must forward the
2112
+ // upgrade to the world's ttyd port (17681 + offset). Path:
2113
+ // /api/world/<id>/ttyd/ws.
2114
+ server.on('upgrade', (req, clientSocket, head) => {
2115
+ try {
2116
+ const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
2117
+ const parsed = parseProxyPath(url.pathname);
2118
+ if (!parsed) { clientSocket.destroy(); return; }
2119
+ const { worldId, subPath } = parsed;
2120
+ const port = WORLDS[worldId];
2121
+ if (port === undefined) { clientSocket.destroy(); return; }
2122
+ if (!subPath.startsWith('/ttyd/') && subPath !== '/ttyd') {
2123
+ clientSocket.destroy();
2124
+ return;
2125
+ }
2126
+ const portOffset = port - 19080;
2127
+ const ttydPort = 17681 + portOffset;
2128
+ const ttydSubPath = subPath === '/ttyd' ? '/' : subPath.slice('/ttyd'.length);
2129
+ const ttydPath = ttydSubPath + (url.search || '');
2130
+ /** @type {Record<string, string | string[]>} */
2131
+ const outHeaders = {};
2132
+ for (const [k, v] of Object.entries(req.headers)) {
2133
+ if (v === undefined) continue;
2134
+ const lower = k.toLowerCase();
2135
+ if (lower === 'host' || lower === 'x-olam-secret') continue;
2136
+ outHeaders[k] = v;
2137
+ }
2138
+ outHeaders['host'] = `${HOST_FOR_WORLD}:${ttydPort}`;
2139
+ const upstreamReq = http.request({
2140
+ hostname: HOST_FOR_WORLD,
2141
+ port: ttydPort,
2142
+ path: ttydPath,
2143
+ method: req.method ?? 'GET',
2144
+ headers: outHeaders,
2145
+ });
2146
+ upstreamReq.on('upgrade', (upstreamRes, upstreamSocket, upstreamHead) => {
2147
+ const headerLines = [
2148
+ `HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage ?? 'Switching Protocols'}`,
2149
+ ];
2150
+ for (const [k, v] of Object.entries(upstreamRes.headers)) {
2151
+ if (Array.isArray(v)) {
2152
+ for (const item of v) headerLines.push(`${k}: ${item}`);
2153
+ } else if (v !== undefined) {
2154
+ headerLines.push(`${k}: ${v}`);
2155
+ }
2156
+ }
2157
+ headerLines.push('', '');
2158
+ clientSocket.write(headerLines.join('\r\n'));
2159
+ if (upstreamHead && upstreamHead.length > 0) clientSocket.write(upstreamHead);
2160
+ upstreamSocket.pipe(clientSocket);
2161
+ clientSocket.pipe(upstreamSocket);
2162
+ upstreamSocket.on('error', () => clientSocket.destroy());
2163
+ clientSocket.on('error', () => upstreamSocket.destroy());
2164
+ });
2165
+ upstreamReq.on('error', (err) => {
2166
+ console.error(`[upgrade] upstream error for ttyd ${ttydPort}: ${err.message}`);
2167
+ clientSocket.destroy();
2168
+ });
2169
+ if (head && head.length > 0) upstreamReq.write(head);
2170
+ upstreamReq.end();
2171
+ } catch (err) {
2172
+ console.error(`[upgrade] handler error: ${err.message}`);
2173
+ clientSocket.destroy();
2174
+ }
2175
+ });
2176
+
2177
+ // Probe persisted tunnels on startup; mark unreachable ones stale.
2178
+ tunnelManager.probeAllOnStartup().catch((err) => {
2179
+ console.error(`tunnel startup probe failed: ${err.message}`);
2180
+ });
2181
+
2182
+ server.listen(PORT, '0.0.0.0', () => {
2183
+ console.log(`olam-host-cp B3 listening on :${PORT}`);
2184
+ console.log(` DOCKER_HOST=${DOCKER_HOST}`);
2185
+ console.log(` cache TTL=${TTL_SEC}s`);
2186
+ console.log(` worlds known: ${Object.keys(WORLDS).join(', ') || '(none)'}`);
2187
+ console.log(` mode=${HOST_CP_MODE} world-host=${HOST_FOR_WORLD}`);
2188
+ console.log(` (override: OLAM_HOST_CP_MODE=container|bare, OLAM_HOST_FOR_WORLD=<host>)`);
2189
+ // Surface the auth wiring state at boot. An empty OLAM_AUTH_SECRET
2190
+ // here is silently fatal for the credential surfaces — the operator
2191
+ // would otherwise only see "0 credentials" in the SPA with no hint
2192
+ // why. Logging it once at boot makes the misconfiguration obvious
2193
+ // in docker-compose-logs without waiting for the first 401.
2194
+ if (AUTH_SERVICE_URL && AUTH_SERVICE_SECRET.length === 0) {
2195
+ console.warn(authSecretHint({
2196
+ authServiceUrl: AUTH_SERVICE_URL,
2197
+ hasSecret: false,
2198
+ }));
2199
+ } else if (AUTH_SERVICE_URL) {
2200
+ console.log(` auth-service=${AUTH_SERVICE_URL} (X-Olam-Secret configured)`);
2201
+ }
2202
+ });
2203
+
2204
+ // Graceful shutdown so docker compose down → SIGTERM → flush + close.
2205
+ for (const sig of ['SIGTERM', 'SIGINT']) {
2206
+ process.on(sig, () => {
2207
+ console.log(`received ${sig}, shutting down`);
2208
+ stopEvents();
2209
+ prPoller.stop();
2210
+ worldsDbReconciler.stop();
2211
+ clearInterval(versionPollTimer);
2212
+ cache.clear();
2213
+ server.close(() => process.exit(0));
2214
+ });
2215
+ }