@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,250 @@
1
+ /**
2
+ * process-poller.mjs — per-world docker top SSE fanout.
3
+ *
4
+ * Dual-mode: HTTP API when DOCKER_HOST != 'docker-cli'; spawnSync otherwise.
5
+ *
6
+ * NOTE: process argv may contain secrets (--api-key=, --token=). Post-v1 audit needed. (S1)
7
+ */
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
12
+
13
+ /**
14
+ * @typedef {{ pid: string, user: string, cpu: string, mem: string, started: string, state: string, command: string }} ProcessRow
15
+ */
16
+
17
+ function worldContainerName(worldId) {
18
+ return `olam-${worldId}-devbox`;
19
+ }
20
+
21
+ /**
22
+ * Parse docker top JSON (Titles + Processes arrays) into normalized rows.
23
+ * Falls back gracefully if the response is not JSON.
24
+ * lstart is stored as a raw string — no Date parse (T1).
25
+ *
26
+ * @param {string} stdout
27
+ * @returns {ProcessRow[]}
28
+ */
29
+ function parseDockerTop(stdout) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(stdout);
33
+ } catch {
34
+ return [];
35
+ }
36
+
37
+ const titles = parsed?.Titles;
38
+ const processes = parsed?.Processes;
39
+ if (!Array.isArray(titles) || !Array.isArray(processes)) return [];
40
+
41
+ // Find column indices by title (case-insensitive partial match).
42
+ function idx(name) {
43
+ const n = name.toLowerCase();
44
+ const i = titles.findIndex((t) => typeof t === 'string' && t.toLowerCase().includes(n));
45
+ return i;
46
+ }
47
+
48
+ const pidIdx = idx('pid');
49
+ const userIdx = idx('user');
50
+ const cpuIdx = idx('cpu');
51
+ const memIdx = idx('mem');
52
+ // Accept LSTART, STARTED, STIME, or START_TIME (T1: store as raw string)
53
+ const startIdx = (() => {
54
+ for (const candidate of ['lstart', 'stime', 'start_time', 'start']) {
55
+ const i = idx(candidate);
56
+ if (i !== -1) return i;
57
+ }
58
+ return -1;
59
+ })();
60
+ const stateIdx = idx('stat');
61
+ const cmdIdx = (() => {
62
+ // CMD may be titled "CMD", "COMMAND", or "cmd"
63
+ const i = idx('command');
64
+ return i !== -1 ? i : idx('cmd');
65
+ })();
66
+
67
+ return processes.map((row) => ({
68
+ pid: pidIdx !== -1 ? String(row[pidIdx] ?? '').trim() : '',
69
+ user: userIdx !== -1 ? String(row[userIdx] ?? '').trim() : '',
70
+ cpu: cpuIdx !== -1 ? String(row[cpuIdx] ?? '').trim() : '0',
71
+ mem: memIdx !== -1 ? String(row[memIdx] ?? '').trim() : '0',
72
+ started: startIdx !== -1 ? String(row[startIdx] ?? '').trim() : '',
73
+ state: stateIdx !== -1 ? String(row[stateIdx] ?? '').trim() : '',
74
+ command: cmdIdx !== -1 ? String(row[cmdIdx] ?? '').trim() : '',
75
+ }));
76
+ }
77
+
78
+ /**
79
+ * Fetch processes for a world container.
80
+ * Returns {ts, processes, error?}.
81
+ * Non-running containers return an empty array + error field (T3).
82
+ *
83
+ * @param {string} worldId
84
+ * @returns {Promise<{ts: number, processes: ProcessRow[], error?: string}>}
85
+ */
86
+ async function fetchProcesses(worldId) {
87
+ const containerName = worldContainerName(worldId);
88
+ // Docker's /containers/<name>/top?ps_args=<X> passes ps_args verbatim to
89
+ // ps(1) inside the container. The pre-2026-05-05 form `pid,user,...` was
90
+ // a bare comma-separated list that ps treats as a process-ID *list*, not
91
+ // a column selector — yielding 500 "ps: error: process ID list syntax
92
+ // error" from the Docker API and a misleading "container not running"
93
+ // chip in the SPA. Correct invocation is `ps -eo <cols>` to select all
94
+ // processes (`-e`) and project specific columns (`-o`). Confirmed via
95
+ // host-cp container against olam-dawn-arc-5703-devbox: this form returns
96
+ // 200 with both Titles + Processes arrays, which parseDockerTop expects.
97
+ //
98
+ // Switched lstart → stime to match the CLI path's column choice (line 98)
99
+ // and avoid multi-word timestamp values; the CLI path's split-on-1+ws
100
+ // parser would break on "Mon May 4 14:00:00 2026", and consistency between
101
+ // paths reduces surprise. parseDockerTop accepts either via title match.
102
+ const ps_args = '-eo pid,user,pcpu,pmem,stime,stat,cmd';
103
+
104
+ let stdout;
105
+ try {
106
+ if (DOCKER_HOST === 'docker-cli') {
107
+ // Bare-node mode: spawnSync blocks ~50ms at 5s cadence (P2 — acceptable).
108
+ // Use `stime` (single-word start time) instead of `lstart` to avoid
109
+ // multi-word timestamp values that break column-split parsing.
110
+ const result = spawnSync(
111
+ 'docker',
112
+ ['top', containerName, 'pid', 'user', 'pcpu', 'pmem', 'stime', 'stat', 'cmd'],
113
+ { encoding: 'utf-8', timeout: 3000 },
114
+ );
115
+ if (result.status !== 0 || result.error) {
116
+ return { ts: Date.now(), processes: [], error: 'container not running' };
117
+ }
118
+ // docker top bare CLI outputs tabular text, not JSON. Wrap it for parseDockerTop.
119
+ stdout = result.stdout ?? '';
120
+ const lines = stdout.trim().split('\n');
121
+ if (lines.length < 1) return { ts: Date.now(), processes: [] };
122
+ // First line is the header row; remaining are process rows.
123
+ // stime is always a single word (e.g. "10:00" or "Feb11"), so splitting
124
+ // on 1+ whitespace is safe.
125
+ const titleFields = lines[0].trim().split(/\s+/);
126
+ const dataRows = lines.slice(1).map((line) => {
127
+ const parts = line.trim().split(/\s+/);
128
+ // CMD may contain spaces — rejoin everything after the 7th token.
129
+ if (parts.length > 7) {
130
+ return [...parts.slice(0, 6), parts.slice(6).join(' ')];
131
+ }
132
+ return parts;
133
+ });
134
+ const wrapped = JSON.stringify({ Titles: titleFields, Processes: dataRows });
135
+ return { ts: Date.now(), processes: parseDockerTop(wrapped) };
136
+ } else {
137
+ // Container mode: Docker HTTP API.
138
+ const apiBase = DOCKER_HOST.replace(/^tcp:\/\//, 'http://');
139
+ const url = `${apiBase}/containers/${encodeURIComponent(containerName)}/top?ps_args=${encodeURIComponent(ps_args)}`;
140
+ const resp = await fetch(url, { signal: AbortSignal.timeout(3000) });
141
+ if (!resp.ok) {
142
+ return { ts: Date.now(), processes: [], error: 'container not running' };
143
+ }
144
+ stdout = await resp.text();
145
+ return { ts: Date.now(), processes: parseDockerTop(stdout) };
146
+ }
147
+ } catch {
148
+ return { ts: Date.now(), processes: [], error: 'container not running' };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Snapshot — thin wrapper over fetchProcesses.
154
+ *
155
+ * @param {string} worldId
156
+ */
157
+ export async function getProcessSnapshot(worldId) {
158
+ return fetchProcesses(worldId);
159
+ }
160
+
161
+ // ── SSE fanout state ─────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Per-world subscriber registry.
165
+ * @type {Map<string, {pollTimer: ReturnType<typeof setInterval>, heartbeatTimer: ReturnType<typeof setInterval>, subscribers: Set<import('node:http').ServerResponse>}>}
166
+ */
167
+ const worldPollers = new Map();
168
+
169
+ /**
170
+ * Broadcast a payload to all subscribers for a world.
171
+ * @param {string} worldId
172
+ * @param {{ts: number, processes: ProcessRow[], error?: string}} data
173
+ */
174
+ function broadcast(worldId, data) {
175
+ const entry = worldPollers.get(worldId);
176
+ if (!entry) return;
177
+ const payload = `event: processes\ndata: ${JSON.stringify(data)}\n\n`;
178
+ for (const res of entry.subscribers) {
179
+ try { res.write(payload); } catch { /* subscriber gone; cleanup fires on close */ }
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Subscribe an SSE response to the world's process stream.
185
+ *
186
+ * SSE headers are written BEFORE adding to the Set (T2: prevents leak if close
187
+ * fires before headers are flushed — the cleanup handler is safe to call even
188
+ * with an empty Set).
189
+ *
190
+ * @param {string} worldId
191
+ * @param {import('node:http').ServerResponse} res
192
+ */
193
+ export function subscribeToProcesses(worldId, res) {
194
+ // Write SSE headers synchronously before touching the subscriber Set (T2).
195
+ res.writeHead(200, {
196
+ 'Content-Type': 'text/event-stream',
197
+ 'Cache-Control': 'no-cache',
198
+ 'Connection': 'keep-alive',
199
+ 'X-Accel-Buffering': 'no',
200
+ });
201
+
202
+ let entry = worldPollers.get(worldId);
203
+
204
+ if (!entry) {
205
+ // First subscriber — start the poll + heartbeat timers.
206
+ const pollTimer = setInterval(async () => {
207
+ const data = await fetchProcesses(worldId);
208
+ broadcast(worldId, data);
209
+ }, 5000);
210
+
211
+ const heartbeatTimer = setInterval(() => {
212
+ const e = worldPollers.get(worldId);
213
+ if (!e) return;
214
+ for (const r of e.subscribers) {
215
+ try { r.write(': heartbeat\n\n'); } catch { /* ignore */ }
216
+ }
217
+ }, 25000);
218
+
219
+ entry = { pollTimer, heartbeatTimer, subscribers: new Set() };
220
+ worldPollers.set(worldId, entry);
221
+ }
222
+
223
+ entry.subscribers.add(res);
224
+
225
+ // Send an immediate first snapshot so the client doesn't wait 5s.
226
+ fetchProcesses(worldId).then((data) => {
227
+ try { res.write(`event: processes\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* gone */ }
228
+ });
229
+
230
+ // Cleanup on disconnect — mirrors wireRelease pattern with once-flag.
231
+ let cleaned = false;
232
+ function cleanup() {
233
+ if (cleaned) return;
234
+ cleaned = true;
235
+ const e = worldPollers.get(worldId);
236
+ if (!e) return;
237
+ e.subscribers.delete(res);
238
+ if (e.subscribers.size === 0) {
239
+ clearInterval(e.pollTimer);
240
+ clearInterval(e.heartbeatTimer);
241
+ worldPollers.delete(worldId);
242
+ }
243
+ }
244
+
245
+ res.on('close', cleanup);
246
+ res.on('finish', cleanup);
247
+ }
248
+
249
+ // Export parseDockerTop for unit tests.
250
+ export { parseDockerTop };
@@ -0,0 +1,245 @@
1
+ // Phase F-2-B (B3): host CP HTTP proxy.
2
+ //
3
+ // Rewrites incoming requests under `/api/world/<id>/<route...>` to the
4
+ // per-world CP at `<perWorldBase>/<route...>` with `X-Olam-Secret`
5
+ // injected server-side.
6
+ //
7
+ // Pattern lifted from `packages/cloudflare-worker/src/index.ts:462-551`
8
+ // (`proxyContainer`). CF Worker uses Workers' `fetch()`; host CP uses
9
+ // Node's `http.request` so SSE streams flow byte-for-byte without
10
+ // buffering. Verbatim passthrough on /hooks/* and /api/auth/* (D8) is
11
+ // implemented in B4 (this module is JSON-API-only — B4 wraps).
12
+
13
+ import http from 'node:http';
14
+
15
+ /**
16
+ * Default upstream-request timeout for proxied per-world CP calls. SSE
17
+ * streams (`/api/stream`, `/hooks/*` long-poll) MUST opt out — they
18
+ * intentionally hold the socket open. Everything else should respond
19
+ * within a few seconds; if the per-world CP wedges (slow sqlite,
20
+ * tmux command stuck, long docker exec), this prevents the host-cp
21
+ * connection from hanging until the OS RSTs it. The browser sees a
22
+ * clean 504 instead of Safari's TypeError "Load failed", and useLanes /
23
+ * useReadiness can retry on a known status code.
24
+ *
25
+ * 10s matches the longest legitimate handler we've measured (cold
26
+ * sqlite open + readiness query) with headroom.
27
+ *
28
+ * @internal exported for test override
29
+ */
30
+ export const DEFAULT_PROXY_TIMEOUT_MS = 10_000;
31
+
32
+ /**
33
+ * Parse `/api/world/<id>/<route...>` from a request path. Returns
34
+ * `{ worldId, subPath }` or null if the path doesn't match.
35
+ *
36
+ * Anchored at `^/api/world/` to prevent prefix-matching from /api/worlds
37
+ * (the worlds-list endpoint, plural). Empty world IDs do not match.
38
+ *
39
+ * @param {string} path
40
+ * @returns {{ worldId: string, subPath: string } | null}
41
+ */
42
+ export function parseProxyPath(path) {
43
+ const m = /^\/api\/world\/([^/?#]+)(\/.*|\?.*|#.*)?$/.exec(path);
44
+ if (!m) return null;
45
+ return {
46
+ worldId: m[1],
47
+ subPath: m[2] ?? '/',
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Compute the per-world CP's base URL from a worldId. Today the world
53
+ * registry stores port offsets; the canonical port is `19080 + offset`.
54
+ * For B3, accept the port directly (deferring worlds.db integration to
55
+ * B6/B10). The caller (server.mjs) resolves worldId → port via worlds.db
56
+ * and passes the port here.
57
+ *
58
+ * In Docker Compose mode, host-cp is in its own network and reaches
59
+ * world CPs via `host.docker.internal:<port>` (compose.yaml's
60
+ * extra_hosts: host-gateway). On Docker Desktop this is automatic;
61
+ * on Linux it requires the `host-gateway` extra-host directive.
62
+ *
63
+ * @param {number} port per-world CP host port (e.g., 20780)
64
+ * @param {string} [host] optional hostname override (default 'host.docker.internal')
65
+ * @returns {string}
66
+ */
67
+ export function perWorldBase(port, host = 'host.docker.internal') { // bare-node-allow: container-mode default; bare callers pass WORLD_HOST explicitly (server.mjs)
68
+ return `http://${host}:${port}`;
69
+ }
70
+
71
+ /**
72
+ * SSE / long-poll paths whose handlers intentionally hold the socket
73
+ * open. These MUST be exempt from the upstream timeout — applying it
74
+ * would kill the stream every 10s. Caller can override per-request via
75
+ * `streaming: true`.
76
+ *
77
+ * @param {string} subPath
78
+ * @returns {boolean}
79
+ */
80
+ function isStreamingPath(subPath) {
81
+ // Strip query string before matching.
82
+ const p = subPath.split('?')[0];
83
+ return (
84
+ p === '/api/stream' ||
85
+ p.endsWith('/api/stream') ||
86
+ p.startsWith('/hooks/') ||
87
+ p === '/hooks' ||
88
+ /^\/api\/auth\/events(\/|$)/.test(p)
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Proxy an incoming request to a per-world CP, injecting X-Olam-Secret.
94
+ *
95
+ * Forwards: method, path (subPath), body bytes, ALL request headers
96
+ * EXCEPT `host` (rewritten) and `x-olam-secret` (overwritten with the
97
+ * injected secret to prevent client spoofing).
98
+ *
99
+ * Returns: status code, ALL response headers (verbatim — D8 contract
100
+ * forwards Set-Cookie, Location, etc. unchanged), body bytes streamed
101
+ * via Node's http.IncomingMessage→ServerResponse pipe (no buffering).
102
+ *
103
+ * Upstream timeout: short-request handlers (≠ SSE) get an upstream
104
+ * socket timeout of `timeoutMs` (defaults to DEFAULT_PROXY_TIMEOUT_MS).
105
+ * On expiry we abort the upstream socket and respond 504 — this
106
+ * converts a wedged per-world CP into a deterministic status code
107
+ * instead of a TCP RST that Safari surfaces as `TypeError: Load
108
+ * failed`. Pass `streaming: true` (or hit a path matching
109
+ * `isStreamingPath`) to opt out.
110
+ *
111
+ * @param {object} args
112
+ * @param {import('node:http').IncomingMessage} args.req
113
+ * @param {import('node:http').ServerResponse} args.res
114
+ * @param {string} args.subPath e.g., '/api/world' or '/api/stream'
115
+ * @param {string} args.targetBase e.g., 'http://host.docker.internal:20780'
116
+ * @param {string} args.secret the X-Olam-Secret value
117
+ * @param {(message: string) => void} [args.log]
118
+ * @param {number} [args.timeoutMs] per-request upstream timeout; ignored for streams
119
+ * @param {boolean} [args.streaming] force SSE/long-poll mode (skip timeout)
120
+ */
121
+ export function proxyToWorld({
122
+ req,
123
+ res,
124
+ subPath,
125
+ targetBase,
126
+ secret,
127
+ log = console.log,
128
+ timeoutMs = DEFAULT_PROXY_TIMEOUT_MS,
129
+ streaming = false,
130
+ }) {
131
+ const target = new URL(subPath, targetBase);
132
+ const isStream = streaming || isStreamingPath(subPath);
133
+
134
+ // Build outbound headers. Filter `host` (Node will set from URL) +
135
+ // overwrite `x-olam-secret` (defense against client spoofing).
136
+ /** @type {Record<string, string | string[]>} */
137
+ const outHeaders = {};
138
+ for (const [k, v] of Object.entries(req.headers)) {
139
+ if (v === undefined) continue;
140
+ const lower = k.toLowerCase();
141
+ if (lower === 'host' || lower === 'x-olam-secret') continue;
142
+ outHeaders[k] = v;
143
+ }
144
+ outHeaders['x-olam-secret'] = secret;
145
+ outHeaders['x-forwarded-by'] = 'olam-host-cp';
146
+
147
+ const upstreamReq = http.request(
148
+ target,
149
+ {
150
+ method: req.method ?? 'GET',
151
+ headers: outHeaders,
152
+ },
153
+ (upstreamRes) => {
154
+ // Once headers come back from upstream, the request is no longer
155
+ // "stuck" — clear the timeout so a slow stream-of-body doesn't
156
+ // get killed mid-flight. Streaming handlers that intentionally
157
+ // delay between writes still rely on the no-timeout path.
158
+ if (timer !== null) {
159
+ clearTimeout(timer);
160
+ timer = null;
161
+ }
162
+ // Verbatim passthrough: status + ALL headers + body bytes.
163
+ // Use res.writeHead so the headers go out atomically with the
164
+ // status line (response.statusCode + setHeader split would race
165
+ // on early body write). statusMessage may be undefined on some
166
+ // upstream paths — fall back to the default.
167
+ res.writeHead(
168
+ upstreamRes.statusCode ?? 502,
169
+ upstreamRes.statusMessage,
170
+ upstreamRes.headers,
171
+ );
172
+ upstreamRes.pipe(res);
173
+ },
174
+ );
175
+
176
+ /** @type {ReturnType<typeof setTimeout> | null} */
177
+ let timer = null;
178
+ if (!isStream && timeoutMs > 0) {
179
+ timer = setTimeout(() => {
180
+ timer = null;
181
+ log(`proxy: upstream timeout (${timeoutMs}ms) for ${target}`);
182
+ // Destroying the upstream req triggers the 'error' handler with
183
+ // a generic socket error; we pre-empt it with an explicit 504
184
+ // first so the client sees a clean status instead of the generic
185
+ // 502 the error handler would emit.
186
+ if (!res.headersSent) {
187
+ res.writeHead(504, { 'Content-Type': 'application/json; charset=utf-8' });
188
+ res.end(JSON.stringify({
189
+ error: 'upstream_timeout',
190
+ message: `per-world CP did not respond within ${timeoutMs}ms`,
191
+ worldUrl: target.origin,
192
+ }));
193
+ } else {
194
+ res.end();
195
+ }
196
+ try {
197
+ upstreamReq.destroy(new Error('proxy upstream timeout'));
198
+ } catch {
199
+ // already destroyed
200
+ }
201
+ }, timeoutMs);
202
+ }
203
+
204
+ // Upstream connection error — don't leak internals to the client.
205
+ upstreamReq.on('error', (err) => {
206
+ if (timer !== null) {
207
+ clearTimeout(timer);
208
+ timer = null;
209
+ }
210
+ log(`proxy: upstream error for ${target}: ${err.message}`);
211
+ if (!res.headersSent) {
212
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
213
+ res.end(JSON.stringify({
214
+ error: 'upstream_unreachable',
215
+ message: 'per-world CP did not respond',
216
+ worldUrl: target.origin,
217
+ }));
218
+ } else {
219
+ // Response already started (likely SSE); just close.
220
+ res.end();
221
+ }
222
+ });
223
+
224
+ // Client closed early (browser navigated away, Safari unloaded the
225
+ // EventSource, etc.). Tear down the upstream so we don't keep an
226
+ // open socket to the per-world CP for an answer the caller no longer
227
+ // wants. Without this, host-cp leaks sockets per cancelled poll.
228
+ res.on('close', () => {
229
+ if (timer !== null) {
230
+ clearTimeout(timer);
231
+ timer = null;
232
+ }
233
+ if (!upstreamReq.destroyed) {
234
+ try {
235
+ upstreamReq.destroy();
236
+ } catch {
237
+ // already gone
238
+ }
239
+ }
240
+ });
241
+
242
+ // Pipe request body. For GET/HEAD this is a no-op (no body bytes);
243
+ // for POST/PUT/PATCH this streams the body upstream.
244
+ req.pipe(upstreamReq);
245
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Phase E3 (olam-dogfood-vision): PylonWorldsSource skeleton.
3
+ *
4
+ * Stub implementation of the WorldsSource contract (E1) for Pylon-
5
+ * managed cloud worlds. Returns `[]` for now — the actual @pleri/pylon
6
+ * SDK integration is intentionally deferred (T5 mitigation: design the
7
+ * contract before the SDK lands so consumers don't churn when it does).
8
+ *
9
+ * The class proves the interface composes: E4 wires this alongside
10
+ * LocalWorldsSource into the GET /api/worlds handler so a Pylon-enabled
11
+ * deployment fans out across both sources, dedupes by id, and returns
12
+ * the union. With this stub returning `[]`, an enabled-but-empty Pylon
13
+ * source is a strict no-op over local-only behavior.
14
+ *
15
+ * Activation: gated by `OLAM_HOST_CP_PYLON_ENABLED=1`. When the env
16
+ * var is unset/0/false, server.mjs (E4) does NOT instantiate this
17
+ * source — the local-only path is preserved verbatim. When enabled,
18
+ * the empty source layers additively on top of local; behavior is
19
+ * still observably identical until the SDK ships.
20
+ *
21
+ * Why a no-op stub instead of waiting for the SDK:
22
+ * - Consumers (SPA badge logic in E5, regression tests, CLI lookup)
23
+ * can be wired against the contract without blocking on the SDK.
24
+ * - Forces E4's composition logic to actually fan out, dedupe, and
25
+ * merge — exercising the multi-source path in CI before any cloud
26
+ * traffic touches it.
27
+ * - Surface-area lock-in: anything missing here surfaces as a
28
+ * contract gap NOW, not after the SDK is wired.
29
+ *
30
+ * @typedef {import('./worlds-source.mjs').WorldsSource} WorldsSource
31
+ * @typedef {import('./worlds-source.mjs').WorldSummary} WorldSummary
32
+ */
33
+
34
+ /**
35
+ * @typedef {object} PylonWorldsSourceDeps
36
+ * @property {boolean} enabled
37
+ * When false, list() short-circuits to `[]` without any Pylon
38
+ * interaction. Kept on the deps object (rather than read from
39
+ * process.env at construction time) so tests can flip it without
40
+ * mutating module-level env state.
41
+ */
42
+
43
+ /**
44
+ * @param {PylonWorldsSourceDeps} [deps]
45
+ * @returns {WorldsSource}
46
+ */
47
+ export function createPylonWorldsSource(deps = { enabled: false }) {
48
+ return {
49
+ name: 'pylon-cloud',
50
+ async list() {
51
+ if (!deps.enabled) return [];
52
+ // TODO(pylon): wire @pleri/pylon SDK. Expected shape:
53
+ // const client = new PylonClient({ token: scopedToken });
54
+ // const cloudWorlds = await client.worlds.list();
55
+ // return cloudWorlds.map((w) => ({
56
+ // id: w.id,
57
+ // name: w.displayName ?? null,
58
+ // status: mapPylonStatus(w.state), // 'running' | 'starting' | ...
59
+ // services: mapPylonServices(w.services),
60
+ // source: 'pylon-cloud',
61
+ // }));
62
+ // Until the SDK lands, the source is intentionally empty —
63
+ // proving the interface composes (E4) without committing the
64
+ // mapping shape prematurely.
65
+ return [];
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,67 @@
1
+ // Phase F-2-B (B6): redact sensitive keys from workspace YAML before
2
+ // exposing via /api/workspaces.
3
+ //
4
+ // T11 mitigation. Workspace YAMLs may contain operator-set environment
5
+ // variables that include OAuth client secrets, API keys, deployment
6
+ // tokens, database passwords. These should NEVER cross the host-cp ↔
7
+ // browser boundary.
8
+ //
9
+ // Strategy: pattern-based recursive redaction. Any object key matching
10
+ // SENSITIVE_KEY_PATTERN replaces its value with `[redacted]`. Catches
11
+ // the standard naming conventions while remaining permissive on
12
+ // non-sensitive keys (we don't false-positive on legitimate config).
13
+ //
14
+ // The pattern is intentionally broad — it's defensive. If an operator
15
+ // names a non-sensitive var with a `_KEY`/`_SECRET`/`_TOKEN`/`_PASSWORD`/
16
+ // `_CREDENTIALS` suffix, it gets redacted. Operators get a clear signal
17
+ // (the value becomes `[redacted]`) and can rename the var if needed.
18
+ //
19
+ // We deliberately do NOT use the `PROTECTED_ENV_KEYS` set from
20
+ // packages/core/src/world/env-setup.ts — that set is for service-
21
+ // discovery host/port/URL keys (POSTGRES_HOST, REDIS_URL, etc.), not
22
+ // org secrets. The two filters address different surfaces:
23
+ // - PROTECTED_ENV_KEYS in core: prevents manifest from overriding
24
+ // service-discovery state on the world's runtime env
25
+ // - SENSITIVE_KEY_PATTERN here: prevents the host CP API from leaking
26
+ // org secrets to the browser
27
+ // Both are needed.
28
+
29
+ export const SENSITIVE_KEY_PATTERN = /(.*_KEY|.*_SECRET|.*_TOKEN|.*_PASSWORD|.*_CREDENTIALS|.*_AUTH|API_KEY|PASSWORD|SECRET|TOKEN)$/i;
30
+
31
+ /**
32
+ * Recursively redact sensitive values in any JSON-like structure
33
+ * (objects, arrays, primitives). Returns a new value; does not mutate
34
+ * input.
35
+ *
36
+ * @param {unknown} value
37
+ * @returns {unknown}
38
+ */
39
+ export function redactSensitive(value) {
40
+ if (Array.isArray(value)) {
41
+ return value.map(redactSensitive);
42
+ }
43
+ if (value !== null && typeof value === 'object') {
44
+ /** @type {Record<string, unknown>} */
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(value)) {
47
+ if (SENSITIVE_KEY_PATTERN.test(k)) {
48
+ out[k] = '[redacted]';
49
+ } else {
50
+ out[k] = redactSensitive(v);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ return value;
56
+ }
57
+
58
+ /**
59
+ * Quick predicate: does this key name look sensitive? Useful for
60
+ * pre-screening when iterating large maps.
61
+ *
62
+ * @param {string} key
63
+ * @returns {boolean}
64
+ */
65
+ export function isSensitiveKey(key) {
66
+ return SENSITIVE_KEY_PATTERN.test(key);
67
+ }