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