@pleri/olam-cli 0.1.144 → 0.1.145

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 (48) hide show
  1. package/dist/commands/doctor.d.ts +53 -23
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +117 -46
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/logs.d.ts +17 -3
  6. package/dist/commands/logs.d.ts.map +1 -1
  7. package/dist/commands/logs.js +38 -35
  8. package/dist/commands/logs.js.map +1 -1
  9. package/dist/commands/memory/bridge.d.ts +57 -0
  10. package/dist/commands/memory/bridge.d.ts.map +1 -0
  11. package/dist/commands/memory/bridge.js +156 -0
  12. package/dist/commands/memory/bridge.js.map +1 -0
  13. package/dist/commands/memory/index.d.ts +3 -0
  14. package/dist/commands/memory/index.d.ts.map +1 -1
  15. package/dist/commands/memory/index.js +10 -1
  16. package/dist/commands/memory/index.js.map +1 -1
  17. package/dist/commands/memory/reclassify.d.ts +56 -0
  18. package/dist/commands/memory/reclassify.d.ts.map +1 -0
  19. package/dist/commands/memory/reclassify.js +177 -0
  20. package/dist/commands/memory/reclassify.js.map +1 -0
  21. package/dist/commands/memory/stats.d.ts +69 -0
  22. package/dist/commands/memory/stats.d.ts.map +1 -0
  23. package/dist/commands/memory/stats.js +164 -0
  24. package/dist/commands/memory/stats.js.map +1 -0
  25. package/dist/commands/skills-source.d.ts +12 -0
  26. package/dist/commands/skills-source.d.ts.map +1 -0
  27. package/dist/commands/skills-source.js +133 -0
  28. package/dist/commands/skills-source.js.map +1 -0
  29. package/dist/commands/skills.d.ts +11 -0
  30. package/dist/commands/skills.d.ts.map +1 -0
  31. package/dist/commands/skills.js +163 -0
  32. package/dist/commands/skills.js.map +1 -0
  33. package/dist/commands/status.d.ts +27 -0
  34. package/dist/commands/status.d.ts.map +1 -1
  35. package/dist/commands/status.js +102 -1
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/image-digests.json +7 -7
  38. package/dist/index.js +2027 -529
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/health-probes.d.ts +72 -0
  41. package/dist/lib/health-probes.d.ts.map +1 -1
  42. package/dist/lib/health-probes.js +218 -0
  43. package/dist/lib/health-probes.js.map +1 -1
  44. package/dist/mcp-server.js +1246 -351
  45. package/host-cp/src/agent-runtime-trigger.mjs +74 -4
  46. package/host-cp/src/engine-identity.mjs +32 -0
  47. package/host-cp/src/server.mjs +188 -3
  48. package/package.json +1 -1
@@ -165,11 +165,81 @@ export async function triggerAgentRuntime(args) {
165
165
  };
166
166
  }
167
167
 
168
- // Container mode (docker-socket-proxy): POST /containers/<name>/exec then
169
- // /exec/<id>/start with Detach:true. Deferred to B7-full — the bare-node
170
- // mode covers the local-demo flow.
168
+ // Container mode (docker-socket-proxy on tcp://<host>:<port>).
169
+ // Two-step Docker API exec: POST /containers/<name>/exec creates an
170
+ // exec instance, then POST /exec/<id>/start with Detach=true runs it
171
+ // in the background. Matches the pattern in container-secret-fetcher.mjs.
172
+ if (dockerHost.startsWith('tcp://')) {
173
+ const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');
174
+
175
+ // Step 0: verify the container is running.
176
+ const inspectRes = await fetch(
177
+ `${apiBase}/containers/${encodeURIComponent(containerName)}/json`,
178
+ );
179
+ if (!inspectRes.ok) {
180
+ throw new Error(
181
+ `socket-proxy GET /containers/${containerName}/json: ${inspectRes.status} ${inspectRes.statusText}`,
182
+ );
183
+ }
184
+ const inspect = await inspectRes.json();
185
+ if (!inspect?.State?.Running) {
186
+ throw new Error(
187
+ `container ${containerName} is not running (state: ${JSON.stringify(inspect?.State)})`,
188
+ );
189
+ }
190
+
191
+ // Step 1: create exec instance with env injection.
192
+ const createRes = await fetch(
193
+ `${apiBase}/containers/${encodeURIComponent(containerName)}/exec`,
194
+ {
195
+ method: 'POST',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body: JSON.stringify({
198
+ Cmd: ['node', supervisorPath],
199
+ Env: [
200
+ `HOST_CP_URL=${hostCpUrl}`,
201
+ `HOST_CP_BEARER=${bearer}`,
202
+ `WORLD_ID=${worldId}`,
203
+ `SESSION_ID=${sessionId}`,
204
+ ],
205
+ AttachStdout: false,
206
+ AttachStderr: false,
207
+ Tty: false,
208
+ }),
209
+ },
210
+ );
211
+ if (!createRes.ok) {
212
+ const errBody = await createRes.text().catch(() => '<no body>');
213
+ throw new Error(
214
+ `socket-proxy POST /containers/${containerName}/exec: ${createRes.status} — ${errBody}`,
215
+ );
216
+ }
217
+ const { Id: execId } = await createRes.json();
218
+
219
+ // Step 2: start exec in detached mode.
220
+ const startRes = await fetch(`${apiBase}/exec/${execId}/start`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({ Detach: true, Tty: false }),
224
+ });
225
+ if (!startRes.ok && startRes.status !== 200) {
226
+ const errBody = await startRes.text().catch(() => '<no body>');
227
+ throw new Error(
228
+ `socket-proxy POST /exec/${execId}/start: ${startRes.status} — ${errBody}`,
229
+ );
230
+ }
231
+
232
+ liveSpawns.set(k, { spawnedAt: Date.now(), execId });
233
+
234
+ return {
235
+ status: 'spawned',
236
+ container: containerName,
237
+ execId,
238
+ };
239
+ }
240
+
171
241
  throw new Error(
172
- `triggerAgentRuntime: dockerHost mode '${dockerHost}' not supported in minimum-demo cut; use 'docker-cli'`,
242
+ `triggerAgentRuntime: unsupported dockerHost mode '${dockerHost}'`,
173
243
  );
174
244
  }
175
245
 
@@ -0,0 +1,32 @@
1
+ // Container-engine identity for host-cp.
2
+ //
3
+ // Phase 1a / A1: defaults to "docker"; switches to "kubernetes" when running
4
+ // inside a K8s pod (autodetected via KUBERNETES_SERVICE_HOST). Operators can
5
+ // override either way via OLAM_HOST_CP_ENGINE.
6
+ //
7
+ // This module exists separately from server.mjs to keep the engine-resolution
8
+ // logic pure (no I/O, no mkdir, no global side-effects) so unit tests can
9
+ // import it without triggering server startup. server.mjs imports
10
+ // resolveHostCpEngine from here and computes its module-level HOST_CP_ENGINE
11
+ // constant.
12
+ //
13
+ // KubernetesEngine adapter (Phase B / PR3) consumes the same env variables
14
+ // when constructing the engine; the context-allowlist guard (T6 / Decision 10)
15
+ // lives inside that adapter, not here. This module is "what name to surface
16
+ // in the X-Olam-Engine response header" — nothing more.
17
+
18
+ /**
19
+ * Resolve the active container-engine identity for host-cp.
20
+ *
21
+ * Precedence (matches HOST_CP_MODE convention at server.mjs:85-87):
22
+ * 1. Explicit env override: OLAM_HOST_CP_ENGINE=docker|kubernetes
23
+ * 2. Autodetect: KUBERNETES_SERVICE_HOST set → "kubernetes"
24
+ * 3. Default: "docker"
25
+ *
26
+ * @param {NodeJS.ProcessEnv} [env=process.env] - environment to inspect.
27
+ * @returns {string} - engine identity surfaced via X-Olam-Engine header.
28
+ */
29
+ export function resolveHostCpEngine(env = process.env) {
30
+ return env.OLAM_HOST_CP_ENGINE
31
+ ?? (env.KUBERNETES_SERVICE_HOST ? 'kubernetes' : 'docker');
32
+ }
@@ -37,6 +37,7 @@ import { subscribeDockerEvents } from './docker-events.mjs';
37
37
  import { createHostStream, newStreamId } from './host-stream.mjs';
38
38
  import { spawnUpgraderContainer } from './upgrade-spawner.mjs';
39
39
  import { parseProxyPath, perWorldBase, proxyToWorld } from './proxy.mjs';
40
+ import { resolveHostCpEngine } from './engine-identity.mjs';
40
41
  import { StartupToken } from './auth.mjs';
41
42
  import { SseGate, isSsePath, wireRelease } from './sse-gate.mjs';
42
43
  import {
@@ -88,6 +89,11 @@ const HOST_CP_MODE = process.env.OLAM_HOST_CP_MODE
88
89
  ?? (fs.existsSync('/.dockerenv') ? 'container' : 'bare');
89
90
  const WORLD_HOST = HOST_CP_MODE === 'container' ? 'host.docker.internal' : '127.0.0.1';
90
91
 
92
+ // Container-engine identity, surfaced to olam-cli via the X-Olam-Engine
93
+ // response header on /health. Resolution lives in engine-identity.mjs so
94
+ // unit tests can import the pure function without triggering server startup.
95
+ const HOST_CP_ENGINE = resolveHostCpEngine();
96
+
91
97
  const PORT = parseInt(process.env.OLAM_HOST_CP_PORT ?? '19000', 10);
92
98
  // In container mode the host-cp talks to the docker daemon via the
93
99
  // socket-proxy sidecar (the proxy enforces the read-only API allow-list).
@@ -664,7 +670,13 @@ const server = http.createServer(async (req, res) => {
664
670
  // /health: fast diagnostics, no auth, no proxying. Docker healthcheck
665
671
  // hits this; SPA pre-load may also poll. Stays unauth so the container
666
672
  // healthcheck doesn't need to know the token.
673
+ //
674
+ // X-Olam-Engine header surfaces the active container-engine adapter to
675
+ // olam-cli (consumed by `olam doctor`, `olam logs`, `olam status`). Lives
676
+ // in the header (NOT the body) so the response body shape stays unchanged
677
+ // and probes don't accidentally leak engine identity to body parsers.
667
678
  if (url.pathname === '/health') {
679
+ res.setHeader('X-Olam-Engine', HOST_CP_ENGINE);
668
680
  return jsonReply(res, 200, {
669
681
  status: 'ok',
670
682
  phase: 'B4',
@@ -725,6 +737,13 @@ const server = http.createServer(async (req, res) => {
725
737
  if (served) return;
726
738
  }
727
739
 
740
+ // /api/plan-chat/* bypasses the host-cp session-token gate because the
741
+ // plan-chat-service it proxies to has its own Bearer auth (the
742
+ // plan-chat-secret). The proxy handler is registered later in the file;
743
+ // dispatch by continuing past the gate.
744
+ if (url.pathname.startsWith('/api/plan-chat/') || url.pathname === '/api/plan-chat') {
745
+ // fall through to the proxy route below
746
+ } else
728
747
  // ALL OTHER ROUTES require auth. Reject with 401 if neither cookie
729
748
  // nor Bearer header matches.
730
749
  if (!auth.isAuthorized(req)) {
@@ -1875,7 +1894,7 @@ const server = http.createServer(async (req, res) => {
1875
1894
  if (!await requirePlanCredential(res)) return;
1876
1895
  let body;
1877
1896
  try {
1878
- body = JSON.parse((await readRequestBody(req)) || '{}');
1897
+ body = await readRequestBody(req);
1879
1898
  } catch (err) {
1880
1899
  return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
1881
1900
  }
@@ -1920,6 +1939,66 @@ const server = http.createServer(async (req, res) => {
1920
1939
  }
1921
1940
  }
1922
1941
 
1942
+ // /api/plan-chat/* — passthrough proxy to plan-chat-service.
1943
+ // The sidecar runs on PLAN_CHAT_SERVICE_URL (default http://127.0.0.1:3112).
1944
+ // Strips the /api/plan-chat prefix; forwards method, headers, body, and
1945
+ // query verbatim. Streams the response (Electric SQL long-poll friendly).
1946
+ // Auth: client supplies Bearer; we don't add or strip it.
1947
+ if (url.pathname.startsWith('/api/plan-chat/') || url.pathname === '/api/plan-chat') {
1948
+ const upstreamBase =
1949
+ process.env.PLAN_CHAT_SERVICE_URL ??
1950
+ // Default depends on where host-cp runs. In-container = host.docker.internal;
1951
+ // bare-node = 127.0.0.1. DOCKER_HOST=tcp://* implies container mode.
1952
+ ((process.env.DOCKER_HOST ?? '').startsWith('tcp://')
1953
+ ? 'http://host.docker.internal:3112'
1954
+ : 'http://127.0.0.1:3112');
1955
+ const subPath = url.pathname === '/api/plan-chat'
1956
+ ? '/'
1957
+ : url.pathname.slice('/api/plan-chat'.length);
1958
+ const upstreamUrl = new URL(subPath + url.search, upstreamBase);
1959
+ try {
1960
+ const headers = {};
1961
+ for (const [k, v] of Object.entries(req.headers)) {
1962
+ if (k === 'host' || k === 'connection' || k === 'content-length') continue;
1963
+ if (Array.isArray(v)) headers[k] = v.join(', ');
1964
+ else if (typeof v === 'string') headers[k] = v;
1965
+ }
1966
+ // Buffer body for non-GET/HEAD; GET shouldn't carry one.
1967
+ let body;
1968
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1969
+ const chunks = [];
1970
+ for await (const c of req) chunks.push(c);
1971
+ body = Buffer.concat(chunks);
1972
+ }
1973
+ const upstreamRes = await fetch(upstreamUrl.toString(), {
1974
+ method: req.method,
1975
+ headers,
1976
+ body,
1977
+ redirect: 'manual',
1978
+ });
1979
+ res.statusCode = upstreamRes.status;
1980
+ upstreamRes.headers.forEach((value, name) => {
1981
+ if (name === 'content-encoding' || name === 'transfer-encoding' || name === 'connection') return;
1982
+ res.setHeader(name, value);
1983
+ });
1984
+ if (upstreamRes.body) {
1985
+ const reader = upstreamRes.body.getReader();
1986
+ while (true) {
1987
+ const { value, done } = await reader.read();
1988
+ if (done) break;
1989
+ res.write(value);
1990
+ }
1991
+ }
1992
+ return res.end();
1993
+ } catch (err) {
1994
+ return jsonReply(res, 502, {
1995
+ error: 'plan_chat_proxy_failed',
1996
+ upstream: upstreamUrl.toString(),
1997
+ message: err.message,
1998
+ });
1999
+ }
2000
+ }
2001
+
1923
2002
  // GET /api/worlds/:id/processes
1924
2003
  // GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world)
1925
2004
  // Handler: routes/process-port.mjs → handleListProcesses
@@ -1992,6 +2071,85 @@ const server = http.createServer(async (req, res) => {
1992
2071
  }
1993
2072
  }
1994
2073
 
2074
+ // ── /v1/worlds/:id/{status,logs} (Phase C / C1) ─────────────────
2075
+ //
2076
+ // Engine-agnostic per-world surfaces consumed by `olam status <world>`
2077
+ // and `olam logs <world>`. Delegates to the active ContainerEngine
2078
+ // (DockerEngine or KubernetesEngine) so the wire shape is identical
2079
+ // regardless of the underlying runtime per Decision 17.
2080
+ //
2081
+ // Auth: this branch is INSIDE the `auth.isAuthorized(req)` gate above
2082
+ // (line 742) — any caller without a valid token has already received
2083
+ // 401. The v1-worlds-endpoints.test.mjs source-asserts that this
2084
+ // route is registered after the auth gate, NOT outside it
2085
+ // (Decision 18 — security-critical positional contract).
2086
+ //
2087
+ // worldId hardening: the K8s engine uses worldId as a labelSelector
2088
+ // value (`olam-world-id=<worldId>`). A worldId containing `,` could
2089
+ // synthesise additional label clauses ("comma-injection"). A token-
2090
+ // holding caller already has full host-cp access so this is
2091
+ // defense-in-depth, but we still reject non-canonical values to keep
2092
+ // the engine's external query shape predictable.
2093
+ const V1_WORLD_ID = /^[A-Za-z0-9._-]+$/;
2094
+ const v1WorldsStatusMatch = /^\/v1\/worlds\/([^/?#]+)\/status\/?$/.exec(url.pathname);
2095
+ if (v1WorldsStatusMatch && req.method === 'GET') {
2096
+ const worldId = decodeURIComponent(v1WorldsStatusMatch[1]);
2097
+ if (!V1_WORLD_ID.test(worldId)) {
2098
+ return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
2099
+ }
2100
+ try {
2101
+ const report = await hostCpEngine.getWorldStatus(worldId);
2102
+ return jsonReply(res, 200, report);
2103
+ } catch (err) {
2104
+ return jsonReply(res, 500, {
2105
+ error: 'engine_error',
2106
+ worldId,
2107
+ message: err?.message ?? String(err),
2108
+ });
2109
+ }
2110
+ }
2111
+
2112
+ const v1WorldsLogsMatch = /^\/v1\/worlds\/([^/?#]+)\/logs\/?$/.exec(url.pathname);
2113
+ if (v1WorldsLogsMatch && req.method === 'GET') {
2114
+ const worldId = decodeURIComponent(v1WorldsLogsMatch[1]);
2115
+ if (!V1_WORLD_ID.test(worldId)) {
2116
+ return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
2117
+ }
2118
+ const tailParam = url.searchParams.get('tail');
2119
+ const followParam = url.searchParams.get('follow');
2120
+ const serviceParam = url.searchParams.get('service');
2121
+ const opts = {
2122
+ tail: tailParam != null ? parseInt(tailParam, 10) : 200,
2123
+ follow: followParam === '1' || followParam === 'true',
2124
+ ...(serviceParam ? { service: serviceParam } : {}),
2125
+ };
2126
+ const abortController = new AbortController();
2127
+ req.on('close', () => abortController.abort());
2128
+ res.writeHead(200, {
2129
+ 'Content-Type': 'text/plain; charset=utf-8',
2130
+ 'Cache-Control': 'no-cache',
2131
+ });
2132
+ try {
2133
+ // Engine yields canonical { ts, container, subContainer, line }
2134
+ // records; server formats one line per record: `<ts> <pod>/<container> <line>`.
2135
+ const iterable = hostCpEngine.getWorldLogs(worldId, { ...opts, signal: abortController.signal });
2136
+ for await (const rec of iterable) {
2137
+ if (res.writableEnded || abortController.signal.aborted) break;
2138
+ const ts = rec.ts || '-';
2139
+ const container = rec.container || '-';
2140
+ const sub = rec.subContainer || container;
2141
+ res.write(`${ts} ${container}/${sub} ${rec.line}\n`);
2142
+ }
2143
+ } catch (err) {
2144
+ if (!res.writableEnded) {
2145
+ res.write(`# engine error: ${err?.message ?? String(err)}\n`);
2146
+ }
2147
+ } finally {
2148
+ if (!res.writableEnded) res.end();
2149
+ }
2150
+ return;
2151
+ }
2152
+
1995
2153
  // Anything else → 404. B4 ships static SPA serving + auth.
1996
2154
  jsonReply(res, 404, {
1997
2155
  error: 'not_found',
@@ -2377,7 +2535,7 @@ const DIST_DIR = (() => {
2377
2535
  })();
2378
2536
 
2379
2537
  const SPA_ROUTES = new Set(['/', '/worlds', '/workspaces', '/inbox', '/repos', '/runbooks']);
2380
- const SPA_PREFIX = ['/world/', '/worlds/', '/workspaces/', '/inbox/', '/repos/', '/runbooks/'];
2538
+ const SPA_PREFIX = ['/world/', '/worlds/', '/workspaces/', '/inbox/', '/repos/', '/runbooks/', '/session/'];
2381
2539
 
2382
2540
  // Top-level path segments that are NOT world IDs. Mirrors RESERVED_SEGMENTS
2383
2541
  // in lib/worldId.ts — keep in sync.
@@ -2595,7 +2753,7 @@ let _spaCacheMtime = 0;
2595
2753
  // and the token-comparison check skips reload when the cookie
2596
2754
  // already matches (so non-rotation 401s — e.g. genuine auth
2597
2755
  // failures — don't cause a refresh loop).
2598
- const BOOTSTRAP_SCRIPT = `<script>(function(){function ck(){var m=document.cookie.match(/olam_host_cp_token=([^;]+)/);return m?m[1]:'';}function sw(t){document.cookie='olam_host_cp_token='+t+'; path=/; samesite=strict';}try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);sw(d.token);}}catch(e){console.error('[host-cp bootstrap]',e);}var reloading=false;function recover(){if(reloading)return;try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);if(d.token&&ck()!==d.token){reloading=true;sw(d.token);console.warn('[host-cp auth recover] token rotated; reloading');location.reload();}}}catch(e){console.error('[host-cp auth recover]',e);}}var HN=['/api/bootstrap','/api/worlds','/api/projects','/api/workspaces','/api/workspaces/match','/api/repos','/api/runbooks','/api/auth','/api/host-stream','/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|repos|runbooks|assets|api|health|favicon)($|\\/|\\?)/.test(p))return null;var r=p.match(/^\\/([a-z][a-z0-9-]+)(?:\\/|$|\\?)/);return r?r[1]:null;}function rw(p){var w=wid();return w?'/api/world/'+w+p:p;}var of=window.fetch.bind(window);window.fetch=function(input,init){var pr;if(typeof input==='string'&&sr(input))pr=of(rw(input),init);else if(input&&typeof input.url==='string'&&sr(input.url))pr=of(new Request(rw(input.url),input),init);else pr=of(input,init);return pr.then(function(res){if(res&&res.status===401)recover();return res;});};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);var es=new OE(s,i);es.addEventListener('error',function(){if(es.readyState===OE.CLOSED)recover();});return es;};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
2756
+ const BOOTSTRAP_SCRIPT = `<script>(function(){function ck(){var m=document.cookie.match(/olam_host_cp_token=([^;]+)/);return m?m[1]:'';}function sw(t){document.cookie='olam_host_cp_token='+t+'; path=/; samesite=strict';}try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);sw(d.token);}}catch(e){console.error('[host-cp bootstrap]',e);}var reloading=false;function recover(){if(reloading)return;try{var x=new XMLHttpRequest();x.open('GET','/api/bootstrap',false);x.send();if(x.status===200){var d=JSON.parse(x.responseText);if(d.token&&ck()!==d.token){reloading=true;sw(d.token);console.warn('[host-cp auth recover] token rotated; reloading');location.reload();}}}catch(e){console.error('[host-cp auth recover]',e);}}var HN=['/api/bootstrap','/api/worlds','/api/projects','/api/workspaces','/api/workspaces/match','/api/repos','/api/runbooks','/api/auth','/api/host-stream','/api/plan-chat','/api/plan/agent-runtime','/health'];var WP=['/api/','/session/','/hooks/','/dispatch','/lanes','/codex/','/review/'];function sr(p){if(typeof p!=='string')return false;if(p.startsWith('/api/world/'))return false;for(var i=0;i<HN.length;i++){var n=HN[i];if(p===n||p.startsWith(n+'?')||p.startsWith(n+'/'))return false;}for(var j=0;j<WP.length;j++){var w=WP[j];if(p===w||p===w.replace(/\\/$/,'')||p.startsWith(w)||p.startsWith(w.replace(/\\/$/,'')+'?')||p.startsWith(w.replace(/\\/$/,'')+'/'))return true;}return false;}function wid(){var p=location.pathname;var m=p.match(/^\\/(world|inbox|session)\\/([^/?#]+)/);if(m)return m[2];if(/^\\/(?:worlds?|workspaces?|world|sandbox|session|inbox|plan|design|repos|runbooks|assets|api|health|favicon)($|\\/|\\?)/.test(p))return null;var r=p.match(/^\\/([a-z][a-z0-9-]+)(?:\\/|$|\\?)/);return r?r[1]:null;}function rw(p){var w=wid();return w?'/api/world/'+w+p:p;}var of=window.fetch.bind(window);window.fetch=function(input,init){var pr;if(typeof input==='string'&&sr(input))pr=of(rw(input),init);else if(input&&typeof input.url==='string'&&sr(input.url))pr=of(new Request(rw(input.url),input),init);else pr=of(input,init);return pr.then(function(res){if(res&&res.status===401)recover();return res;});};var OE=window.EventSource;if(OE){window.EventSource=function(u,i){var s=u;if(typeof s==='string'&&sr(s))s=rw(s);var es=new OE(s,i);es.addEventListener('error',function(){if(es.readyState===OE.CLOSED)recover();});return es;};window.EventSource.prototype=OE.prototype;window.EventSource.CONNECTING=OE.CONNECTING;window.EventSource.OPEN=OE.OPEN;window.EventSource.CLOSED=OE.CLOSED;}})();</script>`;
2599
2757
 
2600
2758
  async function renderSpaShell(filePath) {
2601
2759
  const stat = fs.statSync(filePath);
@@ -2701,12 +2859,39 @@ startWorldsSnapshotLoop();
2701
2859
  startTunnelsSnapshotLoop();
2702
2860
  startListeningSnapshotLoop();
2703
2861
 
2862
+ // ── Phase 1a / B1 (PR3): engine-select + await-before-listen ─────
2863
+ //
2864
+ // Decision 15: the async KubernetesEngine factory MUST be fully awaited
2865
+ // BEFORE `server.listen()`. If the factory throws (context-allowlist guard
2866
+ // rejects a non-local kubectl context), startup aborts via the top-level
2867
+ // await's unhandled rejection — the HTTP server never binds. No half-live
2868
+ // state.
2869
+ //
2870
+ // Selection precedence matches the existing resolveHostCpEngine():
2871
+ // 1. OLAM_HOST_CP_ENGINE=kubernetes → KubernetesEngine
2872
+ // 2. KUBERNETES_SERVICE_HOST set → KubernetesEngine (autodetect)
2873
+ // 3. otherwise → DockerEngine
2874
+ //
2875
+ // DockerEngine ships as a sync factory (no await needed), but we still
2876
+ // resolve through the same async branch for symmetry — the call-site
2877
+ // migration to engine.* methods is a downstream task; today the engine
2878
+ // instance is held for /health diagnostic + future use.
2879
+ const hostCpEngine = await (async () => {
2880
+ if (HOST_CP_ENGINE === 'kubernetes') {
2881
+ const { createKubernetesEngine } = await import('./engines/kubernetes.mjs');
2882
+ return createKubernetesEngine({ env: process.env });
2883
+ }
2884
+ const { createDockerEngine } = await import('./engines/docker.mjs');
2885
+ return createDockerEngine({ dockerHost: DOCKER_HOST });
2886
+ })();
2887
+
2704
2888
  server.listen(PORT, '0.0.0.0', () => {
2705
2889
  console.log(`olam-host-cp B3 listening on :${PORT}`);
2706
2890
  console.log(` DOCKER_HOST=${DOCKER_HOST}`);
2707
2891
  console.log(` cache TTL=${TTL_SEC}s`);
2708
2892
  console.log(` worlds known: ${Object.keys(WORLDS).join(', ') || '(none)'}`);
2709
2893
  console.log(` mode=${HOST_CP_MODE} world-host=${HOST_FOR_WORLD}`);
2894
+ console.log(` engine=${hostCpEngine.engineName}${hostCpEngine.context ? ` ctx=${hostCpEngine.context}` : ''}`);
2710
2895
  console.log(` (override: OLAM_HOST_CP_MODE=container|bare, OLAM_HOST_FOR_WORLD=<host>)`);
2711
2896
  // Surface the auth wiring state at boot. An empty OLAM_AUTH_SECRET
2712
2897
  // here is silently fatal for the credential surfaces — the operator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.144",
3
+ "version": "0.1.145",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"