@pleri/olam-cli 0.1.135 → 0.1.136
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/kg-build.d.ts +29 -31
- package/dist/commands/kg-build.d.ts.map +1 -1
- package/dist/commands/kg-build.js +102 -190
- package/dist/commands/kg-build.js.map +1 -1
- package/dist/commands/kg-service-container.d.ts.map +1 -1
- package/dist/commands/kg-service-container.js +10 -0
- package/dist/commands/kg-service-container.js.map +1 -1
- package/dist/commands/memory/start.d.ts.map +1 -1
- package/dist/commands/memory/start.js +6 -0
- package/dist/commands/memory/start.js.map +1 -1
- package/dist/image-digests.json +6 -6
- package/dist/index.js +324 -306
- package/dist/mcp-server.js +209 -51
- package/host-cp/src/plan-chat-secret.mjs +92 -0
- package/host-cp/src/plan-chat-service.mjs +271 -0
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// plan-chat-service — host-cp sidecar serving two HTTP surfaces on one port:
|
|
2
|
+
//
|
|
3
|
+
// POST /v1/chunks — bearer-auth write API; server-injects actor_id +
|
|
4
|
+
// actor_type from the bearer principal, validates the
|
|
5
|
+
// payload, INSERTs into a source Postgres.
|
|
6
|
+
// GET /v1/shape — proxied straight to a backing Electric SQL server
|
|
7
|
+
// (read-only by Electric's contract; no extra auth).
|
|
8
|
+
// GET /livez — liveness probe (200 on healthy; matches the
|
|
9
|
+
// agent-memory-service shape so host-cp uses one
|
|
10
|
+
// probe pattern for both sidecars).
|
|
11
|
+
//
|
|
12
|
+
// The service is started by `olam plan-chat start` (deferred follow-up task)
|
|
13
|
+
// and managed by host-cp's process supervisor in the long run. For now it is
|
|
14
|
+
// runnable standalone via `node plan-chat-service.mjs`.
|
|
15
|
+
//
|
|
16
|
+
// Configuration is environment-driven so a single binary works in laptop
|
|
17
|
+
// (the K3 container-spike), in a devbox container, and on a host-cp Mac:
|
|
18
|
+
//
|
|
19
|
+
// OLAM_PLAN_CHAT_PORT (default 3112)
|
|
20
|
+
// OLAM_PLAN_CHAT_DATABASE_URL (default postgres://postgres:spike@localhost:54321/chunks)
|
|
21
|
+
// OLAM_PLAN_CHAT_ELECTRIC_URL (default http://localhost:30001)
|
|
22
|
+
// OLAM_PLAN_CHAT_SECRET_PATH (default ~/.olam/plan-chat-secret)
|
|
23
|
+
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import { URL } from 'node:url';
|
|
26
|
+
import pg from 'pg';
|
|
27
|
+
import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PORT = 3112;
|
|
30
|
+
const DEFAULT_DB_URL = 'postgres://postgres:spike@localhost:54321/chunks';
|
|
31
|
+
const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';
|
|
32
|
+
|
|
33
|
+
const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
|
|
34
|
+
const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the bearer principal to (actor_id, actor_type). The minimal-A2
|
|
38
|
+
* implementation treats every authenticated bearer as the same shared
|
|
39
|
+
* principal — refinement to multi-principal mapping lands when host-cp's
|
|
40
|
+
* auth-service integrates (Phase A follow-up, tracked at A6.1 / A4 wiring).
|
|
41
|
+
*
|
|
42
|
+
* Callers MUST never use client-supplied actor_id / actor_type; this
|
|
43
|
+
* function is the single source of authority. T3 mitigation depends on it.
|
|
44
|
+
*
|
|
45
|
+
* IMPORTANT — silent-collapse safeguard (audit finding A2.M3, 2026-05-13):
|
|
46
|
+
* when A4 begins configuring multiple secrets (e.g., agent + operator +
|
|
47
|
+
* codex bearers), this single-principal collapse becomes a foot-gun: every
|
|
48
|
+
* caller's writes are attributed to 'agent', regardless of which secret
|
|
49
|
+
* they authenticated with, and S3 audit-replay attributes operator actions
|
|
50
|
+
* to agent. The check below fails LOUD when more than one secret is
|
|
51
|
+
* configured, so an A4 multi-secret rollout cannot land until principal
|
|
52
|
+
* resolution is wired here. Remove the throw when this function actually
|
|
53
|
+
* inspects the bearer.
|
|
54
|
+
*/
|
|
55
|
+
function principalFromBearer(bearer) {
|
|
56
|
+
if (process.env.OLAM_PLAN_CHAT_SECRETS) {
|
|
57
|
+
// Multi-secret mode reserved for A4 / A6.1; minimal A2 doesn't support it.
|
|
58
|
+
throw new Error(
|
|
59
|
+
'plan-chat-service: OLAM_PLAN_CHAT_SECRETS is set but principalFromBearer ' +
|
|
60
|
+
"still hardcodes ('agent','agent'). Wire multi-principal resolution before " +
|
|
61
|
+
'enabling multi-secret mode (see plan A4 / A6.1).',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (typeof bearer !== 'string' || bearer.length === 0) {
|
|
65
|
+
throw new Error('plan-chat-service: principalFromBearer called with empty bearer');
|
|
66
|
+
}
|
|
67
|
+
return { actorId: 'agent', actorType: 'agent' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readJson(req, limit = 65536) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let length = 0;
|
|
74
|
+
req.on('data', (chunk) => {
|
|
75
|
+
length += chunk.length;
|
|
76
|
+
if (length > limit) {
|
|
77
|
+
reject(Object.assign(new Error('payload-too-large'), { status: 413 }));
|
|
78
|
+
req.destroy();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
});
|
|
83
|
+
req.on('end', () => {
|
|
84
|
+
try {
|
|
85
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
86
|
+
if (!body) return resolve({});
|
|
87
|
+
resolve(JSON.parse(body));
|
|
88
|
+
} catch (_err) {
|
|
89
|
+
reject(Object.assign(new Error('invalid-json'), { status: 400 }));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
req.on('error', reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function send(res, status, body) {
|
|
97
|
+
const json = typeof body === 'string' ? body : JSON.stringify(body);
|
|
98
|
+
res.writeHead(status, {
|
|
99
|
+
'content-type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
|
|
100
|
+
'content-length': Buffer.byteLength(json),
|
|
101
|
+
});
|
|
102
|
+
res.end(json);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function unauthorized(res) {
|
|
106
|
+
send(res, 401, { error: 'unauthorized' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function badRequest(res, message) {
|
|
110
|
+
send(res, 400, { error: 'bad-request', message });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateChunkInput(body) {
|
|
114
|
+
if (!body || typeof body !== 'object') return 'body must be an object';
|
|
115
|
+
const required = ['world_id', 'session_id', 'message_id', 'seq', 'role', 'chunk'];
|
|
116
|
+
for (const key of required) {
|
|
117
|
+
if (!(key in body)) return `missing field: ${key}`;
|
|
118
|
+
}
|
|
119
|
+
if (typeof body.world_id !== 'string' || body.world_id.length === 0) return 'world_id must be a non-empty string';
|
|
120
|
+
if (typeof body.session_id !== 'string' || body.session_id.length === 0) return 'session_id must be a non-empty string';
|
|
121
|
+
if (typeof body.message_id !== 'string' || body.message_id.length === 0) return 'message_id must be a non-empty string';
|
|
122
|
+
if (!Number.isInteger(body.seq) || body.seq < 0) return 'seq must be a non-negative integer';
|
|
123
|
+
if (typeof body.chunk !== 'string') return 'chunk must be a string';
|
|
124
|
+
if (!ROLES.has(body.role)) return `role must be one of ${[...ROLES].join(', ')}`;
|
|
125
|
+
// actor_id and actor_type from the client are IGNORED (server-derived).
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the HTTP request handler. Pure factory — easy to test against a
|
|
131
|
+
* stubbed pool. Production callers pass a real pg.Pool.
|
|
132
|
+
*/
|
|
133
|
+
export function createHandler({ pool, bearer, electricUrl }) {
|
|
134
|
+
if (!pool) throw new Error('createHandler: { pool } required');
|
|
135
|
+
if (typeof bearer !== 'string' || bearer.length === 0) {
|
|
136
|
+
throw new Error('createHandler: { bearer } required');
|
|
137
|
+
}
|
|
138
|
+
const electricBase = electricUrl ?? DEFAULT_ELECTRIC_URL;
|
|
139
|
+
|
|
140
|
+
function checkAuth(req) {
|
|
141
|
+
const header = req.headers.authorization;
|
|
142
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
|
|
143
|
+
const supplied = header.slice('Bearer '.length).trim();
|
|
144
|
+
return timingSafeEqual(supplied, bearer);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function handlePostChunks(req, res) {
|
|
148
|
+
if (!checkAuth(req)) return unauthorized(res);
|
|
149
|
+
let body;
|
|
150
|
+
try {
|
|
151
|
+
body = await readJson(req);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
|
|
154
|
+
}
|
|
155
|
+
const invalid = validateChunkInput(body);
|
|
156
|
+
if (invalid) return badRequest(res, invalid);
|
|
157
|
+
const principal = principalFromBearer(bearer);
|
|
158
|
+
try {
|
|
159
|
+
await pool.query(
|
|
160
|
+
`INSERT INTO chunks
|
|
161
|
+
(world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk)
|
|
162
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
163
|
+
[
|
|
164
|
+
body.world_id,
|
|
165
|
+
body.session_id,
|
|
166
|
+
body.message_id,
|
|
167
|
+
body.seq,
|
|
168
|
+
principal.actorId,
|
|
169
|
+
principal.actorType,
|
|
170
|
+
body.role,
|
|
171
|
+
body.chunk,
|
|
172
|
+
],
|
|
173
|
+
);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
|
|
176
|
+
return send(res, 409, { error: 'duplicate', message: '(message_id, seq) already exists' });
|
|
177
|
+
}
|
|
178
|
+
return send(res, 500, { error: 'insert-failed', message: String(err?.message ?? err) });
|
|
179
|
+
}
|
|
180
|
+
return send(res, 201, {
|
|
181
|
+
world_id: body.world_id,
|
|
182
|
+
session_id: body.session_id,
|
|
183
|
+
message_id: body.message_id,
|
|
184
|
+
seq: body.seq,
|
|
185
|
+
actor_id: principal.actorId,
|
|
186
|
+
actor_type: principal.actorType,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function handleGetShape(req, res, url) {
|
|
191
|
+
// Proxy directly to Electric — read-path is shape-server's responsibility,
|
|
192
|
+
// not ours; we just preserve the URL and add no auth (Electric is read-only).
|
|
193
|
+
const upstream = new URL(electricBase);
|
|
194
|
+
upstream.pathname = '/v1/shape';
|
|
195
|
+
for (const [key, value] of url.searchParams) upstream.searchParams.set(key, value);
|
|
196
|
+
try {
|
|
197
|
+
const upstreamRes = await fetch(upstream, {
|
|
198
|
+
method: 'GET',
|
|
199
|
+
headers: { accept: 'application/json' },
|
|
200
|
+
});
|
|
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
|
+
} catch (err) {
|
|
208
|
+
send(res, 502, { error: 'shape-upstream-unreachable', message: String(err?.message ?? err) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return async function handler(req, res) {
|
|
213
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
214
|
+
if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
|
|
215
|
+
if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
|
|
216
|
+
if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
|
|
217
|
+
return send(res, 404, { error: 'not-found' });
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Boot the service. Returns the http.Server instance plus a stop() helper.
|
|
223
|
+
* Callers pass the resolved bearer + pool + electric URL; defaults live in
|
|
224
|
+
* the standalone launcher below.
|
|
225
|
+
*/
|
|
226
|
+
export async function startService(opts = {}) {
|
|
227
|
+
const port = opts.port ?? Number(process.env.OLAM_PLAN_CHAT_PORT ?? DEFAULT_PORT);
|
|
228
|
+
const databaseUrl =
|
|
229
|
+
opts.databaseUrl ?? process.env.OLAM_PLAN_CHAT_DATABASE_URL ?? DEFAULT_DB_URL;
|
|
230
|
+
const electricUrl =
|
|
231
|
+
opts.electricUrl ?? process.env.OLAM_PLAN_CHAT_ELECTRIC_URL ?? DEFAULT_ELECTRIC_URL;
|
|
232
|
+
const secretPath = opts.secretPath ?? process.env.OLAM_PLAN_CHAT_SECRET_PATH ?? SECRET_PATH;
|
|
233
|
+
const bearer = opts.bearer ?? ensureSecret(secretPath);
|
|
234
|
+
|
|
235
|
+
const pool = opts.pool ?? new pg.Pool({ connectionString: databaseUrl, max: 8 });
|
|
236
|
+
const handler = createHandler({ pool, bearer, electricUrl });
|
|
237
|
+
const server = http.createServer((req, res) => {
|
|
238
|
+
handler(req, res).catch((err) => {
|
|
239
|
+
try {
|
|
240
|
+
send(res, 500, { error: 'handler-crash', message: String(err?.message ?? err) });
|
|
241
|
+
} catch { /* response already started */ }
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await new Promise((resolve, reject) => {
|
|
246
|
+
server.once('error', reject);
|
|
247
|
+
server.listen(port, () => resolve(undefined));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
async function stop() {
|
|
251
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)));
|
|
252
|
+
if (!opts.pool) await pool.end().catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { server, stop, port, bearer };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Standalone launcher (allows `node plan-chat-service.mjs` for ad-hoc runs +
|
|
259
|
+
// the deferred `olam plan-chat start` lifecycle command).
|
|
260
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
261
|
+
startService()
|
|
262
|
+
.then(({ port }) => {
|
|
263
|
+
// eslint-disable-next-line no-console
|
|
264
|
+
console.log(`[plan-chat-service] listening on :${port}`);
|
|
265
|
+
})
|
|
266
|
+
.catch((err) => {
|
|
267
|
+
// eslint-disable-next-line no-console
|
|
268
|
+
console.error('[plan-chat-service] failed to start:', err);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
});
|
|
271
|
+
}
|