@pleri/olam-cli 0.1.174 → 0.1.180

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 (41) hide show
  1. package/README.md +19 -0
  2. package/bin/olam.cjs +22 -0
  3. package/dist/commands/flywheel/index.d.ts.map +1 -1
  4. package/dist/commands/flywheel/index.js +4 -0
  5. package/dist/commands/flywheel/index.js.map +1 -1
  6. package/dist/commands/flywheel/install-sessionstart-hook.d.ts +64 -0
  7. package/dist/commands/flywheel/install-sessionstart-hook.d.ts.map +1 -0
  8. package/dist/commands/flywheel/install-sessionstart-hook.js +197 -0
  9. package/dist/commands/flywheel/install-sessionstart-hook.js.map +1 -0
  10. package/dist/commands/flywheel/session-start.d.ts +26 -0
  11. package/dist/commands/flywheel/session-start.d.ts.map +1 -0
  12. package/dist/commands/flywheel/session-start.js +119 -0
  13. package/dist/commands/flywheel/session-start.js.map +1 -0
  14. package/dist/commands/host-cp.d.ts +0 -3
  15. package/dist/commands/host-cp.d.ts.map +1 -1
  16. package/dist/commands/host-cp.js +27 -2
  17. package/dist/commands/host-cp.js.map +1 -1
  18. package/dist/commands/yolo.d.ts +99 -0
  19. package/dist/commands/yolo.d.ts.map +1 -0
  20. package/dist/commands/yolo.js +377 -0
  21. package/dist/commands/yolo.js.map +1 -0
  22. package/dist/image-digests.json +8 -8
  23. package/dist/index.js +808 -207
  24. package/dist/index.js.map +1 -1
  25. package/dist/lib/auth-remote.d.ts +0 -17
  26. package/dist/lib/auth-remote.d.ts.map +1 -1
  27. package/dist/lib/auth-remote.js +6 -16
  28. package/dist/lib/auth-remote.js.map +1 -1
  29. package/dist/mcp-server.js +26 -0
  30. package/hermes-bundle/version.json +1 -1
  31. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  32. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  33. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  34. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  35. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  36. package/host-cp/src/bootstrap-selective.mjs +56 -0
  37. package/host-cp/src/plan-chat-service.mjs +51 -0
  38. package/host-cp/src/redirect.mjs +152 -0
  39. package/host-cp/src/resolver.mjs +121 -0
  40. package/host-cp/src/server.mjs +57 -16
  41. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ // resolver.mjs — Phase A A1 (plan-chat-spa-supersedes-control-plane).
2
+ //
3
+ // Disambiguates a single opaque :id supplied on /plan/:id between
4
+ //
5
+ // - a planning session (planning_sessions.session_id), or
6
+ // - a crystallized world (planning_artifacts.crystallized_world_id), or
7
+ // - unresolvable (returns {kind:'unresolved', canonical_id:null}).
8
+ //
9
+ // Used by plan-chat-spa's useResolveId hook (Phase A A2) so the SPA's
10
+ // cold-open path can mount the correct surface without trusting the
11
+ // id-shape (sentinel `sess_*` prefix is a hint, not authority — see
12
+ // plan-chat-spa-supersedes-control-plane.md K1 SEC-1).
13
+ //
14
+ // Single SQL query (UNION ALL) so resolution costs one round-trip even
15
+ // when the id misses both tables. Bearer auth + rate-limit live in the
16
+ // HTTP handler in plan-chat-service.mjs; this helper is pool-pure for
17
+ // unit testability.
18
+
19
+ /**
20
+ * Validate the resolver :id shape. Mirrors plan-chat-service.mjs's
21
+ * SCOPE_ID_RE; tightened to 6-80 chars so an enumeration attacker can't
22
+ * grind through 1-5 char shapes.
23
+ */
24
+ export const RESOLVE_ID_RE = /^[A-Za-z0-9._-]{6,80}$/;
25
+
26
+ /**
27
+ * Resolve an opaque id against the chunks substrate.
28
+ *
29
+ * @param {{ query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> }} pool
30
+ * A pg-shaped pool. Tests pass a stub; production passes pg.Pool.
31
+ * @param {string} id The candidate id.
32
+ * @returns {Promise<{ kind: 'session' | 'world' | 'unresolved', canonical_id: string | null }>}
33
+ */
34
+ export async function resolveId(pool, id) {
35
+ if (typeof id !== 'string' || !RESOLVE_ID_RE.test(id)) {
36
+ return { kind: 'unresolved', canonical_id: null };
37
+ }
38
+
39
+ // Single round-trip. Both branches return the same shape
40
+ // (kind, canonical_id) so PG can UNION ALL them without coercion.
41
+ //
42
+ // Session branch wins on tie (LIMIT 1 + session ordered first) — a
43
+ // session id colliding with a world id is unlikely in practice
44
+ // (worldId is the random docker name; sessionId is uuid-shaped),
45
+ // but the deterministic ordering closes the K1 collision risk
46
+ // surfaced in pass 3 review.
47
+ const sql = `
48
+ SELECT kind, canonical_id FROM (
49
+ SELECT 'session' AS kind, session_id AS canonical_id, 1 AS rank
50
+ FROM planning_sessions
51
+ WHERE session_id = $1
52
+ UNION ALL
53
+ SELECT 'world' AS kind, crystallized_world_id AS canonical_id, 2 AS rank
54
+ FROM planning_artifacts
55
+ WHERE crystallized_world_id = $1
56
+ ) AS resolved
57
+ ORDER BY rank
58
+ LIMIT 1
59
+ `;
60
+
61
+ const result = await pool.query(sql, [id]);
62
+ const row = result.rows && result.rows[0];
63
+ if (!row) return { kind: 'unresolved', canonical_id: null };
64
+
65
+ // Pool stub-friendly: tolerate column names emerging from pg's
66
+ // case-insensitive identifier handling.
67
+ const kind = row.kind ?? row.KIND;
68
+ const canonical_id = row.canonical_id ?? row.CANONICAL_ID;
69
+ if (kind !== 'session' && kind !== 'world') {
70
+ return { kind: 'unresolved', canonical_id: null };
71
+ }
72
+ if (typeof canonical_id !== 'string' || canonical_id.length === 0) {
73
+ return { kind: 'unresolved', canonical_id: null };
74
+ }
75
+ return { kind, canonical_id };
76
+ }
77
+
78
+ /**
79
+ * Token-bucket rate limiter, per bearer principal. Closes the brute-
80
+ * force enumeration vector that bearer auth alone leaves open (an
81
+ * authenticated caller could otherwise grind through ids at
82
+ * line-rate).
83
+ *
84
+ * 60 req/min per bearer. Single-process in-memory map (one host-cp
85
+ * per host); a multi-instance deployment would need a shared store,
86
+ * but plan-chat-service is single-tenant single-host by design.
87
+ */
88
+ export function createRateLimiter({
89
+ capacity = 60,
90
+ windowMs = 60_000,
91
+ now = () => Date.now(),
92
+ } = {}) {
93
+ const buckets = new Map(); // key -> { tokens, lastRefill }
94
+
95
+ function take(key) {
96
+ const t = now();
97
+ let bucket = buckets.get(key);
98
+ if (!bucket) {
99
+ bucket = { tokens: capacity, lastRefill: t };
100
+ buckets.set(key, bucket);
101
+ }
102
+ // Refill proportional to elapsed time.
103
+ const elapsed = t - bucket.lastRefill;
104
+ if (elapsed > 0) {
105
+ const refill = (elapsed / windowMs) * capacity;
106
+ bucket.tokens = Math.min(capacity, bucket.tokens + refill);
107
+ bucket.lastRefill = t;
108
+ }
109
+ if (bucket.tokens < 1) {
110
+ return { allowed: false, retryAfterMs: Math.ceil((1 - bucket.tokens) * (windowMs / capacity)) };
111
+ }
112
+ bucket.tokens -= 1;
113
+ return { allowed: true, retryAfterMs: 0 };
114
+ }
115
+
116
+ function reset() {
117
+ buckets.clear();
118
+ }
119
+
120
+ return { take, reset };
121
+ }
@@ -48,7 +48,9 @@ import {
48
48
  import { betaResponseEmitter } from '@olam/auth-client';
49
49
  import { attemptRecovery, findScenarioForKind } from '../recovery/index.mjs';
50
50
  import { detectHaltChunk } from './halt-detect.mjs';
51
+ import { evaluateRedirect, applyRedirect } from './redirect.mjs';
51
52
  import { spawnUpgraderContainer } from './upgrade-spawner.mjs';
53
+ import { isPlanningPath } from './bootstrap-selective.mjs';
52
54
  import { parseProxyPath, perWorldBase, proxyToWorld } from './proxy.mjs';
53
55
  import { resolveHostCpEngine } from './engine-identity.mjs';
54
56
  import { StartupToken } from './auth.mjs';
@@ -896,6 +898,18 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
896
898
  });
897
899
  }
898
900
 
901
+ // Phase B3 (plan-chat-spa-supersedes-control-plane): 301 redirect layer.
902
+ // Runs BEFORE static-serve so legacy `/world/:id` catch-all URLs that
903
+ // would otherwise be served as the SPA shell (and then 404 inside the
904
+ // SPA router after Phase B4 deletes the route) get redirected to
905
+ // their canonical successor. Allow-listed; closed set; security
906
+ // gated against SEC-2 (no caller-controlled Location, regex-validated
907
+ // ids, hardcoded prefixes). See packages/host-cp/src/redirect.mjs.
908
+ if (req.method === 'GET' || req.method === 'HEAD') {
909
+ const redirectVerdict = evaluateRedirect(url.pathname);
910
+ if (applyRedirect(res, redirectVerdict)) return;
911
+ }
912
+
899
913
  // Phase F-2-D dogfood fix: serve the SPA dist/ for non-API GET requests
900
914
  // BEFORE auth gate. The SPA itself is the auth gate — it loads, fetches
901
915
  // /api/bootstrap (unauthed), sets the cookie, then makes authed API calls.
@@ -3032,7 +3046,7 @@ async function tryServeStatic(req, res, pathname) {
3032
3046
  // Without this the SPA loads but every fetch 401s and the operator
3033
3047
  // sees "Could not load worlds — HTTP 401".
3034
3048
  if (isSpaShell) {
3035
- const html = await renderSpaShell(filePath);
3049
+ const html = await renderSpaShell(filePath, pathname);
3036
3050
  res.writeHead(200, {
3037
3051
  'Content-Type': 'text/html; charset=utf-8',
3038
3052
  'Cache-Control': 'no-cache, no-store, must-revalidate',
@@ -3062,11 +3076,12 @@ async function tryServeStatic(req, res, pathname) {
3062
3076
  return true;
3063
3077
  }
3064
3078
 
3065
- // Memoized injected SPA shell. Read once at first request; serve from
3066
- // memory thereafter. Cache invalidates on dist/ mtime change so a
3067
- // rebuilt bundle is picked up without restart.
3068
- let _spaCache = null;
3069
- let _spaCacheKey = '';
3079
+ // Memoized injected SPA shells. Read once per (mtime, bearer-len,
3080
+ // bootstrap-bit) tuple; serve from memory thereafter. Cache invalidates
3081
+ // on dist/ mtime change so a rebuilt bundle is picked up without
3082
+ // restart. Phase D1 — keyed map (not single slot) so /plan and
3083
+ // /workspaces don't trash each other's cached HTML.
3084
+ const _spaCacheByKey = new Map();
3070
3085
 
3071
3086
  /**
3072
3087
  * Bootstrap script injected into the SPA shell. Two responsibilities:
@@ -3166,14 +3181,37 @@ function buildPlanChatBearerInjection() {
3166
3181
  }
3167
3182
  }
3168
3183
 
3169
- async function renderSpaShell(filePath) {
3184
+ // Phase D1 — Selective BOOTSTRAP_SCRIPT no-op.
3185
+ //
3186
+ // Planning paths use plan-chat-spa's own readBearer() resolver
3187
+ // (lib/bearer.ts) which reads window.__OLAM_PLAN_CHAT_BEARER__ injected
3188
+ // inline OR falls back to the URL hash channel. They DO NOT need the
3189
+ // host-cp bootstrap's cookie+fetch-rewrite shim. Non-planning surfaces
3190
+ // (/workspaces, /repos, /runbooks, /design, /inbox, /world/:id/editor,
3191
+ // /world/:id/events) still rely on bootstrap-injected cookie + the
3192
+ // monkey-patched fetch/EventSource that rewrites world-scoped paths
3193
+ // to /api/world/<id>/... — keep injecting for them until Phase E
3194
+ // migrates each to a bootstrap-free pattern.
3195
+ //
3196
+ // Reversal: edit BOOTSTRAP_NOOP_PLANNING_PATHS in bootstrap-selective.mjs
3197
+ // to [] to restore universal injection. Single-line change.
3198
+ //
3199
+ // Per K1 SCP-3 + phase-d-tasks D1 acceptance.
3200
+
3201
+ async function renderSpaShell(filePath, pathname) {
3170
3202
  const stat = fs.statSync(filePath);
3171
3203
  const bearerInjection = buildPlanChatBearerInjection();
3172
- // Cache key must include the bearer so rotation invalidates correctly.
3173
- const cacheKey = stat.mtimeMs + ':' + bearerInjection.length;
3174
- if (_spaCache !== null && _spaCacheKey === cacheKey) {
3175
- return _spaCache;
3176
- }
3204
+ // Path-selective: planning paths skip the bootstrap shim entirely
3205
+ // (plan-chat-spa's readBearer handles auth); non-planning paths
3206
+ // retain it (Phase E will migrate them).
3207
+ const skipBootstrap = isPlanningPath(pathname);
3208
+ const bootstrapPart = skipBootstrap ? '' : BOOTSTRAP_SCRIPT;
3209
+ // Cache key includes bearer length AND the bootstrap-presence bit so
3210
+ // /plan and /workspaces don't share a cached shell.
3211
+ const cacheKey =
3212
+ stat.mtimeMs + ':' + bearerInjection.length + ':' + (skipBootstrap ? '0' : '1');
3213
+ const cached = _spaCacheByKey.get(cacheKey);
3214
+ if (cached !== undefined) return cached;
3177
3215
  let html = fs.readFileSync(filePath, 'utf-8');
3178
3216
  // Vite emits relative asset paths (`./assets/...`) so the SPA bundle
3179
3217
  // is portable across deploy paths. But under host-cp's path-segment
@@ -3184,10 +3222,13 @@ async function renderSpaShell(filePath) {
3184
3222
  // Inject right after <head> so the bootstrap runs before any other
3185
3223
  // script tag on the page. Bearer injection runs after the host-cp
3186
3224
  // bootstrap so window.__OLAM_PLAN_CHAT_BEARER__ is set before the
3187
- // SPA bundle reads it.
3188
- html = html.replace(/<head>/i, `<head>\n ${BOOTSTRAP_SCRIPT}\n ${bearerInjection}`);
3189
- _spaCache = html;
3190
- _spaCacheKey = cacheKey;
3225
+ // SPA bundle reads it. On planning paths the bootstrap is empty —
3226
+ // bearer injection still runs (plan-chat-spa reads it directly).
3227
+ html = html.replace(
3228
+ /<head>/i,
3229
+ `<head>\n ${bootstrapPart}\n ${bearerInjection}`,
3230
+ );
3231
+ _spaCacheByKey.set(cacheKey, html);
3191
3232
  return html;
3192
3233
  }
3193
3234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.174",
3
+ "version": "0.1.180",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"