@pleri/olam-cli 0.1.137 → 0.1.138
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/image-digests.json +5 -4
- package/host-cp/src/plan-chat-service.mjs +120 -13
- package/package.json +1 -1
package/dist/image-digests.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"auth": "sha256:47f58fad2635727a03382a75bbd0db3cd1357e18c4173f29302c8648c3a01d43",
|
|
3
|
-
"devbox": "sha256:
|
|
4
|
-
"devbox-base": "sha256:
|
|
5
|
-
"host-cp": "sha256:
|
|
3
|
+
"devbox": "sha256:370210d8adad83d6f974c720a1988bfa4e4bc18b2fb5e026eb1820fb4c40d5f4",
|
|
4
|
+
"devbox-base": "sha256:33de7809b0e53090f2d4d2efb36b4c916ee2a5cc3583606031ca934110b5d16f",
|
|
5
|
+
"host-cp": "sha256:eeddba15e412e2b913dbcbcc1d314cb2dd83a534fce8168b7445e72c4341e1f5",
|
|
6
|
+
"kg-service": "sha256:db2219624e4eb423c171391d61f0b46da416b1414b56469a73d67bf6e31fd8ea",
|
|
6
7
|
"mcp-auth": "sha256:452b10df72ac8f7e8f249c171e068e51c7ca846a9b287df81e253b7df1169a95",
|
|
7
8
|
"$schema_version": 1,
|
|
8
|
-
"$published_version": "0.1.
|
|
9
|
+
"$published_version": "0.1.138",
|
|
9
10
|
"$registry": "ghcr.io/pleri"
|
|
10
11
|
}
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
// POST /v1/chunks — bearer-auth write API; server-injects actor_id +
|
|
4
4
|
// actor_type from the bearer principal, validates the
|
|
5
5
|
// payload, INSERTs into a source Postgres.
|
|
6
|
-
// GET /v1/shape —
|
|
7
|
-
//
|
|
6
|
+
// GET /v1/shape — bearer-auth read API; requires (world_id,
|
|
7
|
+
// session_id) query params; rewrites the upstream
|
|
8
|
+
// Electric `where` clause server-side so a caller
|
|
9
|
+
// can never enumerate cross-world chunks (PB2).
|
|
10
|
+
// Streams the upstream response without buffering
|
|
11
|
+
// so Electric's `live=true` long-poll round-trips
|
|
12
|
+
// stay reactive (PB1). Phase B B1 closes both
|
|
13
|
+
// A2-CP3 audit HIGHs.
|
|
8
14
|
// GET /livez — liveness probe (200 on healthy; matches the
|
|
9
15
|
// agent-memory-service shape so host-cp uses one
|
|
10
16
|
// probe pattern for both sidecars).
|
|
@@ -22,6 +28,7 @@
|
|
|
22
28
|
// OLAM_PLAN_CHAT_SECRET_PATH (default ~/.olam/plan-chat-secret)
|
|
23
29
|
|
|
24
30
|
import http from 'node:http';
|
|
31
|
+
import { Readable } from 'node:stream';
|
|
25
32
|
import { URL } from 'node:url';
|
|
26
33
|
import pg from 'pg';
|
|
27
34
|
import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
|
|
@@ -33,6 +40,27 @@ const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';
|
|
|
33
40
|
const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
|
|
34
41
|
const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
|
|
35
42
|
|
|
43
|
+
// PB2 — scope-ID shape. world_id + session_id query params are interpolated
|
|
44
|
+
// into the upstream Electric `where` clause; the regex IS the SQL-injection
|
|
45
|
+
// defence (single-quotes, semicolons, comments can't pass). Same shape as
|
|
46
|
+
// @olam/chunks schema accepts and as measure-in-world.mjs validates. Bump
|
|
47
|
+
// only if the chunks schema bumps; the two must move together.
|
|
48
|
+
const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;
|
|
49
|
+
|
|
50
|
+
// PB2 — query params the server OWNS on /v1/shape. All three are stripped
|
|
51
|
+
// from what we forward to upstream Electric:
|
|
52
|
+
// - `world_id` + `session_id` are SEEDS for the server-derived `where`
|
|
53
|
+
// clause; Electric doesn't read them natively, so dropping them from
|
|
54
|
+
// the upstream URL is a no-op for Electric semantics. (Per CP3-B1-M2:
|
|
55
|
+
// this drop is intentional — if a future Electric version adopts these
|
|
56
|
+
// as native scope params, split this set into SEEDS vs DROPPED.)
|
|
57
|
+
// - `where` is the actual SQL predicate; we drop the client's value and
|
|
58
|
+
// replace with the server-derived one. Operator can't bypass scope by
|
|
59
|
+
// passing their own.
|
|
60
|
+
// Everything else (`table`, `offset`, `handle`, `live`, future Electric
|
|
61
|
+
// params) forwards through verbatim.
|
|
62
|
+
const SCOPED_QUERY_PARAMS = new Set(['world_id', 'session_id', 'where']);
|
|
63
|
+
|
|
36
64
|
/**
|
|
37
65
|
* Resolve the bearer principal to (actor_id, actor_type). The minimal-A2
|
|
38
66
|
* implementation treats every authenticated bearer as the same shared
|
|
@@ -188,24 +216,103 @@ export function createHandler({ pool, bearer, electricUrl }) {
|
|
|
188
216
|
}
|
|
189
217
|
|
|
190
218
|
async function handleGetShape(req, res, url) {
|
|
191
|
-
//
|
|
192
|
-
//
|
|
219
|
+
// PB2 — bearer required. Same checkAuth as POST /v1/chunks; rejects with
|
|
220
|
+
// a constant-time comparison via timingSafeEqual.
|
|
221
|
+
if (!checkAuth(req)) return unauthorized(res);
|
|
222
|
+
|
|
223
|
+
// PB2 — server-derived scope. world_id + session_id query params are
|
|
224
|
+
// mandatory; the regex check is the SQL-injection defence for the
|
|
225
|
+
// upstream `where` interpolation below.
|
|
226
|
+
const worldId = url.searchParams.get('world_id');
|
|
227
|
+
const sessionId = url.searchParams.get('session_id');
|
|
228
|
+
if (!worldId || !SCOPE_ID_RE.test(worldId)) {
|
|
229
|
+
return badRequest(
|
|
230
|
+
res,
|
|
231
|
+
'world_id query param required (alphanumerics + _ - . only)',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (!sessionId || !SCOPE_ID_RE.test(sessionId)) {
|
|
235
|
+
return badRequest(
|
|
236
|
+
res,
|
|
237
|
+
'session_id query param required (alphanumerics + _ - . only)',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Forward all client query params EXCEPT the scoped set. Client-supplied
|
|
242
|
+
// `where` is silently dropped — server-derived `where` always wins.
|
|
243
|
+
// Operators inspecting traffic see the rewrite in the upstream URL; we
|
|
244
|
+
// don't 400 on `where=` presence because legitimate Electric clients may
|
|
245
|
+
// pass it via library defaults.
|
|
193
246
|
const upstream = new URL(electricBase);
|
|
194
247
|
upstream.pathname = '/v1/shape';
|
|
195
|
-
for (const [key, value] of url.searchParams)
|
|
248
|
+
for (const [key, value] of url.searchParams) {
|
|
249
|
+
if (SCOPED_QUERY_PARAMS.has(key)) continue;
|
|
250
|
+
upstream.searchParams.set(key, value);
|
|
251
|
+
}
|
|
252
|
+
// PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
|
|
253
|
+
// already rejected single-quotes, semicolons, and SQL meta-characters.
|
|
254
|
+
upstream.searchParams.set(
|
|
255
|
+
'where',
|
|
256
|
+
`world_id='${worldId}' AND session_id='${sessionId}'`,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
let upstreamRes;
|
|
196
260
|
try {
|
|
197
|
-
|
|
261
|
+
upstreamRes = await fetch(upstream, {
|
|
198
262
|
method: 'GET',
|
|
199
263
|
headers: { accept: 'application/json' },
|
|
200
264
|
});
|
|
201
|
-
const text = await upstreamRes.text();
|
|
202
|
-
for (const header of ['electric-handle', 'electric-offset', 'electric-up-to-date', 'electric-schema']) {
|
|
203
|
-
const value = upstreamRes.headers.get(header);
|
|
204
|
-
if (value) res.setHeader(header, value);
|
|
205
|
-
}
|
|
206
|
-
send(res, upstreamRes.status, text || '[]');
|
|
207
265
|
} catch (err) {
|
|
208
|
-
send(res, 502, {
|
|
266
|
+
return send(res, 502, {
|
|
267
|
+
error: 'shape-upstream-unreachable',
|
|
268
|
+
message: String(err?.message ?? err),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Forward upstream electric-* headers BEFORE the stream starts. Once
|
|
273
|
+
// res.writeHead fires, headers are locked.
|
|
274
|
+
for (const header of [
|
|
275
|
+
'electric-handle',
|
|
276
|
+
'electric-offset',
|
|
277
|
+
'electric-up-to-date',
|
|
278
|
+
'electric-schema',
|
|
279
|
+
]) {
|
|
280
|
+
const value = upstreamRes.headers.get(header);
|
|
281
|
+
if (value) res.setHeader(header, value);
|
|
282
|
+
}
|
|
283
|
+
const upstreamContentType = upstreamRes.headers.get('content-type');
|
|
284
|
+
if (upstreamContentType) res.setHeader('content-type', upstreamContentType);
|
|
285
|
+
|
|
286
|
+
res.writeHead(upstreamRes.status);
|
|
287
|
+
|
|
288
|
+
// PB1 — stream the body without buffering. Electric SQL long-poll
|
|
289
|
+
// responses (`live=true`) can sit open for tens of seconds before the
|
|
290
|
+
// first row arrives; the old `fetch().text()` blocked the response until
|
|
291
|
+
// upstream closed, which broke reactive UX. `Readable.fromWeb` wraps
|
|
292
|
+
// upstream's web ReadableStream as a Node Readable; `.pipe(res)` ends
|
|
293
|
+
// the response automatically when the upstream stream closes.
|
|
294
|
+
//
|
|
295
|
+
// CP3-B1-H1 fix: attach an `error` listener BEFORE piping. Without one,
|
|
296
|
+
// a mid-stream upstream error (Electric SQL restart, network blip mid
|
|
297
|
+
// long-poll, upstream socket reset after headers) becomes an
|
|
298
|
+
// uncaughtException and kills the host-cp process — DoS class. On
|
|
299
|
+
// upstream error we destroy the response so the client sees a
|
|
300
|
+
// truncated body (the only honest signal once headers are sent) and
|
|
301
|
+
// the process stays up. Mirrors the established pattern in
|
|
302
|
+
// packages/host-cp/src/proxy.mjs:205.
|
|
303
|
+
if (upstreamRes.body) {
|
|
304
|
+
const src = Readable.fromWeb(upstreamRes.body);
|
|
305
|
+
src.on('error', (err) => {
|
|
306
|
+
try {
|
|
307
|
+
res.destroy(err);
|
|
308
|
+
} catch {
|
|
309
|
+
/* response already destroyed; fine */
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
src.pipe(res);
|
|
313
|
+
} else {
|
|
314
|
+
// Defensive — no body (e.g. HEAD-shaped empty response). Close out.
|
|
315
|
+
res.end();
|
|
209
316
|
}
|
|
210
317
|
}
|
|
211
318
|
|