@pleri/olam-cli 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. package/package.json +38 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared output helpers for consistent CLI formatting.
3
+ */
4
+ export declare function printError(message: string): void;
5
+ export declare function printSuccess(message: string): void;
6
+ export declare function printWarning(message: string): void;
7
+ export declare function printInfo(label: string, value: string): void;
8
+ export declare function printHeader(title: string): void;
9
+ export declare function formatAge(createdAt: string): string;
10
+ //# sourceMappingURL=output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output.d.ts","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAEhD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAElD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE/C;AAED,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQnD"}
package/dist/output.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared output helpers for consistent CLI formatting.
3
+ */
4
+ import pc from 'picocolors';
5
+ export function printError(message) {
6
+ console.error(`${pc.red('error')} ${message}`);
7
+ }
8
+ export function printSuccess(message) {
9
+ console.log(`${pc.green('ok')} ${message}`);
10
+ }
11
+ export function printWarning(message) {
12
+ console.log(`${pc.yellow('warn')} ${message}`);
13
+ }
14
+ export function printInfo(label, value) {
15
+ console.log(` ${pc.dim(label.padEnd(14))} ${value}`);
16
+ }
17
+ export function printHeader(title) {
18
+ console.log(`\n${pc.bold(title)}`);
19
+ }
20
+ export function formatAge(createdAt) {
21
+ const ms = Date.now() - new Date(createdAt).getTime();
22
+ const minutes = Math.floor(ms / 60_000);
23
+ if (minutes < 60)
24
+ return `${minutes}m`;
25
+ const hours = Math.floor(minutes / 60);
26
+ if (hours < 24)
27
+ return `${hours}h ${minutes % 60}m`;
28
+ const days = Math.floor(hours / 24);
29
+ return `${days}d ${hours % 24}h`;
30
+ }
31
+ //# sourceMappingURL=output.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"output.js","sourceRoot":"","sources":["../src/output.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AAC9C,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,OAAe;IAC1C,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,KAAa;IACpD,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC;AACxD,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AACrC,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,SAAiB;IACzC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,CAAC;IACtD,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,MAAM,CAAC,CAAC;IACxC,IAAI,OAAO,GAAG,EAAE;QAAE,OAAO,GAAG,OAAO,GAAG,CAAC;IACvC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC;IACvC,IAAI,KAAK,GAAG,EAAE;QAAE,OAAO,GAAG,KAAK,KAAK,OAAO,GAAG,EAAE,GAAG,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;IACpC,OAAO,GAAG,IAAI,KAAK,KAAK,GAAG,EAAE,GAAG,CAAC;AACnC,CAAC"}
@@ -0,0 +1,126 @@
1
+ # Phase F-2-B (B2): olam-host-cp compose stack.
2
+ #
3
+ # Two services on a private internal network:
4
+ #
5
+ # 1. host-cp — the SPA proxy server (B3+ implementation). Exposes
6
+ # port 19000 to the operator's host. Talks to the
7
+ # docker-socket-proxy via `tcp://docker-socket-proxy:2375`
8
+ # (NOT the raw /var/run/docker.sock).
9
+ #
10
+ # 2. docker-socket-proxy
11
+ # — tecnativa/docker-socket-proxy sidecar. Mounts the
12
+ # real /var/run/docker.sock read-only and exposes a
13
+ # whitelisted subset of the Docker API. Whitelist:
14
+ # CONTAINERS=1 — list/inspect (find world IDs)
15
+ # EVENTS=1 — stream restart/stop events
16
+ # (cache invalidation; B3 / T2)
17
+ # EXEC=1 — exec inside containers
18
+ # (read /tmp/olam-container-secret)
19
+ # Everything else is denied (images, volumes,
20
+ # networks, swarm, build, push, etc.). T6 + T8
21
+ # mitigation: blast-radius reduction vs raw socket.
22
+ #
23
+ # Bring up: `docker compose -f packages/host-cp/compose.yaml up --build -d`
24
+ # Tear down: `docker compose -f packages/host-cp/compose.yaml down`
25
+
26
+ services:
27
+ host-cp:
28
+ container_name: olam-host-cp
29
+ build:
30
+ context: .
31
+ dockerfile: Dockerfile
32
+ image: olam-host-cp:latest
33
+ ports:
34
+ # Bind to 127.0.0.1 only — single-user-per-host assumption (T4).
35
+ # Multi-user / TLS / remote access lands in Phase G+.
36
+ - "127.0.0.1:19000:19000"
37
+ environment:
38
+ # Connection string for docker-socket-proxy. The proxy listens on
39
+ # tcp://0.0.0.0:2375 inside the internal network. host-cp uses
40
+ # this to enumerate worlds (containers list) + read secrets
41
+ # (containers exec) + subscribe to restart events.
42
+ DOCKER_HOST: "tcp://docker-socket-proxy:2375"
43
+ # Phase F-2-B M2 ship gate: secret cache TTL (5min, demoted from
44
+ # 1h per D2). B3 reads this; B10's m2-cache-invalidate.sh tests
45
+ # the docker-events invalidation path.
46
+ OLAM_SECRET_CACHE_TTL_SEC: "300"
47
+ # Bind operator-facing UI port. Always 19000 in compose.
48
+ OLAM_HOST_CP_PORT: "19000"
49
+ # Token + workspace + world registry mount points. Bind-mounted
50
+ # below; host CP reads these at boot.
51
+ OLAM_HOST_CP_TOKEN_PATH: "/data/host-cp.token"
52
+ OLAM_WORKSPACES_DIR: "/data/workspaces"
53
+ OLAM_WORLDS_DB: "/data/worlds.db"
54
+ OLAM_PR_POLL_INTERVAL_MS: "300000"
55
+ OLAM_MERGE_GRACE_MS: "600000"
56
+ # Version detection: path to the operator's olam repo checkout.
57
+ # host-cp reads .git/refs/heads/main (or the active branch) to
58
+ # determine "latest" SHA. Defaults to $HOME/Projects/ein-sof/olam;
59
+ # override with OLAM_REPO_PATH env var before `docker compose up`.
60
+ OLAM_REPO_PATH: "${OLAM_REPO_PATH:-}"
61
+ # Auth-service inter-service auth. The secret is shared with the
62
+ # long-lived olam-auth container (generated on first `olam auth
63
+ # up` at ~/.olam/auth-secret). Without it, X-Olam-Secret is never
64
+ # sent and auth-service 401s every host-cp → /credentials/* call,
65
+ # which surfaces in the dashboard as a failed Connect Claude flow.
66
+ OLAM_AUTH_SERVICE_URL: "http://host.docker.internal:9999"
67
+ OLAM_AUTH_SECRET: "${OLAM_AUTH_SECRET:-}"
68
+ volumes:
69
+ # ~/.olam/ from operator's home → /data/ inside container. B4
70
+ # writes the startup token here (chmod 600). B6 reads workspaces
71
+ # + worlds.db from here. ~/.olam/ is the canonical operator-state
72
+ # directory established by the Olam CLI; consistent with the
73
+ # devbox container's mount layout.
74
+ - ${HOME}/.olam:/data
75
+ - ${HOME}/.config/gh:/gh-config:ro
76
+ # Operator's olam repo mounted read-only so host-cp can poll
77
+ # .git/refs/heads/main to detect when a new version is available.
78
+ # The path inside the container is always /operator-repo.
79
+ # On the host: OLAM_REPO_PATH env var, or defaults to
80
+ # $HOME/Projects/ein-sof/olam. If the path doesn't exist, the
81
+ # mount is a no-op and version detection reports "operator-repo not mounted".
82
+ - ${OLAM_REPO_PATH:-${HOME}/Projects/ein-sof/olam}:/operator-repo:ro
83
+ depends_on:
84
+ docker-socket-proxy:
85
+ condition: service_started
86
+ networks:
87
+ - olam-host-cp-internal
88
+ restart: unless-stopped
89
+
90
+ docker-socket-proxy:
91
+ container_name: olam-docker-socket-proxy
92
+ # Pin to a specific tag, not :latest. Update via Renovate / dependabot.
93
+ # tecnativa/docker-socket-proxy:0.3.0 (2024-10-22) — last tagged
94
+ # release as of plan-pass-2 emit. T8 mitigation: pinning prevents
95
+ # supply-chain drift on the sidecar.
96
+ image: tecnativa/docker-socket-proxy:0.3.0
97
+ environment:
98
+ # Whitelist matches plan D5 + T6/T8: host CP needs exactly these
99
+ # four operations. EVERYTHING else stays at the proxy default
100
+ # (deny). Audit periodically; widen with explicit justification.
101
+ CONTAINERS: "1"
102
+ EVENTS: "1"
103
+ EXEC: "1"
104
+ # tecnativa/docker-socket-proxy 0.3.0 requires POST=1 to allow
105
+ # POST verbs on whitelisted endpoints (exec creation requires
106
+ # POST /containers/<id>/exec + POST /exec/<id>/start). Phase
107
+ # F-2-D dogfood revealed the missing perm.
108
+ POST: "1"
109
+ # Optional: lower log verbosity. Default is INFO; DEBUG floods
110
+ # logs in dev. Comment out for troubleshooting.
111
+ LOG_LEVEL: "warning"
112
+ volumes:
113
+ # Mount the host's docker socket READ-ONLY. The proxy is the only
114
+ # consumer of the raw socket. host-cp talks to the proxy over
115
+ # TCP (port 2375 on the internal network).
116
+ - /var/run/docker.sock:/var/run/docker.sock:ro
117
+ networks:
118
+ - olam-host-cp-internal
119
+ restart: unless-stopped
120
+
121
+ networks:
122
+ olam-host-cp-internal:
123
+ name: olam-host-cp-internal
124
+ driver: bridge
125
+ # Internal-only: no host port published; host-cp <-> proxy traffic
126
+ # never leaves the docker network.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Operator-facing diagnostic for auth-service authentication failures.
3
+ *
4
+ * Pre-fix, an empty OLAM_AUTH_SECRET (compose.yaml's
5
+ * `${OLAM_AUTH_SECRET:-}` interpolation when the operator's shell
6
+ * didn't export it) silently 401'd every host-cp → auth-service
7
+ * call. The SPA showed "0 credentials" with no log line explaining
8
+ * why. Logging a clear hint — both at boot when the env var is empty
9
+ * AND on the first runtime 401 — turns a silent footgun into a
10
+ * grep-able warning.
11
+ *
12
+ * Lives in its own file (not server.mjs) so unit tests can import it
13
+ * without triggering server.mjs's top-level mkdir + http.listen side
14
+ * effects.
15
+ */
16
+
17
+ /**
18
+ * @param {object} ctx
19
+ * @param {string} ctx.authServiceUrl
20
+ * The configured auth-service base URL — quoted back to the operator
21
+ * so they can cross-reference with their compose env.
22
+ * @param {boolean} ctx.hasSecret
23
+ * True when host-cp's OLAM_AUTH_SECRET is set (and the 401 means a
24
+ * value mismatch); false when it's empty (the original silent-fail
25
+ * regression mode).
26
+ * @returns {string}
27
+ * A single-line message safe for `console.warn` / docker-compose-logs.
28
+ */
29
+ export function authSecretHint({ authServiceUrl, hasSecret }) {
30
+ if (!hasSecret) {
31
+ return (
32
+ `[auth] auth-service at ${authServiceUrl} is configured but ` +
33
+ `OLAM_AUTH_SECRET is empty — every credentials/* call will 401. ` +
34
+ `Set the env var to the contents of ~/.olam/auth-secret (or run ` +
35
+ `'olam host-cp start' so the CLI loads it for you).`
36
+ );
37
+ }
38
+ return (
39
+ `[auth] auth-service at ${authServiceUrl} returned 401 even though ` +
40
+ `OLAM_AUTH_SECRET is set — the secret does NOT match the value the ` +
41
+ `auth-service container is using. Check that both containers were ` +
42
+ `started from the same ~/.olam/auth-secret file and recreate them ` +
43
+ `together if the file changed.`
44
+ );
45
+ }
@@ -0,0 +1,155 @@
1
+ // Phase F-2-B (B4): startup-token authentication for host CP.
2
+ //
3
+ // On boot: generate a 32-byte hex token (or reuse the file if it
4
+ // exists), write to `~/.olam/host-cp.token` with mode 0600, cache in
5
+ // memory. Middleware on all non-static, non-bootstrap routes validates
6
+ // the request via:
7
+ // - Cookie `olam_host_cp_token=<value>`
8
+ // - OR Authorization: Bearer <value>
9
+ // Reject 401 if neither matches.
10
+ //
11
+ // Threat model (T4 mitigation):
12
+ // - Bound to 127.0.0.1:19000 only (compose.yaml). No public exposure.
13
+ // - Single-user-per-host assumption; multi-user is Phase G+.
14
+ // - Token file is chmod 600 owned by the operator. Browser tabs on
15
+ // the same machine that try to hit :19000 are blocked unless they
16
+ // have the token (cookie or header).
17
+ // - /api/bootstrap returns the token unauthenticated. Rationale:
18
+ // anything local that can hit 127.0.0.1:19000 can also read
19
+ // ~/.olam/host-cp.token (same OS-level privilege boundary). This
20
+ // just removes a UX friction step. NOT acceptable in multi-user
21
+ // mode (Phase G+ uses cookie-with-Secure+HttpOnly via real auth).
22
+
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ export class StartupToken {
28
+ /**
29
+ * @param {object} opts
30
+ * @param {string} opts.tokenPath absolute path to the token file
31
+ * @param {() => string} [opts.generate] defaults to 32-byte hex via crypto.randomBytes
32
+ * @param {(message: string) => void} [opts.log]
33
+ * @param {typeof fs} [opts.fs] injectable for tests
34
+ */
35
+ constructor({ tokenPath, generate, log = console.log, fs: fsImpl = fs }) {
36
+ if (!tokenPath || !path.isAbsolute(tokenPath)) {
37
+ throw new Error('StartupToken: tokenPath must be an absolute path');
38
+ }
39
+ this.tokenPath = tokenPath;
40
+ this.generate = generate ?? (() => crypto.randomBytes(32).toString('hex'));
41
+ this.log = log;
42
+ this.fs = fsImpl;
43
+ /** @type {string | null} */
44
+ this.token = null;
45
+ }
46
+
47
+ /**
48
+ * Ensure the token exists in memory + on disk. Call once at server
49
+ * boot before listen(). Idempotent: subsequent calls return the
50
+ * cached value.
51
+ *
52
+ * Behavior:
53
+ * - If tokenPath exists: read it, cache, return it. (Lifecycle
54
+ * CLI's `olam host-cp start` may have written the token before
55
+ * the container starts; we must reuse the operator-visible
56
+ * value, not regenerate it.)
57
+ * - Else: generate a new token, write file with mode 0600, return.
58
+ *
59
+ * @returns {string}
60
+ */
61
+ ensure() {
62
+ if (this.token) return this.token;
63
+ const dir = path.dirname(this.tokenPath);
64
+ if (!this.fs.existsSync(dir)) {
65
+ this.fs.mkdirSync(dir, { recursive: true });
66
+ }
67
+ if (this.fs.existsSync(this.tokenPath)) {
68
+ const raw = this.fs.readFileSync(this.tokenPath, 'utf-8').trim();
69
+ if (raw.length < 16) {
70
+ // Defensive: a too-short token is almost certainly a corrupted
71
+ // file. Regenerate rather than accept.
72
+ this.log(`auth: existing token at ${this.tokenPath} too short (${raw.length}); regenerating`);
73
+ this.token = this._writeNew();
74
+ } else {
75
+ this.token = raw;
76
+ this.log(`auth: reused existing token at ${this.tokenPath}`);
77
+ }
78
+ } else {
79
+ this.token = this._writeNew();
80
+ }
81
+ return this.token;
82
+ }
83
+
84
+ /** @private */
85
+ _writeNew() {
86
+ const t = this.generate();
87
+ this.fs.writeFileSync(this.tokenPath, t, { mode: 0o600 });
88
+ this.log(`auth: generated new token at ${this.tokenPath} (${t.length} chars)`);
89
+ return t;
90
+ }
91
+
92
+ /**
93
+ * Check request authorization. Constant-time comparison via
94
+ * crypto.timingSafeEqual prevents timing-side-channel leaks of the
95
+ * token's first-byte mismatches.
96
+ *
97
+ * @param {import('node:http').IncomingMessage} req
98
+ * @returns {boolean}
99
+ */
100
+ isAuthorized(req) {
101
+ if (!this.token) return false;
102
+
103
+ // Bearer header
104
+ const authHeader = req.headers['authorization'];
105
+ if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
106
+ const got = authHeader.slice('Bearer '.length).trim();
107
+ if (this._compare(got)) return true;
108
+ }
109
+
110
+ // Cookie
111
+ const cookieHeader = req.headers['cookie'];
112
+ if (typeof cookieHeader === 'string') {
113
+ const cookies = parseCookies(cookieHeader);
114
+ const got = cookies['olam_host_cp_token'];
115
+ if (got && this._compare(got)) return true;
116
+ }
117
+
118
+ return false;
119
+ }
120
+
121
+ /** @private */
122
+ _compare(got) {
123
+ if (!this.token) return false;
124
+ if (got.length !== this.token.length) return false;
125
+ try {
126
+ return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(this.token));
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Parse a Cookie request header into an object. Handles `; ` separators
135
+ * and `=` value-may-contain-equals (e.g., base64). Empty values + cookies
136
+ * without `=` are tolerated.
137
+ *
138
+ * @param {string} header
139
+ * @returns {Record<string, string>}
140
+ */
141
+ export function parseCookies(header) {
142
+ /** @type {Record<string, string>} */
143
+ const out = {};
144
+ for (const pair of header.split(';')) {
145
+ const trimmed = pair.trim();
146
+ if (!trimmed) continue;
147
+ const eq = trimmed.indexOf('=');
148
+ if (eq === -1) {
149
+ out[trimmed] = '';
150
+ } else {
151
+ out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
152
+ }
153
+ }
154
+ return out;
155
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Phase E4 (olam-dogfood-vision): WorldsSource composition + dedup.
3
+ *
4
+ * Runs every configured WorldsSource (E1) in parallel and dedupes by
5
+ * `id`. Source-array order expresses precedence: the LAST source to
6
+ * claim an id wins on collision. server.mjs (E4 wiring via
7
+ * `buildWorldsSources`) orders sources `[localSource, pylonSource]`
8
+ * so cloud-side metadata overrides local stubs when the Pylon SDK
9
+ * eventually returns real data for a world that's also docker-
10
+ * resident locally.
11
+ *
12
+ * The function is intentionally pure + dep-free (no env reads, no
13
+ * http, no module-level state) so vitest can drive it with two mock
14
+ * sources to assert dedup direction without spinning up the server.
15
+ *
16
+ * ## Failure-mode contract (CP3 audit follow-up — closes CRIT/HIGH-1+2)
17
+ *
18
+ * Robustness goals:
19
+ * 1. **One bad source must NOT take down the union.** Pylon SDK
20
+ * transient outages, auth errors, network blips — these MUST
21
+ * degrade to "cloud worlds missing this poll" rather than
22
+ * "/api/worlds endpoint hangs". Achieved via `Promise.allSettled`
23
+ * + per-source try/log/treat-as-empty.
24
+ * 2. **Slow sources MUST NOT extend wall time past the SPA poll
25
+ * cadence.** The SPA polls every 4s (Worlds.tsx:124); a Pylon
26
+ * `client.worlds.list()` that takes 8s would block, queue
27
+ * sockets, and pile up overlapping fetches. Achieved via
28
+ * per-source `Promise.race` with `timeoutMs` (default 2000ms,
29
+ * matching the existing docker-inspect timeout in
30
+ * fetchWorldServices). A timed-out source is treated as `[]` for
31
+ * this poll.
32
+ * 3. **A failing source must produce a log line, not a silent
33
+ * empty.** Operators need to see "[worlds-source] pylon-cloud
34
+ * list() failed: <err>" in the host-cp boot log so the
35
+ * degradation is observable.
36
+ *
37
+ * ## Dedup semantics on collision (CP3 audit follow-up — closes HIGH-4)
38
+ *
39
+ * Whole-record replacement (the pre-audit behavior) blanks fields the
40
+ * later source doesn't populate. Concrete example: Pylon returns
41
+ * `{services: undefined}` (or omits the field entirely) for a freshly-
42
+ * claimed world while Local has `{services: [4 entries]}`. Whole-
43
+ * record replacement would drop the local services array; the SPA
44
+ * would render the world with no clickable links until Pylon
45
+ * back-fills.
46
+ *
47
+ * Field-merge (the post-audit behavior): later source's defined
48
+ * fields override earlier; earlier source's fields are preserved
49
+ * where the later source omits them. `id` and `source` always come
50
+ * from the later source (the precedence contract). Implementation:
51
+ * `{ ...byId.get(id), ...world }` — ES spread skips own properties
52
+ * with value `undefined` only if the producer ELIDES them; explicit
53
+ * `field: undefined` does override. Therefore source authors should
54
+ * OMIT fields they don't manage rather than setting them to
55
+ * `undefined` / `[]`.
56
+ *
57
+ * @typedef {import('./worlds-source.mjs').WorldsSource} WorldsSource
58
+ * @typedef {import('./worlds-source.mjs').WorldSummary} WorldSummary
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} ComposeWorldsSourcesOptions
63
+ * @property {number} [timeoutMs=2000]
64
+ * Per-source timeout cap. A source whose `list()` doesn't resolve
65
+ * within this budget is treated as `[]` for this composition pass
66
+ * (logged at error level). Default matches the docker-inspect
67
+ * timeout used elsewhere in host-cp so the /api/worlds path's worst-
68
+ * case wall time stays bounded by it.
69
+ * @property {(sourceName: string, err: unknown) => void} [onSourceError]
70
+ * Invoked when a source rejects or times out. Defaults to
71
+ * `console.error('[worlds-source] <name> list() failed:', err)`.
72
+ * Tests inject a spy to assert log behavior without polluting
73
+ * stderr.
74
+ */
75
+
76
+ const DEFAULT_TIMEOUT_MS = 8000;
77
+
78
+ /**
79
+ * Per-source last-known-good cache. Keyed by source.name → WorldSummary[].
80
+ * When a source resolves successfully, its output is stored here. When a
81
+ * source rejects or times out, we fall back to the cached value so the
82
+ * dashboard shows stale data rather than blanking. Stale data self-heals
83
+ * on the next successful poll.
84
+ *
85
+ * Process-local, no TTL — the running server is authoritative. Tests that
86
+ * need a clean slate should call _resetLastKnownGoodCache().
87
+ *
88
+ * @type {Map<string, import('./worlds-source.mjs').WorldSummary[]>}
89
+ */
90
+ const _lastKnownGood = new Map();
91
+
92
+ /**
93
+ * Wraps a Promise in a per-source timeout race. The timeout error
94
+ * carries the source name so `onSourceError` can log it usefully.
95
+ *
96
+ * @template T
97
+ * @param {Promise<T>} promise
98
+ * @param {number} ms
99
+ * @param {string} sourceName
100
+ * @returns {Promise<T>}
101
+ */
102
+ function withTimeout(promise, ms, sourceName) {
103
+ /** @type {ReturnType<typeof setTimeout> | null} */
104
+ let timer = null;
105
+ const timeout = new Promise((_, reject) => {
106
+ timer = setTimeout(() => {
107
+ reject(new Error(`source "${sourceName}" timed out after ${ms}ms`));
108
+ }, ms);
109
+ });
110
+ return Promise.race([promise, timeout]).finally(() => {
111
+ if (timer !== null) clearTimeout(timer);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Reset the last-known-good cache. Exposed for tests only — call before
117
+ * each test that needs a clean slate.
118
+ */
119
+ export function _resetLastKnownGoodCache() {
120
+ _lastKnownGood.clear();
121
+ }
122
+
123
+ /**
124
+ * @param {WorldsSource[]} sources
125
+ * Sources to compose. Order expresses precedence: later wins.
126
+ * @param {ComposeWorldsSourcesOptions} [options]
127
+ * @returns {Promise<WorldSummary[]>}
128
+ * Deduped union of every source's `list()` output, keyed by `id`.
129
+ * On collision: fields from later source override earlier where
130
+ * defined; earlier fields preserved where later source omits them.
131
+ */
132
+ export async function composeWorldsSources(sources, options = {}) {
133
+ if (sources.length === 0) return [];
134
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
135
+ const onSourceError =
136
+ options.onSourceError ??
137
+ ((name, err) => {
138
+ console.error(`[worlds-source] ${name} list() failed:`, err);
139
+ });
140
+
141
+ const settled = await Promise.allSettled(
142
+ sources.map((s) => withTimeout(s.list(), timeoutMs, s.name)),
143
+ );
144
+
145
+ /** @type {Map<string, WorldSummary>} */
146
+ const byId = new Map();
147
+ for (let i = 0; i < settled.length; i++) {
148
+ const result = settled[i];
149
+ const source = sources[i];
150
+ let resolved;
151
+ if (result.status === 'rejected') {
152
+ onSourceError(source.name, result.reason);
153
+ const lkg = _lastKnownGood.get(source.name);
154
+ if (!lkg) continue;
155
+ resolved = lkg;
156
+ } else {
157
+ resolved = result.value;
158
+ _lastKnownGood.set(source.name, result.value);
159
+ }
160
+ for (const world of resolved) {
161
+ // Field-merge on collision: later source overrides earlier
162
+ // where defined; earlier preserved where later omits. Keeps
163
+ // local service-strip + host_port intact when Pylon claims a
164
+ // world but hasn't populated those fields yet.
165
+ const prior = byId.get(world.id);
166
+ byId.set(world.id, prior ? { ...prior, ...world } : world);
167
+ }
168
+ }
169
+ return [...byId.values()];
170
+ }