@pleri/olam-cli 0.1.143 → 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.
- package/dist/commands/doctor.d.ts +53 -23
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +117 -46
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/logs.d.ts +17 -3
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +38 -35
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/memory/bridge.d.ts +57 -0
- package/dist/commands/memory/bridge.d.ts.map +1 -0
- package/dist/commands/memory/bridge.js +156 -0
- package/dist/commands/memory/bridge.js.map +1 -0
- package/dist/commands/memory/index.d.ts +3 -0
- package/dist/commands/memory/index.d.ts.map +1 -1
- package/dist/commands/memory/index.js +10 -1
- package/dist/commands/memory/index.js.map +1 -1
- package/dist/commands/memory/reclassify.d.ts +56 -0
- package/dist/commands/memory/reclassify.d.ts.map +1 -0
- package/dist/commands/memory/reclassify.js +177 -0
- package/dist/commands/memory/reclassify.js.map +1 -0
- package/dist/commands/memory/stats.d.ts +69 -0
- package/dist/commands/memory/stats.d.ts.map +1 -0
- package/dist/commands/memory/stats.js +164 -0
- package/dist/commands/memory/stats.js.map +1 -0
- package/dist/commands/skills-source.d.ts +12 -0
- package/dist/commands/skills-source.d.ts.map +1 -0
- package/dist/commands/skills-source.js +133 -0
- package/dist/commands/skills-source.js.map +1 -0
- package/dist/commands/skills.d.ts +11 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +163 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/status.d.ts +27 -0
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +102 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/upgrade.d.ts +24 -0
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +73 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/image-digests.json +7 -7
- package/dist/index.js +2093 -538
- package/dist/index.js.map +1 -1
- package/dist/lib/health-probes.d.ts +72 -0
- package/dist/lib/health-probes.d.ts.map +1 -1
- package/dist/lib/health-probes.js +218 -0
- package/dist/lib/health-probes.js.map +1 -1
- package/dist/mcp-server.js +1248 -353
- package/host-cp/src/agent-runtime-trigger.mjs +262 -0
- package/host-cp/src/engine-identity.mjs +32 -0
- package/host-cp/src/server.mjs +246 -2
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// agent-runtime-trigger — Phase B B7 (minimum-demo cut) host-side launch hook.
|
|
2
|
+
//
|
|
3
|
+
// When the SPA opens the plan-tab for a (worldId, sessionId), it POSTs
|
|
4
|
+
// here; host-cp idempotently spawns the agent-stream-launch supervisor
|
|
5
|
+
// inside the world's devbox container via `docker exec`. The supervisor
|
|
6
|
+
// (PID 1 within the spawned exec session) then fork-spawns driver +
|
|
7
|
+
// codex runners that long-poll host-cp's /v1/shape.
|
|
8
|
+
//
|
|
9
|
+
// Demo-cut simplifications (per minimum-demo decision; full B7 in follow-up):
|
|
10
|
+
// - In-memory idempotency map keyed by `(worldId, sessionId)`. Restart of
|
|
11
|
+
// host-cp loses state; second call after restart re-issues docker exec,
|
|
12
|
+
// which the supervisor's idempotency check (B6-full's flock + PID-file)
|
|
13
|
+
// would catch. B6-minimum has no such check → restart of host-cp +
|
|
14
|
+
// re-trigger may spawn two supervisors. Acceptable for single-operator
|
|
15
|
+
// local demo; full B7 + B6-full close this.
|
|
16
|
+
// - Uses shared-secret bearer (from `~/.olam/plan-chat-secret` per the
|
|
17
|
+
// existing plan-chat-service contract). JWT scope-claim migration is B9.
|
|
18
|
+
// - No conversation_id ↔ (worldId, sessionId) join-table (A1.4
|
|
19
|
+
// §migration-schema open question). For demo, the supervisor is
|
|
20
|
+
// keyed by (worldId, sessionId) directly; codex's APPROVE chunks
|
|
21
|
+
// write under (worldId, sessionId) — `conversation_id` plumbing
|
|
22
|
+
// deferred until lookouts (B3) need it.
|
|
23
|
+
// - No host-cp restart cleanup of dead supervisor entries (the in-memory
|
|
24
|
+
// map only tracks live spawns; container crash + re-trigger DOES
|
|
25
|
+
// re-spawn).
|
|
26
|
+
//
|
|
27
|
+
// Source: docs/design/olam-plan-chat-agent-runtime.md `lifecycle` +
|
|
28
|
+
// `bake-in-seam` sections, minimum-demo cut.
|
|
29
|
+
|
|
30
|
+
import { spawnSync, spawn } from 'node:child_process';
|
|
31
|
+
|
|
32
|
+
const SPAWN_TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
34
|
+
// Default container-side path for the supervisor binary. The devbox image
|
|
35
|
+
// COPYs `packages/intelligence/dist/agent-stream/` to this location during
|
|
36
|
+
// build (devbox Dockerfile update lands alongside this PR or in a follow-up).
|
|
37
|
+
// Compiled supervisor lives at /opt/olam/agent-stream/dist/agent-stream-launch.js
|
|
38
|
+
// per the devbox runtime Dockerfile build-in-image step (tsc writes dist/).
|
|
39
|
+
const DEFAULT_SUPERVISOR_PATH = '/opt/olam/agent-stream/dist/agent-stream-launch.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} TriggerArgs
|
|
43
|
+
* @property {string} worldId
|
|
44
|
+
* @property {string} sessionId
|
|
45
|
+
* @property {string} hostCpUrl — URL the container reaches host-cp at
|
|
46
|
+
* (e.g. `http://host.docker.internal:3112`)
|
|
47
|
+
* @property {string} bearer — shared-secret token (read from
|
|
48
|
+
* `~/.olam/plan-chat-secret` server-side; never passed in from SPA)
|
|
49
|
+
* @property {string} [dockerHost='docker-cli'] — `'docker-cli'` for bare-node
|
|
50
|
+
* mode; `tcp://...` for container mode (docker-socket-proxy)
|
|
51
|
+
* @property {string} [supervisorPath] — override for tests
|
|
52
|
+
* @property {(cmd: string, args: string[], opts?: object) => any} [spawnSyncImpl]
|
|
53
|
+
* — injectable for tests; defaults to node:child_process spawnSync
|
|
54
|
+
* @property {(cmd: string, args: string[], opts?: object) => any} [spawnImpl]
|
|
55
|
+
* — injectable for tests; defaults to node:child_process spawn (detached)
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Internal state: which `(worldId, sessionId)` pairs we've already
|
|
60
|
+
* spawned. Survives only within a single host-cp process instance.
|
|
61
|
+
*
|
|
62
|
+
* @type {Map<string, {spawnedAt: number, pid?: number}>}
|
|
63
|
+
*/
|
|
64
|
+
const liveSpawns = new Map();
|
|
65
|
+
|
|
66
|
+
/** @param {string} worldId @param {string} sessionId */
|
|
67
|
+
function key(worldId, sessionId) {
|
|
68
|
+
return `${worldId}::${sessionId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Idempotently spawn the agent-stream supervisor inside the world's container.
|
|
73
|
+
*
|
|
74
|
+
* Returns `{status: 'spawned' | 'already-running', container, pid?}`.
|
|
75
|
+
* Throws on docker-CLI failure or container-not-running.
|
|
76
|
+
*
|
|
77
|
+
* @param {TriggerArgs} args
|
|
78
|
+
*/
|
|
79
|
+
export async function triggerAgentRuntime(args) {
|
|
80
|
+
const {
|
|
81
|
+
worldId,
|
|
82
|
+
sessionId,
|
|
83
|
+
hostCpUrl,
|
|
84
|
+
bearer,
|
|
85
|
+
dockerHost = 'docker-cli',
|
|
86
|
+
supervisorPath = DEFAULT_SUPERVISOR_PATH,
|
|
87
|
+
spawnSyncImpl = spawnSync,
|
|
88
|
+
spawnImpl = spawn,
|
|
89
|
+
} = args;
|
|
90
|
+
|
|
91
|
+
if (!worldId || !sessionId || !hostCpUrl || !bearer) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'triggerAgentRuntime: worldId, sessionId, hostCpUrl, bearer all required',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const k = key(worldId, sessionId);
|
|
98
|
+
if (liveSpawns.has(k)) {
|
|
99
|
+
const entry = liveSpawns.get(k);
|
|
100
|
+
return {
|
|
101
|
+
status: 'already-running',
|
|
102
|
+
container: `olam-${worldId}-devbox`,
|
|
103
|
+
spawnedAt: entry.spawnedAt,
|
|
104
|
+
pid: entry.pid,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const containerName = `olam-${worldId}-devbox`;
|
|
109
|
+
|
|
110
|
+
// Bare-node mode: shell out to docker exec --detach (or background
|
|
111
|
+
// via & in a wrapper command). Detached so the SPA's HTTP request
|
|
112
|
+
// returns promptly; the supervisor lives until SIGTERM.
|
|
113
|
+
if (dockerHost === 'docker-cli') {
|
|
114
|
+
// First, verify the container exists and is running. `docker inspect`
|
|
115
|
+
// returns exit 1 if the container is not found; exit 0 with stdout
|
|
116
|
+
// containing the state if found.
|
|
117
|
+
const inspect = spawnSyncImpl(
|
|
118
|
+
'docker',
|
|
119
|
+
['inspect', '--format', '{{.State.Running}}', containerName],
|
|
120
|
+
{ encoding: 'utf-8', timeout: SPAWN_TIMEOUT_MS },
|
|
121
|
+
);
|
|
122
|
+
if (inspect.error) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`docker inspect ${containerName} failed: ${inspect.error.message}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (inspect.status !== 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`docker inspect ${containerName} exit ${inspect.status}: ${(inspect.stderr || '').trim()}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if ((inspect.stdout || '').trim() !== 'true') {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`container ${containerName} is not running (state: ${(inspect.stdout || '').trim()})`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Use docker exec --detach to spawn the supervisor in the background.
|
|
139
|
+
// -e flags inject the runtime env; the supervisor binary path is the
|
|
140
|
+
// last positional argument.
|
|
141
|
+
const env = {
|
|
142
|
+
HOST_CP_URL: hostCpUrl,
|
|
143
|
+
HOST_CP_BEARER: bearer,
|
|
144
|
+
WORLD_ID: worldId,
|
|
145
|
+
SESSION_ID: sessionId,
|
|
146
|
+
};
|
|
147
|
+
const execArgs = ['exec', '--detach'];
|
|
148
|
+
for (const [k_, v] of Object.entries(env)) {
|
|
149
|
+
execArgs.push('-e', `${k_}=${v}`);
|
|
150
|
+
}
|
|
151
|
+
execArgs.push(containerName, 'node', supervisorPath);
|
|
152
|
+
|
|
153
|
+
const detached = spawnImpl('docker', execArgs, {
|
|
154
|
+
stdio: 'ignore',
|
|
155
|
+
detached: true,
|
|
156
|
+
});
|
|
157
|
+
detached.unref?.();
|
|
158
|
+
|
|
159
|
+
liveSpawns.set(k, { spawnedAt: Date.now(), pid: detached.pid });
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
status: 'spawned',
|
|
163
|
+
container: containerName,
|
|
164
|
+
pid: detached.pid,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
|
|
241
|
+
throw new Error(
|
|
242
|
+
`triggerAgentRuntime: unsupported dockerHost mode '${dockerHost}'`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Test-only: clear the in-memory live-spawns map.
|
|
248
|
+
* Production code should NEVER call this — it would let a duplicate
|
|
249
|
+
* supervisor spawn.
|
|
250
|
+
*/
|
|
251
|
+
export function _clearLiveSpawnsForTests() {
|
|
252
|
+
liveSpawns.clear();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Inspect-only: read the current live-spawns map (for observability).
|
|
257
|
+
*
|
|
258
|
+
* @returns {ReadonlyMap<string, {spawnedAt: number, pid?: number}>}
|
|
259
|
+
*/
|
|
260
|
+
export function getLiveSpawns() {
|
|
261
|
+
return new Map(liveSpawns);
|
|
262
|
+
}
|
|
@@ -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
|
+
}
|
package/host-cp/src/server.mjs
CHANGED
|
@@ -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 {
|
|
@@ -56,6 +57,8 @@ import { createPylonWorldsSource } from './pylon-worlds-source.mjs';
|
|
|
56
57
|
import { composeWorldsSources } from './compose-worlds-sources.mjs';
|
|
57
58
|
import { createWorldPrStateStore } from './world-pr-state.mjs';
|
|
58
59
|
import { PlanOrchestrator } from './plan-orchestrator.mjs';
|
|
60
|
+
import { triggerAgentRuntime } from './agent-runtime-trigger.mjs';
|
|
61
|
+
import { readSecret as readPlanChatSecret, SECRET_PATH as PLAN_CHAT_SECRET_PATH } from './plan-chat-secret.mjs';
|
|
59
62
|
import { createPrMergePoller } from './pr-merge-poller.mjs';
|
|
60
63
|
import { parse as parseYaml } from 'yaml';
|
|
61
64
|
import { startWorldsDbReconciler } from './worlds-db-source.mjs';
|
|
@@ -86,6 +89,11 @@ const HOST_CP_MODE = process.env.OLAM_HOST_CP_MODE
|
|
|
86
89
|
?? (fs.existsSync('/.dockerenv') ? 'container' : 'bare');
|
|
87
90
|
const WORLD_HOST = HOST_CP_MODE === 'container' ? 'host.docker.internal' : '127.0.0.1';
|
|
88
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
|
+
|
|
89
97
|
const PORT = parseInt(process.env.OLAM_HOST_CP_PORT ?? '19000', 10);
|
|
90
98
|
// In container mode the host-cp talks to the docker daemon via the
|
|
91
99
|
// socket-proxy sidecar (the proxy enforces the read-only API allow-list).
|
|
@@ -662,7 +670,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
662
670
|
// /health: fast diagnostics, no auth, no proxying. Docker healthcheck
|
|
663
671
|
// hits this; SPA pre-load may also poll. Stays unauth so the container
|
|
664
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.
|
|
665
678
|
if (url.pathname === '/health') {
|
|
679
|
+
res.setHeader('X-Olam-Engine', HOST_CP_ENGINE);
|
|
666
680
|
return jsonReply(res, 200, {
|
|
667
681
|
status: 'ok',
|
|
668
682
|
phase: 'B4',
|
|
@@ -723,6 +737,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
723
737
|
if (served) return;
|
|
724
738
|
}
|
|
725
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
|
|
726
747
|
// ALL OTHER ROUTES require auth. Reject with 401 if neither cookie
|
|
727
748
|
// nor Bearer header matches.
|
|
728
749
|
if (!auth.isAuthorized(req)) {
|
|
@@ -1861,6 +1882,123 @@ const server = http.createServer(async (req, res) => {
|
|
|
1861
1882
|
return jsonReply(res, 200, { signals });
|
|
1862
1883
|
}
|
|
1863
1884
|
|
|
1885
|
+
// POST /api/plan/agent-runtime/trigger — Phase B B7 (minimum-demo cut).
|
|
1886
|
+
// SPA POSTs here when /session/<worldId>/plan opens; host-cp idempotently
|
|
1887
|
+
// docker-execs the agent-stream-launch supervisor inside the world's
|
|
1888
|
+
// devbox container. Body: { worldId, sessionId }. Returns the spawn
|
|
1889
|
+
// status + container name + supervisor pid.
|
|
1890
|
+
//
|
|
1891
|
+
// Source: docs/design/olam-plan-chat-agent-runtime.md `lifecycle` +
|
|
1892
|
+
// `bake-in-seam` sections.
|
|
1893
|
+
if (url.pathname === '/api/plan/agent-runtime/trigger' && req.method === 'POST') {
|
|
1894
|
+
if (!await requirePlanCredential(res)) return;
|
|
1895
|
+
let body;
|
|
1896
|
+
try {
|
|
1897
|
+
body = await readRequestBody(req);
|
|
1898
|
+
} catch (err) {
|
|
1899
|
+
return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
|
|
1900
|
+
}
|
|
1901
|
+
if (!body.worldId || !body.sessionId) {
|
|
1902
|
+
return jsonReply(res, 400, {
|
|
1903
|
+
error: 'missing_fields',
|
|
1904
|
+
message: 'worldId and sessionId required',
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
// Bearer for the container-side runners: shared-secret from
|
|
1908
|
+
// ~/.olam/plan-chat-secret (matches plan-chat-service.mjs auth).
|
|
1909
|
+
// JWT scope-claim migration is B9 / post-demo.
|
|
1910
|
+
let bearer;
|
|
1911
|
+
try {
|
|
1912
|
+
bearer = readPlanChatSecret();
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
return jsonReply(res, 503, {
|
|
1915
|
+
error: 'plan_chat_secret_unavailable',
|
|
1916
|
+
message: `plan-chat-secret unreadable at ${PLAN_CHAT_SECRET_PATH}: ${err.message}`,
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
// host-cp's URL as seen from inside the devbox container. Bare-node
|
|
1920
|
+
// mode (docker-cli) uses host.docker.internal; container mode uses
|
|
1921
|
+
// the host-cp service name. Default: host.docker.internal for the
|
|
1922
|
+
// operator-local demo flow.
|
|
1923
|
+
const hostCpUrlForContainer =
|
|
1924
|
+
process.env.OLAM_AGENT_RUNTIME_HOST_CP_URL ?? 'http://host.docker.internal:3112';
|
|
1925
|
+
try {
|
|
1926
|
+
const result = await triggerAgentRuntime({
|
|
1927
|
+
worldId: body.worldId,
|
|
1928
|
+
sessionId: body.sessionId,
|
|
1929
|
+
hostCpUrl: hostCpUrlForContainer,
|
|
1930
|
+
bearer,
|
|
1931
|
+
dockerHost: DOCKER_HOST,
|
|
1932
|
+
});
|
|
1933
|
+
return jsonReply(res, 200, result);
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
return jsonReply(res, 500, {
|
|
1936
|
+
error: 'agent_runtime_trigger_failed',
|
|
1937
|
+
message: err.message,
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
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
|
+
|
|
1864
2002
|
// GET /api/worlds/:id/processes
|
|
1865
2003
|
// GET /api/worlds/:id/processes/stream — SSE fanout (5s cadence, per-world)
|
|
1866
2004
|
// Handler: routes/process-port.mjs → handleListProcesses
|
|
@@ -1933,6 +2071,85 @@ const server = http.createServer(async (req, res) => {
|
|
|
1933
2071
|
}
|
|
1934
2072
|
}
|
|
1935
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
|
+
|
|
1936
2153
|
// Anything else → 404. B4 ships static SPA serving + auth.
|
|
1937
2154
|
jsonReply(res, 404, {
|
|
1938
2155
|
error: 'not_found',
|
|
@@ -2318,7 +2535,7 @@ const DIST_DIR = (() => {
|
|
|
2318
2535
|
})();
|
|
2319
2536
|
|
|
2320
2537
|
const SPA_ROUTES = new Set(['/', '/worlds', '/workspaces', '/inbox', '/repos', '/runbooks']);
|
|
2321
|
-
const SPA_PREFIX = ['/world/', '/worlds/', '/workspaces/', '/inbox/', '/repos/', '/runbooks/'];
|
|
2538
|
+
const SPA_PREFIX = ['/world/', '/worlds/', '/workspaces/', '/inbox/', '/repos/', '/runbooks/', '/session/'];
|
|
2322
2539
|
|
|
2323
2540
|
// Top-level path segments that are NOT world IDs. Mirrors RESERVED_SEGMENTS
|
|
2324
2541
|
// in lib/worldId.ts — keep in sync.
|
|
@@ -2536,7 +2753,7 @@ let _spaCacheMtime = 0;
|
|
|
2536
2753
|
// and the token-comparison check skips reload when the cookie
|
|
2537
2754
|
// already matches (so non-rotation 401s — e.g. genuine auth
|
|
2538
2755
|
// failures — don't cause a refresh loop).
|
|
2539
|
-
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>`;
|
|
2540
2757
|
|
|
2541
2758
|
async function renderSpaShell(filePath) {
|
|
2542
2759
|
const stat = fs.statSync(filePath);
|
|
@@ -2642,12 +2859,39 @@ startWorldsSnapshotLoop();
|
|
|
2642
2859
|
startTunnelsSnapshotLoop();
|
|
2643
2860
|
startListeningSnapshotLoop();
|
|
2644
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
|
+
|
|
2645
2888
|
server.listen(PORT, '0.0.0.0', () => {
|
|
2646
2889
|
console.log(`olam-host-cp B3 listening on :${PORT}`);
|
|
2647
2890
|
console.log(` DOCKER_HOST=${DOCKER_HOST}`);
|
|
2648
2891
|
console.log(` cache TTL=${TTL_SEC}s`);
|
|
2649
2892
|
console.log(` worlds known: ${Object.keys(WORLDS).join(', ') || '(none)'}`);
|
|
2650
2893
|
console.log(` mode=${HOST_CP_MODE} world-host=${HOST_FOR_WORLD}`);
|
|
2894
|
+
console.log(` engine=${hostCpEngine.engineName}${hostCpEngine.context ? ` ctx=${hostCpEngine.context}` : ''}`);
|
|
2651
2895
|
console.log(` (override: OLAM_HOST_CP_MODE=container|bare, OLAM_HOST_FOR_WORLD=<host>)`);
|
|
2652
2896
|
// Surface the auth wiring state at boot. An empty OLAM_AUTH_SECRET
|
|
2653
2897
|
// here is silently fatal for the credential surfaces — the operator
|