@jbarket/loomai 0.7.0 → 0.9.0

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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * loom's web UI on the HTTP daemon (full scope only).
3
+ *
4
+ * GET /ui Home: the tape, tiles, "Your call"
5
+ * GET /ui/mind, /ui/people[/<slug>], /ui/memory/<ref> loom's own pages
6
+ * GET /ui/p/<piece>[/<path>] a piece's page
7
+ * GET /ui/pieces.json the installed pieces' manifests, for scripts
8
+ * POST /ui/act/<source> an action (source = loom or a piece)
9
+ * GET/POST /ui/login, POST /ui/logout
10
+ *
11
+ * Auth follows the daemon's bearer token, and the UI needs one: a daemon with
12
+ * no token serves no /ui (like /cli), because an open page would be readable
13
+ * by any site that DNS-rebinds a name to the loopback address. A browser logs
14
+ * in once by pasting the token and gets a session: a random id held in this
15
+ * process's memory. It isn't the token and isn't derivable from it, so it
16
+ * can't be replayed as a bearer or used to guess the token. It lasts 30 days,
17
+ * until logout (which revokes it), until the token changes, or until the
18
+ * daemon restarts. Scripts can send the bearer header instead.
19
+ *
20
+ * A GET from another site or another port is served only as a top-level
21
+ * navigation, never to an <img> or a fetch (fetch metadata).
22
+ *
23
+ * Every POST from a browser carries an anti-forgery token derived from its
24
+ * cookie, and a cross-site POST is refused outright (Sec-Fetch-Site / Origin),
25
+ * on top of the SameSite=Strict cookie. The page runs no script and is served
26
+ * under a CSP that forbids script and framing. Action fields go to the piece
27
+ * on stdin and are never logged, stored, or put in a URL; the one-time result
28
+ * message is kept in memory under a random id for a minute.
29
+ */
30
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
31
+ import { checkBearer } from '../transport/guards.js';
32
+ import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
33
+ import { pieceAct, pieceManifests, piecePage } from './pieces.js';
34
+ import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
35
+ import { ID, LIMITS, QUERY_KEY, splitPage, validPath } from './vocab.js';
36
+ const COOKIE = 'loom_ui';
37
+ const LOGIN_MAX_BYTES = 4096;
38
+ const ACT_MAX_BYTES = 64 * 1024;
39
+ const COOKIE_MAX_AGE_S = 30 * 24 * 3600;
40
+ const FLASH_TTL_MS = 60_000;
41
+ const FLASH_MAX = 200;
42
+ /** This process's cookie key. Never written anywhere: a restart means logging in again. */
43
+ const COOKIE_KEY = randomBytes(32);
44
+ const SECURITY_HEADERS = {
45
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
46
+ 'x-content-type-options': 'nosniff',
47
+ 'referrer-policy': 'no-referrer',
48
+ 'cache-control': 'no-store',
49
+ };
50
+ export function isUiPath(pathname) {
51
+ return pathname === '/ui' || pathname.startsWith('/ui/');
52
+ }
53
+ const digest = (s) => createHash('sha256').update(s).digest();
54
+ const sessions = new Map();
55
+ const MAX_SESSIONS = 50;
56
+ function newSession(secret) {
57
+ const id = randomBytes(32).toString('hex');
58
+ sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: digest(secret) });
59
+ // Oldest first in a Map: drop the oldest past the cap.
60
+ while (sessions.size > MAX_SESSIONS)
61
+ sessions.delete(sessions.keys().next().value);
62
+ return id;
63
+ }
64
+ /** The anti-forgery token for a session: bound to its id, useless without it. */
65
+ function csrfFor(id) {
66
+ return createHmac('sha256', COOKIE_KEY).update(`loom-ui-csrf|${id}`).digest('hex');
67
+ }
68
+ /** Equal-length digests, so neither the content nor the length of a guess leaks. */
69
+ function safeEq(a, b) {
70
+ return timingSafeEqual(digest(a), digest(b));
71
+ }
72
+ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
73
+ if (!/^[0-9a-f]{64}$/.test(id))
74
+ return false;
75
+ const s = sessions.get(id);
76
+ if (!s)
77
+ return false;
78
+ if (nowS - s.issuedAt > COOKIE_MAX_AGE_S) {
79
+ sessions.delete(id);
80
+ return false;
81
+ }
82
+ // A session dies with its token: rotate the token and old sessions stop.
83
+ return secrets.some((t) => timingSafeEqual(digest(t), s.secret));
84
+ }
85
+ function readCookie(req, name) {
86
+ for (const part of (req.headers.cookie ?? '').split(';')) {
87
+ const [k, ...v] = part.trim().split('=');
88
+ if (k === name)
89
+ return v.join('=');
90
+ }
91
+ return undefined;
92
+ }
93
+ function whoIs(req, opts) {
94
+ if (opts.secrets.length === 0)
95
+ return null;
96
+ const c = readCookie(req, COOKIE);
97
+ if (c && validSession(c, opts.secrets))
98
+ return { kind: 'session', cookie: c };
99
+ const header = req.headers['authorization'];
100
+ if (header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok))
101
+ return { kind: 'bearer' };
102
+ return null;
103
+ }
104
+ /**
105
+ * A GET from somewhere else is fine as a top-level navigation (a link in a
106
+ * chat) and refused otherwise: an <img> or fetch from another site, or from
107
+ * another port on this host (same-site, so the cookie rides along), must not
108
+ * make loom run a piece.
109
+ */
110
+ function allowedGet(req) {
111
+ const site = req.headers['sec-fetch-site'];
112
+ if (site === undefined || site === 'same-origin' || site === 'none')
113
+ return true;
114
+ return req.headers['sec-fetch-mode'] === 'navigate' && req.headers['sec-fetch-dest'] === 'document';
115
+ }
116
+ /**
117
+ * A browser POST must come from loom's own page. Sec-Fetch-Site is set by
118
+ * every current browser and can't be forged by a page; Origin, when sent,
119
+ * must name this host. Either one saying "elsewhere" is a refusal.
120
+ */
121
+ function sameOrigin(req) {
122
+ const site = req.headers['sec-fetch-site'];
123
+ if (site !== undefined && site !== 'same-origin' && site !== 'none')
124
+ return false;
125
+ const origin = req.headers.origin;
126
+ if (origin !== undefined) {
127
+ if (origin === 'null')
128
+ return false;
129
+ try {
130
+ if (new URL(origin).host !== req.headers.host)
131
+ return false;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
137
+ return true;
138
+ }
139
+ function send(res, status, type, body, extra = {}) {
140
+ res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
141
+ res.end(body);
142
+ }
143
+ function redirect(res, to, extra = {}) {
144
+ res.writeHead(303, { location: to, ...SECURITY_HEADERS, ...extra });
145
+ res.end();
146
+ }
147
+ function readForm(req, max) {
148
+ return new Promise((resolve) => {
149
+ const chunks = [];
150
+ let bytes = 0;
151
+ let done = false;
152
+ const finish = (v) => { if (!done) {
153
+ done = true;
154
+ resolve(v);
155
+ } };
156
+ req.on('data', (d) => {
157
+ if (done)
158
+ return;
159
+ bytes += d.length;
160
+ if (bytes > max) {
161
+ finish(null);
162
+ req.destroy();
163
+ return;
164
+ }
165
+ chunks.push(d);
166
+ });
167
+ req.on('end', () => finish(new URLSearchParams(Buffer.concat(chunks).toString('utf-8'))));
168
+ req.on('error', () => finish(null));
169
+ req.on('close', () => finish(null));
170
+ });
171
+ }
172
+ /** The page's own query: valid keys only, loom's `_` keys and oversize values dropped. */
173
+ function pageQuery(url) {
174
+ const q = Object.create(null);
175
+ let n = 0;
176
+ for (const [k, v] of url.searchParams) {
177
+ if (!QUERY_KEY.test(k) || v.length > LIMITS.queryValue || Object.hasOwn(q, k))
178
+ continue;
179
+ if (++n > LIMITS.query)
180
+ break;
181
+ q[k] = v;
182
+ }
183
+ return q;
184
+ }
185
+ const flashes = new Map();
186
+ function putFlash(flash) {
187
+ const now = Date.now();
188
+ for (const [k, v] of flashes)
189
+ if (now - v.at > FLASH_TTL_MS || flashes.size > FLASH_MAX)
190
+ flashes.delete(k);
191
+ const id = randomBytes(12).toString('hex');
192
+ flashes.set(id, { at: now, flash });
193
+ return id;
194
+ }
195
+ function takeFlash(id) {
196
+ if (!id)
197
+ return undefined;
198
+ const f = flashes.get(id);
199
+ flashes.delete(id);
200
+ return f && Date.now() - f.at <= FLASH_TTL_MS ? f.flash : undefined;
201
+ }
202
+ // ─── Assembling a page ───────────────────────────────────────────────────────
203
+ function clock() {
204
+ return new Intl.DateTimeFormat('en-US', { timeZone: uiTimeZone(), weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date());
205
+ }
206
+ function hasPages(p) {
207
+ return Boolean(p.manifest.tab || p.manifest.legacy?.length);
208
+ }
209
+ function chrome(opts, pieces, skipped, current, csrf, flash) {
210
+ const tabs = pieces.filter(hasPages)
211
+ .map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/ui/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
212
+ .sort((a, b) => a.label.localeCompare(b.label));
213
+ return {
214
+ agent: agentName(opts.contextDir),
215
+ nav: [
216
+ { label: 'Home', href: '/ui', current: current === '', piece: false },
217
+ { label: 'Mind', href: '/ui/mind', current: current === 'mind', piece: false },
218
+ { label: 'People', href: '/ui/people', current: current === 'people', piece: false },
219
+ ...tabs,
220
+ ],
221
+ clock: clock(),
222
+ csrf,
223
+ flash,
224
+ skipped,
225
+ };
226
+ }
227
+ /** Where to land after an action: the page the piece names, or back where the form was. */
228
+ function landing(source, result, back) {
229
+ const s = (result.page !== undefined ? splitPage(result.page) : null) ?? splitPage(back);
230
+ const core = s?.core || source.id === 'loom';
231
+ const path = s?.path ?? '';
232
+ const q = s ? new URLSearchParams(s.query).toString() : '';
233
+ const base = core ? `/ui${path ? `/${path}` : ''}` : `/ui/p/${source.id}${path ? `/${path}` : ''}`;
234
+ return q ? `${base}?${q}` : base;
235
+ }
236
+ function withFlash(to, id) {
237
+ return `${to}${to.includes('?') ? '&' : '?'}_flash=${id}`;
238
+ }
239
+ const notFound = { title: 'Not found', sections: [] };
240
+ /** decodeURIComponent, or null for a malformed escape (a 404, never a 500). */
241
+ function decode(s) {
242
+ try {
243
+ return decodeURIComponent(s);
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ /** Handle a /ui request. The caller has already matched isUiPath. */
250
+ export async function handleUi(req, res, pathname, opts) {
251
+ // No token, no page: an open /ui would be readable by any site that
252
+ // DNS-rebinds a name to the loopback address (the same rule as /cli).
253
+ if (opts.secrets.length === 0) {
254
+ return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
255
+ }
256
+ const secure = req.headers['x-forwarded-proto'] === 'https';
257
+ if (pathname === '/ui/login') {
258
+ // Unauthenticated: the login page names no agent.
259
+ if (req.method === 'GET')
260
+ return send(res, 200, 'text/html; charset=utf-8', renderLogin());
261
+ if (req.method !== 'POST')
262
+ return send(res, 405, 'text/plain', 'method not allowed');
263
+ if (!sameOrigin(req))
264
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
265
+ const form = await readForm(req, LOGIN_MAX_BYTES);
266
+ if (!form)
267
+ return send(res, 413, 'text/plain', 'too large');
268
+ const token = form.get('token')?.trim() ?? '';
269
+ const match = opts.secrets.find((s) => safeEq(token, s));
270
+ if (!match)
271
+ return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
272
+ return redirect(res, '/ui', {
273
+ 'set-cookie': `${COOKIE}=${newSession(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
274
+ });
275
+ }
276
+ const who = whoIs(req, opts);
277
+ const csrf = who?.kind === 'session' ? csrfFor(who.cookie) : '';
278
+ if (pathname === '/ui/logout') {
279
+ if (req.method !== 'POST')
280
+ return send(res, 405, 'text/plain', 'method not allowed');
281
+ if (!sameOrigin(req))
282
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
283
+ // Revoke it server-side, so a copy of the cookie stops working too.
284
+ const c = readCookie(req, COOKIE);
285
+ if (c)
286
+ sessions.delete(c);
287
+ return redirect(res, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
288
+ }
289
+ if (!who) {
290
+ if (pathname.endsWith('.json'))
291
+ return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
292
+ if (req.method === 'GET')
293
+ return redirect(res, '/ui/login');
294
+ return send(res, 401, 'text/plain', 'unauthorized');
295
+ }
296
+ const url = new URL(req.url ?? pathname, 'http://loom.invalid');
297
+ if (pathname.startsWith('/ui/act/')) {
298
+ if (req.method !== 'POST')
299
+ return send(res, 405, 'text/plain', 'method not allowed');
300
+ if (!sameOrigin(req))
301
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
302
+ const sourceId = pathname.slice('/ui/act/'.length);
303
+ if (sourceId !== 'loom' && !ID.test(sourceId))
304
+ return send(res, 404, 'text/plain', 'not found');
305
+ const form = await readForm(req, ACT_MAX_BYTES);
306
+ if (!form)
307
+ return send(res, 413, 'text/plain', 'too large');
308
+ // A browser session must prove the form came from loom's page. A bearer
309
+ // script has no ambient credential a forger could ride, so it needs none.
310
+ if (who.kind === 'session' && !safeEq(form.get('_csrf') ?? '', csrf)) {
311
+ return send(res, 403, 'text/plain', 'forbidden: this form is stale or not from loom\'s page; reload and try again');
312
+ }
313
+ const action = form.get('_action') ?? '';
314
+ const back = form.get('_path') ?? '';
315
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(action))
316
+ return send(res, 400, 'text/plain', 'bad action');
317
+ const backLink = splitPage(back) ? back : '';
318
+ // A form has up to LIMITS.fields fields plus LIMITS.hidden hidden values.
319
+ // More than that is refused whole, never cut short: a truncated form
320
+ // could drop the very field (a secret) the piece needs.
321
+ const fields = Object.create(null);
322
+ for (const [k, v] of form) {
323
+ if (k.startsWith('_') || !/^[a-z][a-z0-9_-]{0,39}$/.test(k) || Object.hasOwn(fields, k))
324
+ continue;
325
+ fields[k] = v;
326
+ }
327
+ if (Object.keys(fields).length > LIMITS.fields + LIMITS.hidden)
328
+ return send(res, 400, 'text/plain', 'too many fields');
329
+ let source;
330
+ let result;
331
+ if (sourceId === 'loom') {
332
+ source = CORE;
333
+ result = await coreAct(opts.contextDir, action, fields);
334
+ }
335
+ else {
336
+ const { pieces } = await pieceManifests(opts.env);
337
+ const info = pieces.find((p) => p.piece === sourceId);
338
+ if (!info)
339
+ return send(res, 404, 'text/plain', 'not found');
340
+ source = pieceSource(info.piece);
341
+ const path = splitPage(backLink)?.path ?? '';
342
+ result = await pieceAct(info, { action, path, fields }, opts.env);
343
+ }
344
+ const to = landing(source, result, backLink);
345
+ // A piece's message is labeled with the piece, so it can't pass as loom's.
346
+ const from = source.id === 'loom' ? undefined : source.id;
347
+ return redirect(res, withFlash(to, putFlash({ ok: result.ok, message: result.message, from })));
348
+ }
349
+ if (req.method !== 'GET')
350
+ return send(res, 405, 'text/plain', 'method not allowed');
351
+ if (!allowedGet(req))
352
+ return send(res, 403, 'text/plain', 'forbidden: loom\'s page can only be opened, not embedded or fetched from another site');
353
+ if (pathname === '/ui/pieces.json') {
354
+ const m = await pieceManifests(opts.env);
355
+ return send(res, 200, 'application/json', JSON.stringify({
356
+ agent: agentName(opts.contextDir),
357
+ pieces: m.pieces.map((p) => ({ piece: p.piece, manifest: p.manifest })),
358
+ skipped: m.skipped,
359
+ }));
360
+ }
361
+ const query = pageQuery(url);
362
+ const flash = takeFlash(url.searchParams.get('_flash'));
363
+ const { pieces, skipped } = await pieceManifests(opts.env);
364
+ const skippedNames = skipped.map((s) => s.piece);
365
+ const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
366
+ if (pathname === '/ui' || pathname === '/ui/') {
367
+ const home = homePage(opts.contextDir, query);
368
+ const tiles = [
369
+ { ...home.tile, source: CORE },
370
+ ...pieces.filter((p) => p.manifest.tile).map((p) => ({ ...p.manifest.tile, source: pieceSource(p.piece) })),
371
+ ];
372
+ const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
373
+ return html(renderHome(home.page, { source: CORE, path: '', query, csrf }, chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
374
+ }
375
+ if (pathname.startsWith('/ui/p/')) {
376
+ const rest = pathname.slice('/ui/p/'.length);
377
+ const slash = rest.indexOf('/');
378
+ const piece = slash === -1 ? rest : rest.slice(0, slash);
379
+ const path = slash === -1 ? '' : decode(rest.slice(slash + 1));
380
+ const info = pieces.find((p) => p.piece === piece && hasPages(p));
381
+ const ch = chrome(opts, pieces, skippedNames, `p/${piece}`, csrf, flash);
382
+ if (!info || path === null || !validPath(path))
383
+ return html(renderPage(notFound, { source: pieceSource(piece), path: '', query: {}, csrf }, ch), 404);
384
+ const page = await piecePage(info, path, query, opts.env);
385
+ const ctx = { source: pieceSource(piece), path, query, csrf };
386
+ if (typeof page === 'string') {
387
+ return html(renderPage({ title: info.manifest.tab?.label ?? piece, sections: [{ id: 'error', blocks: [{ type: 'callout', tone: 'bad', text: `${piece} couldn't show this page (${page}).` }] }] }, ctx, ch), 502);
388
+ }
389
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
390
+ }
391
+ const path = decode(pathname.slice('/ui/'.length)) ?? '\0';
392
+ const top = path.split('/')[0];
393
+ const ch = chrome(opts, pieces, skippedNames, top, csrf, flash);
394
+ const page = validPath(path) ? corePage(opts.contextDir, path, query) : null;
395
+ const ctx = { source: CORE, path: validPath(path) ? path : '', query, csrf };
396
+ if (!page)
397
+ return html(renderPage(notFound, ctx, ch), 404);
398
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
399
+ }
@@ -0,0 +1,37 @@
1
+ import { type ActResult, type Manifest, type Page } from './vocab.js';
2
+ export declare const PIECE_TIMEOUT_MS = 3000;
3
+ export declare const ACT_TIMEOUT_MS = 15000;
4
+ export declare const PIECE_MAX_BYTES: number;
5
+ /** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
6
+ export declare const CACHE_MS = 45000;
7
+ export interface PieceInfo {
8
+ piece: string;
9
+ path: string;
10
+ manifest: Manifest;
11
+ }
12
+ /** Why a piece contributed nothing, for the page footer (never shown as an error). */
13
+ export interface PieceSkip {
14
+ piece: string;
15
+ reason: string;
16
+ }
17
+ export declare function clearPieceCache(path?: string): void;
18
+ export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
19
+ /**
20
+ * At most this many piece processes at once, across every page load: a burst
21
+ * of requests queues instead of forking a process each. The time limit starts
22
+ * when a piece actually runs.
23
+ */
24
+ export declare const MAX_RUNNING = 6;
25
+ /** Every installed piece's manifest, plus the ones that had nothing to show and why. */
26
+ export declare function pieceManifests(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
27
+ pieces: PieceInfo[];
28
+ skipped: PieceSkip[];
29
+ }>;
30
+ /** One page from a piece, or why it couldn't be had. */
31
+ export declare function piecePage(info: PieceInfo, path: string, query: Record<string, string>, env: NodeJS.ProcessEnv): Promise<Page | string>;
32
+ /** Run an action on a piece. Field values go on stdin only. */
33
+ export declare function pieceAct(info: PieceInfo, request: {
34
+ action: string;
35
+ path: string;
36
+ fields: Record<string, string>;
37
+ }, env: NodeJS.ProcessEnv): Promise<ActResult>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Talking to installed pieces for the page: each `loom-<piece>` on PATH is
3
+ * asked `ui --json` for its manifest, `ui page --json` for a page and
4
+ * `ui act --json` to run an action (docs/extensions.md, "The page"). A piece
5
+ * that answers shows up with no configuration; one that doesn't (no `ui`
6
+ * verb, a timeout, bad JSON) simply isn't there. Composes without requiring.
7
+ *
8
+ * Bounded on every axis: one manifest run per piece per cache window however
9
+ * many pages load at once (the in-flight promise is what's cached), a time
10
+ * limit and 512KB per answer, and on a timeout the piece's whole process group
11
+ * is killed (after every run, not just a timeout), and at most MAX_RUNNING
12
+ * pieces run at once. Requests travel on stdin, never argv, so an action's
13
+ * field values (a secret typed into a form) stay out of `ps`.
14
+ *
15
+ * A piece is trusted code running as the same user as the daemon. It gets the
16
+ * daemon's env minus loom's own bearer tokens, but that's hygiene, not a
17
+ * boundary: a same-user process can read the daemon's environment from /proc.
18
+ * Install only pieces you'd trust with the daemon itself.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { listPieces } from '../cli/pieces.js';
22
+ import { parseActResult, parseManifest, parsePage } from './vocab.js';
23
+ export const PIECE_TIMEOUT_MS = 3_000;
24
+ export const ACT_TIMEOUT_MS = 15_000;
25
+ export const PIECE_MAX_BYTES = 512 * 1024;
26
+ /** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
27
+ export const CACHE_MS = 45_000;
28
+ /** After the piece exits, how long to wait for its stdout to close (a background helper may hold it). */
29
+ const DRAIN_MS = 200;
30
+ /** Env loom never hands to a piece. */
31
+ const WITHHELD = ['LOOM_BEARER_TOKEN', 'LOOM_KNOWLEDGE_BEARER_TOKENS'];
32
+ const cache = new Map();
33
+ export function clearPieceCache(path) {
34
+ if (path === undefined)
35
+ cache.clear();
36
+ else
37
+ cache.delete(path);
38
+ }
39
+ export function pieceEnv(env) {
40
+ const out = { ...env };
41
+ for (const k of WITHHELD)
42
+ delete out[k];
43
+ return out;
44
+ }
45
+ function killGroup(pid) {
46
+ if (pid === undefined)
47
+ return;
48
+ try {
49
+ process.kill(-pid, 'SIGKILL');
50
+ }
51
+ catch { /* already gone */ }
52
+ }
53
+ /**
54
+ * At most this many piece processes at once, across every page load: a burst
55
+ * of requests queues instead of forking a process each. The time limit starts
56
+ * when a piece actually runs.
57
+ */
58
+ export const MAX_RUNNING = 6;
59
+ let running = 0;
60
+ const waiting = [];
61
+ async function slot(f) {
62
+ if (running >= MAX_RUNNING)
63
+ await new Promise((r) => waiting.push(r));
64
+ running++;
65
+ try {
66
+ return await f();
67
+ }
68
+ finally {
69
+ running--;
70
+ waiting.shift()?.();
71
+ }
72
+ }
73
+ function ask(path, args, env, opts = {}) {
74
+ return slot(() => run(path, args, env, opts));
75
+ }
76
+ function run(path, args, env, opts) {
77
+ return new Promise((resolve) => {
78
+ // Its own process group, so a timeout takes the piece's children with it.
79
+ const child = spawn(path, args, {
80
+ env: pieceEnv(env),
81
+ stdio: [opts.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'],
82
+ detached: true,
83
+ });
84
+ let out = '';
85
+ let bytes = 0;
86
+ let settled = false;
87
+ let drain;
88
+ const finish = (r) => {
89
+ if (settled)
90
+ return;
91
+ settled = true;
92
+ clearTimeout(timer);
93
+ if (drain)
94
+ clearTimeout(drain);
95
+ child.stdout?.destroy();
96
+ // Every time, not only on a timeout: whatever the piece left running in
97
+ // its group goes with it. (A piece that calls setsid on purpose escapes.)
98
+ killGroup(child.pid);
99
+ resolve(r);
100
+ };
101
+ const timer = setTimeout(() => { killGroup(child.pid); finish({ ok: false, reason: 'timed out' }); }, opts.timeoutMs ?? PIECE_TIMEOUT_MS);
102
+ child.stdout?.on('data', (d) => {
103
+ bytes += d.length;
104
+ if (bytes > PIECE_MAX_BYTES) {
105
+ killGroup(child.pid);
106
+ finish({ ok: false, reason: 'answer too large' });
107
+ return;
108
+ }
109
+ out += d.toString('utf-8');
110
+ });
111
+ child.on('error', (e) => finish({ ok: false, reason: e.message }));
112
+ if (opts.stdin !== undefined && child.stdin) {
113
+ child.stdin.on('error', () => { });
114
+ child.stdin.end(opts.stdin);
115
+ }
116
+ // Resolve on the piece's own exit (plus a short drain), not on 'close':
117
+ // 'close' waits for every holder of the pipe, and a helper the piece left
118
+ // running would hold it open past the timeout.
119
+ child.on('exit', (code) => {
120
+ const done = () => finish(code === 0 ? { ok: true, stdout: out } : { ok: false, reason: 'no ui' });
121
+ if (child.stdout?.readableEnded)
122
+ return done();
123
+ child.stdout?.once('end', done);
124
+ drain = setTimeout(done, DRAIN_MS);
125
+ });
126
+ });
127
+ }
128
+ function json(stdout) {
129
+ try {
130
+ return JSON.parse(stdout);
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ }
136
+ async function fetchManifest(piece, path, env) {
137
+ const r = await ask(path, ['ui', '--json'], env);
138
+ if (!r.ok)
139
+ return { piece, reason: r.reason };
140
+ const parsed = json(r.stdout);
141
+ if (parsed === undefined)
142
+ return { piece, reason: 'invalid: not JSON' };
143
+ const m = parseManifest(parsed);
144
+ return typeof m === 'string' ? { piece, reason: `invalid: ${m}` } : { piece, path, manifest: m };
145
+ }
146
+ function manifestFor(piece, path, env, nowMs) {
147
+ const hit = cache.get(path);
148
+ if (hit && nowMs - hit.at < CACHE_MS)
149
+ return hit.result;
150
+ // Cache the promise itself: concurrent page loads share one run.
151
+ const result = fetchManifest(piece, path, env);
152
+ cache.set(path, { at: nowMs, result });
153
+ return result;
154
+ }
155
+ /** Every installed piece's manifest, plus the ones that had nothing to show and why. */
156
+ export async function pieceManifests(env, nowMs = Date.now()) {
157
+ // "loom" is loom's own name on the page; a piece can't take it.
158
+ const installed = listPieces(env).filter((p) => p.name !== 'loom');
159
+ const results = await Promise.all(installed.map((p) => manifestFor(p.name, p.path, env, nowMs)));
160
+ const pieces = results.filter((r) => 'manifest' in r);
161
+ const empty = pieces.filter((p) => !p.manifest.tab && !p.manifest.tile && !p.manifest.asks.length && !p.manifest.legacy?.length);
162
+ return {
163
+ pieces: pieces.filter((p) => !empty.includes(p)),
164
+ skipped: [...results.filter((r) => 'reason' in r), ...empty.map((p) => ({ piece: p.piece, reason: 'nothing to show' }))],
165
+ };
166
+ }
167
+ /** One page from a piece, or why it couldn't be had. */
168
+ export async function piecePage(info, path, query, env) {
169
+ // A first-version piece has one page: its sections.
170
+ if (info.manifest.legacy) {
171
+ return path === '' ? { title: info.piece, sections: info.manifest.legacy } : { title: 'Not found', sections: [] };
172
+ }
173
+ const r = await ask(info.path, ['ui', 'page', '--json'], env, { stdin: JSON.stringify({ path, query }) });
174
+ if (!r.ok)
175
+ return r.reason;
176
+ const parsed = json(r.stdout);
177
+ if (parsed === undefined)
178
+ return 'not JSON';
179
+ return parsePage(parsed);
180
+ }
181
+ /** Run an action on a piece. Field values go on stdin only. */
182
+ export async function pieceAct(info, request, env) {
183
+ const r = await ask(info.path, ['ui', 'act', '--json'], env, { stdin: JSON.stringify(request), timeoutMs: ACT_TIMEOUT_MS });
184
+ // Whatever happened, the piece's state may have changed.
185
+ clearPieceCache(info.path);
186
+ if (!r.ok)
187
+ return { ok: false, message: `${info.piece} didn't answer (${r.reason}).` };
188
+ const parsed = json(r.stdout);
189
+ const result = parsed === undefined ? 'not JSON' : parseActResult(parsed);
190
+ return typeof result === 'string' ? { ok: false, message: `${info.piece} sent an answer loom couldn't read (${result}).` } : result;
191
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Server-side HTML for loom's page. Every string that reaches the page goes
3
+ * through esc(): pieces send data in the page vocabulary, never markup. No
4
+ * client script anywhere; folding is <details>, filters are links, and every
5
+ * action is a plain form post carrying the anti-forgery token.
6
+ */
7
+ import type { Ask, Page, Tile } from './vocab.js';
8
+ export declare function esc(s: string): string;
9
+ /** Whose page this is: loom's own (`loom`) or a piece's. */
10
+ export interface Source {
11
+ id: string;
12
+ base: string;
13
+ }
14
+ export declare const CORE: Source;
15
+ export declare const pieceSource: (piece: string) => Source;
16
+ export interface RenderContext {
17
+ source: Source;
18
+ /** This page's path within its source, and its query, for filters and for returning after an action. */
19
+ path: string;
20
+ query: Record<string, string>;
21
+ csrf: string;
22
+ }
23
+ /** Where a page link goes. Pieces' links stay inside their own base unless they say `loom:`. */
24
+ export declare function pageHref(link: string, source: Source): string;
25
+ export interface Chrome {
26
+ agent: string;
27
+ nav: Array<{
28
+ label: string;
29
+ href: string;
30
+ current: boolean;
31
+ piece: boolean;
32
+ }>;
33
+ clock: string;
34
+ csrf: string;
35
+ /** The one-time result of an action; `from` names the piece that sent it. */
36
+ flash?: {
37
+ ok: boolean;
38
+ message: string;
39
+ from?: string;
40
+ };
41
+ skipped: string[];
42
+ }
43
+ /** One page, loom's or a piece's. */
44
+ export declare function renderPage(page: Page, ctx: RenderContext, chrome: Chrome): string;
45
+ export interface HomeTile extends Tile {
46
+ source: Source;
47
+ }
48
+ export interface HomeAsk extends Ask {
49
+ source: Source;
50
+ }
51
+ /** Home: tiles across the top, the tape as the page, "Your call" leading the side column. */
52
+ export declare function renderHome(page: Page, ctx: RenderContext, chrome: Chrome, tiles: HomeTile[], asks: HomeAsk[]): string;
53
+ export declare function renderLogin(error?: string): string;