@jbarket/loomai 0.9.2 → 0.11.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.
- package/CHANGELOG.md +69 -1
- package/README.md +29 -0
- package/dist/backends/migrations.js +9 -0
- package/dist/backends/people-household.d.ts +77 -0
- package/dist/backends/people-household.js +305 -0
- package/dist/backends/people.d.ts +37 -1
- package/dist/backends/people.js +42 -7
- package/dist/cli/args.js +2 -0
- package/dist/cli/household.d.ts +3 -0
- package/dist/cli/household.js +159 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/serve.js +9 -2
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +16 -1
- package/dist/household.d.ts +117 -0
- package/dist/household.js +459 -0
- package/dist/injection/writer.js +5 -2
- package/dist/path-safety.d.ts +1 -1
- package/dist/path-safety.js +5 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +6 -4
- package/dist/tools/harness.d.ts +17 -0
- package/dist/tools/harness.js +45 -2
- package/dist/tools/person.d.ts +1 -0
- package/dist/tools/person.js +19 -6
- package/dist/transport/http-server.d.ts +7 -0
- package/dist/transport/http-server.js +156 -20
- package/dist/ui/handler.d.ts +15 -2
- package/dist/ui/handler.js +22 -10
- package/package.json +1 -1
package/dist/tools/person.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* loom tool. The CLI (`loom person`) calls the read functions here too.
|
|
10
10
|
*/
|
|
11
11
|
import { resolveIdentityName } from '../config.js';
|
|
12
|
-
import {
|
|
12
|
+
import { householdOf } from '../household.js';
|
|
13
|
+
import { openHouseholdPeople } from '../backends/people-household.js';
|
|
14
|
+
import { openPeopleStore, PeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
|
|
13
15
|
const json = (v) => JSON.stringify(v, null, 2);
|
|
14
16
|
/** Slugs validate before anything else, store or no store (criterion 11). */
|
|
15
17
|
function assertSlug(slug) {
|
|
@@ -22,9 +24,20 @@ function errorText(e) {
|
|
|
22
24
|
return `Error: ${e.message}`;
|
|
23
25
|
return `Error: ${e.message ?? String(e)}`;
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The people a context dir sees. In a household: the commons directory plus
|
|
29
|
+
* this agent's relationships (backends/people-household.ts). Otherwise the
|
|
30
|
+
* one store in the agent's memories.db, as before.
|
|
31
|
+
*/
|
|
32
|
+
function openPeople(contextDir, opts = {}) {
|
|
33
|
+
const hh = householdOf(contextDir);
|
|
34
|
+
if (hh)
|
|
35
|
+
return openHouseholdPeople(hh.household, hh.agent, contextDir, opts);
|
|
36
|
+
return openPeopleStore(contextDir, opts);
|
|
37
|
+
}
|
|
25
38
|
function withWritable(contextDir, fn) {
|
|
26
39
|
try {
|
|
27
|
-
const store =
|
|
40
|
+
const store = openPeople(contextDir);
|
|
28
41
|
try {
|
|
29
42
|
return json(fn(store));
|
|
30
43
|
}
|
|
@@ -50,12 +63,12 @@ export async function personRestore(contextDir, input) {
|
|
|
50
63
|
return withWritable(contextDir, (s) => s.restore(input));
|
|
51
64
|
}
|
|
52
65
|
export async function personRevisionRestore(contextDir, input) {
|
|
53
|
-
return withWritable(contextDir, (s) => s.revisionRestore(input));
|
|
66
|
+
return withWritable(contextDir, (s) => (s instanceof PeopleStore ? s.revisionRestore({ slug: input.slug, revision_id: input.revision_id }) : s.revisionRestore(input)));
|
|
54
67
|
}
|
|
55
68
|
/** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
|
|
56
69
|
export function readPerson(contextDir, slug, includeArchived = false) {
|
|
57
70
|
assertSlug(slug);
|
|
58
|
-
const store =
|
|
71
|
+
const store = openPeople(contextDir, { readonly: true });
|
|
59
72
|
if (!store)
|
|
60
73
|
return { found: false };
|
|
61
74
|
try {
|
|
@@ -66,7 +79,7 @@ export function readPerson(contextDir, slug, includeArchived = false) {
|
|
|
66
79
|
}
|
|
67
80
|
}
|
|
68
81
|
export function listPeople(contextDir, includeArchived = false) {
|
|
69
|
-
const store =
|
|
82
|
+
const store = openPeople(contextDir, { readonly: true });
|
|
70
83
|
if (!store)
|
|
71
84
|
return [];
|
|
72
85
|
try {
|
|
@@ -95,7 +108,7 @@ export async function personList(contextDir, input) {
|
|
|
95
108
|
export async function personHistory(contextDir, input) {
|
|
96
109
|
try {
|
|
97
110
|
assertSlug(input.slug);
|
|
98
|
-
const store =
|
|
111
|
+
const store = openPeople(contextDir, { readonly: true });
|
|
99
112
|
if (!store)
|
|
100
113
|
return json([]);
|
|
101
114
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { type Household } from '../household.js';
|
|
2
3
|
export interface HttpServeOptions {
|
|
3
4
|
contextDir: string;
|
|
4
5
|
host: string;
|
|
@@ -34,6 +35,12 @@ export interface HttpServeOptions {
|
|
|
34
35
|
}) => {
|
|
35
36
|
server: McpServer;
|
|
36
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Household mode: tokens resolve to roles from household.home/tokens.json
|
|
40
|
+
* (plus `token`, if set, as an owner token). Mutually exclusive with
|
|
41
|
+
* `tokens` and `createServer`; `contextDir` should be the default agent's.
|
|
42
|
+
*/
|
|
43
|
+
household?: Household;
|
|
37
44
|
}
|
|
38
45
|
export interface HttpServeHandle {
|
|
39
46
|
/** The actually-bound port (resolves a 0 request to the OS-assigned port). */
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* bound to the session at open time (a later request on the same
|
|
24
24
|
* session presenting a DIFFERENT identity's token is refused, not
|
|
25
25
|
* silently re-attributed).
|
|
26
|
+
* - `household` (docs/specs/household.md) — one daemon for many agents.
|
|
27
|
+
* Each token in $LOOM_HOME/tokens.json has a role: `agent:<name>` gets a
|
|
28
|
+
* full server over that agent's context dir; `owner` gets the default
|
|
29
|
+
* agent's (or another's under `/agents/<name>/…`) and the page; `peer`
|
|
30
|
+
* gets the knowledge-only server. LOOM_BEARER_TOKEN, when set, is an
|
|
31
|
+
* owner token too. The role is resolved on every request and a session
|
|
32
|
+
* is pinned to the role and agent it opened with.
|
|
26
33
|
*/
|
|
27
34
|
import { randomUUID } from 'node:crypto';
|
|
28
35
|
import { createServer } from 'node:http';
|
|
@@ -32,7 +39,11 @@ import { EmptyResultSchema } from '@modelcontextprotocol/core';
|
|
|
32
39
|
import { createLoomServer } from '../server.js';
|
|
33
40
|
import { createBackend } from '../backends/index.js';
|
|
34
41
|
import { handleCliBody } from './cli-endpoint.js';
|
|
35
|
-
import { handleUi, isUiRequest } from '../ui/handler.js';
|
|
42
|
+
import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
|
|
43
|
+
import { createKnowledgeOnlyServer } from './knowledge-server.js';
|
|
44
|
+
import { hasUnsplitPeople } from '../backends/people-household.js';
|
|
45
|
+
import { agentDir, assertRealAgentDir, commonsProblem, roleString, TokenResolver } from '../household.js';
|
|
46
|
+
import { createHash, timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto';
|
|
36
47
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
37
48
|
/**
|
|
38
49
|
* SSE keep-alive: the SDK opens the standalone GET stream but sends no
|
|
@@ -119,6 +130,67 @@ export async function startHttpServer(opts) {
|
|
|
119
130
|
if (opts.token && opts.tokens) {
|
|
120
131
|
throw new Error('startHttpServer: pass either token or tokens, not both');
|
|
121
132
|
}
|
|
133
|
+
if (opts.household && (opts.tokens || opts.createServer)) {
|
|
134
|
+
throw new Error('startHttpServer: household mode resolves roles itself; pass neither tokens nor createServer');
|
|
135
|
+
}
|
|
136
|
+
const household = opts.household;
|
|
137
|
+
if (household) {
|
|
138
|
+
// Each of these points every agent at one store or one name, which would
|
|
139
|
+
// quietly merge the household. Refuse rather than serve it that way.
|
|
140
|
+
for (const v of ['LOOM_SQLITE_DB_PATH', 'LOOM_KNOWLEDGE_DB_PATH', 'LOOM_IDENTITY']) {
|
|
141
|
+
if (process.env[v])
|
|
142
|
+
throw new Error(`startHttpServer: ${v} is set; a household gives each agent its own store and name, so unset it`);
|
|
143
|
+
}
|
|
144
|
+
for (const a of household.agents)
|
|
145
|
+
assertRealAgentDir(household, a);
|
|
146
|
+
const problem = commonsProblem(household);
|
|
147
|
+
if (problem)
|
|
148
|
+
throw new Error(`startHttpServer: ${problem}`);
|
|
149
|
+
for (const a of household.agents) {
|
|
150
|
+
if (hasUnsplitPeople(agentDir(household, a))) {
|
|
151
|
+
throw new Error(`startHttpServer: agents/${a}'s person cards haven't been split into the commons; with every loom daemon stopped, run: loom household commons`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const resolver = household ? new TokenResolver(household) : undefined;
|
|
156
|
+
const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
|
|
157
|
+
/** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
|
|
158
|
+
function callerFor(header, target) {
|
|
159
|
+
const presented = (header ?? '').replace(/^Bearer\s+/i, '').trim();
|
|
160
|
+
if (!presented)
|
|
161
|
+
return { status: 401, error: 'unauthorized: missing bearer token' };
|
|
162
|
+
// The env owner token first: a broken tokens.json must not lock it out.
|
|
163
|
+
let role = null;
|
|
164
|
+
if (ownerDigest && cryptoTimingSafeEqual(createHash('sha256').update(presented).digest(), ownerDigest))
|
|
165
|
+
role = { kind: 'owner' };
|
|
166
|
+
if (!role) {
|
|
167
|
+
try {
|
|
168
|
+
role = resolver.resolve(presented);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
// Fail closed, and keep the details (paths) out of the response.
|
|
172
|
+
process.stderr.write(`loom: household tokens unreadable: ${err.message}\n`);
|
|
173
|
+
return { status: 503, error: 'unavailable: the household token file is unreadable; see the daemon log' };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!role)
|
|
177
|
+
return { status: 401, error: 'unauthorized: bearer token not recognized' };
|
|
178
|
+
const h = household;
|
|
179
|
+
if (role.kind === 'peer') {
|
|
180
|
+
if (target)
|
|
181
|
+
return { status: 403, error: 'forbidden: a peer token reaches the commons only' };
|
|
182
|
+
return { role, agent: h.default, contextDir: agentDir(h, h.default), key: `${roleString(role)}>${h.default}` };
|
|
183
|
+
}
|
|
184
|
+
if (role.kind === 'agent') {
|
|
185
|
+
if (target && target !== role.agent)
|
|
186
|
+
return { status: 403, error: `forbidden: this token is ${role.agent}'s` };
|
|
187
|
+
return { role, agent: role.agent, contextDir: agentDir(h, role.agent), key: `${roleString(role)}>${role.agent}` };
|
|
188
|
+
}
|
|
189
|
+
const agent = target ?? h.default;
|
|
190
|
+
if (!h.agents.includes(agent))
|
|
191
|
+
return { status: 404, error: `not-found: no agent named ${agent}` };
|
|
192
|
+
return { role, agent, contextDir: agentDir(h, agent), key: `owner>${agent}` };
|
|
193
|
+
}
|
|
122
194
|
// A scoped server factory (e.g. the knowledge-only service) always needs
|
|
123
195
|
// to know WHO is calling for attribution — refuse to boot it open or on a
|
|
124
196
|
// single shared secret with no identity behind it.
|
|
@@ -140,15 +212,52 @@ export async function startHttpServer(opts) {
|
|
|
140
212
|
// a session three hops later. Scoped to the default server only — a scoped
|
|
141
213
|
// createServer (e.g. the knowledge-only service) never touches this
|
|
142
214
|
// backend, so forcing it there would just pay for an unused embedder load.
|
|
143
|
-
if (
|
|
215
|
+
if (household)
|
|
216
|
+
for (const a of household.agents)
|
|
217
|
+
createBackend(agentDir(household, a));
|
|
218
|
+
else if (!opts.createServer)
|
|
144
219
|
createBackend(opts.contextDir);
|
|
145
|
-
|
|
220
|
+
// The page is the owner's: in a household, only owner tokens log in to it.
|
|
221
|
+
const uiAuth = household
|
|
222
|
+
? {
|
|
223
|
+
enabled: true,
|
|
224
|
+
check: (t) => {
|
|
225
|
+
const d = createHash('sha256').update(t).digest();
|
|
226
|
+
if (ownerDigest && cryptoTimingSafeEqual(d, ownerDigest))
|
|
227
|
+
return d;
|
|
228
|
+
try {
|
|
229
|
+
return resolver.resolve(t)?.kind === 'owner' ? d : null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
live: (d) => {
|
|
236
|
+
if (ownerDigest !== undefined && cryptoTimingSafeEqual(d, ownerDigest))
|
|
237
|
+
return true;
|
|
238
|
+
try {
|
|
239
|
+
return resolver.ownerDigestLive(d.toString('hex'));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: legacyUiAuth(opts.token ? [opts.token] : Object.values(opts.tokens ?? {}));
|
|
247
|
+
const makeServer = opts.createServer ?? ((ctx) => createLoomServer({ ...ctx, remote: true }));
|
|
248
|
+
const serverFor = (c, identity) => {
|
|
249
|
+
if (!c)
|
|
250
|
+
return makeServer({ contextDir: opts.contextDir, gapsDir: opts.gapsDir, identity });
|
|
251
|
+
if (c.role.kind === 'peer')
|
|
252
|
+
return createKnowledgeOnlyServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, identity: c.role.name });
|
|
253
|
+
return createLoomServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, remote: true });
|
|
254
|
+
};
|
|
146
255
|
// One transport (+ its connected server) per live MCP session, plus the
|
|
147
256
|
// identity it was opened for — a multi-token session is pinned to that
|
|
148
257
|
// identity for its lifetime (see the mismatch check in the request handler).
|
|
149
258
|
const sessions = new Map();
|
|
150
|
-
async function openSession(identity) {
|
|
151
|
-
const { server } =
|
|
259
|
+
async function openSession(identity, caller) {
|
|
260
|
+
const { server } = serverFor(caller, identity);
|
|
152
261
|
// Fires AFTER the initialize handshake completes — getClientVersion() is
|
|
153
262
|
// populated here (it is not yet at onsessioninitialized). Logging the peer
|
|
154
263
|
// makes the resolved harness observable and reveals an unmapped client's
|
|
@@ -200,26 +309,53 @@ export async function startHttpServer(opts) {
|
|
|
200
309
|
// loom's web page (src/ui), at the root. Its own auth (a session from
|
|
201
310
|
// the bearer, or the bearer header), so it comes before the bearer gate.
|
|
202
311
|
// Full scope only: a scoped server has no memory to show.
|
|
203
|
-
const
|
|
204
|
-
if (isUiRequest(req,
|
|
312
|
+
const fullPath = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
313
|
+
if (isUiRequest(req, fullPath)) {
|
|
205
314
|
if (opts.createServer)
|
|
206
315
|
return sendError(res, 404, 'not-found: this server has no UI');
|
|
207
|
-
return handleUi(req, res,
|
|
316
|
+
return handleUi(req, res, fullPath, {
|
|
208
317
|
contextDir: opts.contextDir,
|
|
209
|
-
|
|
318
|
+
auth: uiAuth,
|
|
210
319
|
env: process.env,
|
|
211
320
|
});
|
|
212
321
|
}
|
|
322
|
+
// Household: `/agents/<name>/…` names the agent (the owner's way to
|
|
323
|
+
// reach a non-default one; an agent's own token may use its own name).
|
|
324
|
+
const scoped = household ? /^\/agents\/([a-z][a-z0-9-]{0,39})(\/.*)?$/.exec(fullPath) : null;
|
|
325
|
+
// A path under /agents/ that doesn't name an agent cleanly is a mistake,
|
|
326
|
+
// not a request for the default agent.
|
|
327
|
+
if (household && !scoped && (fullPath === '/agents' || fullPath.startsWith('/agents/'))) {
|
|
328
|
+
return sendError(res, 404, 'not-found: /agents/<name>/ takes a lowercase agent name');
|
|
329
|
+
}
|
|
330
|
+
const target = scoped?.[1];
|
|
331
|
+
const path = scoped ? (scoped[2] ?? '/') : fullPath;
|
|
332
|
+
// In a household the only routes past the page are MCP and /cli: any
|
|
333
|
+
// other path (/AGENTS/mark/mcp, /x/agents/mark/mcp) is a 404, not the
|
|
334
|
+
// default agent.
|
|
335
|
+
if (household && path !== '/' && path !== '/mcp' && path !== '/cli') {
|
|
336
|
+
return sendError(res, 404, 'not-found: in a household, use /, /mcp or /cli, optionally under /agents/<name>/');
|
|
337
|
+
}
|
|
213
338
|
// Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
|
|
214
339
|
// token map) is configured. Multi-token mode additionally resolves
|
|
215
340
|
// WHICH identity made this call — the bearer itself is the identity
|
|
216
341
|
// claim (t-677); there is no separate login step.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
342
|
+
let caller;
|
|
343
|
+
let identity;
|
|
344
|
+
if (household) {
|
|
345
|
+
const c = callerFor(req.headers['authorization'], target);
|
|
346
|
+
if ('status' in c)
|
|
347
|
+
return sendError(res, c.status, c.error);
|
|
348
|
+
caller = c;
|
|
349
|
+
identity = c.key;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const auth = opts.tokens
|
|
353
|
+
? checkBearerMulti(opts.tokens, req.headers['authorization'])
|
|
354
|
+
: checkBearer(opts.token, req.headers['authorization']);
|
|
355
|
+
if (!auth.ok)
|
|
356
|
+
return sendError(res, 401, auth.error);
|
|
357
|
+
identity = auth.identity ?? 'art';
|
|
358
|
+
}
|
|
223
359
|
// Oversized guard (ac-lt-oversized-guard): fast path on declared length.
|
|
224
360
|
const declared = Number(req.headers['content-length'] ?? 0);
|
|
225
361
|
if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
|
|
@@ -228,15 +364,15 @@ export async function startHttpServer(opts) {
|
|
|
228
364
|
// POST /cli: the CLI's remote mode (cli-endpoint.ts). Same gate and cap
|
|
229
365
|
// as /mcp above. Full server only: a scoped server (knowledge-only)
|
|
230
366
|
// can't offer the memory verbs, so it has no /cli at all.
|
|
231
|
-
if (
|
|
232
|
-
if (opts.createServer)
|
|
367
|
+
if (path === '/cli') {
|
|
368
|
+
if (opts.createServer || caller?.role.kind === 'peer')
|
|
233
369
|
return sendError(res, 404, 'not-found: this server has no CLI endpoint');
|
|
234
370
|
// Unlike /mcp (initialize handshake + mcp-session-id header), a bare
|
|
235
371
|
// POST could be a browser's "simple" cross-site request. So /cli needs
|
|
236
372
|
// a configured token, a JSON content type (forces a CORS preflight,
|
|
237
373
|
// which this server never grants), and no Origin header at all: the
|
|
238
374
|
// CLI never sends one, and a browser always does.
|
|
239
|
-
if (!opts.token && !opts.tokens)
|
|
375
|
+
if (!opts.token && !opts.tokens && !household)
|
|
240
376
|
return sendError(res, 403, 'forbidden: /cli requires LOOM_BEARER_TOKEN');
|
|
241
377
|
if (req.headers['origin'] !== undefined)
|
|
242
378
|
return sendError(res, 403, 'forbidden: /cli refuses browser requests');
|
|
@@ -255,7 +391,7 @@ export async function startHttpServer(opts) {
|
|
|
255
391
|
catch (e) {
|
|
256
392
|
return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
|
|
257
393
|
}
|
|
258
|
-
const outcome = await handleCliBody(opts.contextDir, parsed);
|
|
394
|
+
const outcome = await handleCliBody(caller?.contextDir ?? opts.contextDir, parsed);
|
|
259
395
|
if (!outcome.ok)
|
|
260
396
|
return sendError(res, 400, outcome.error);
|
|
261
397
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -319,7 +455,7 @@ export async function startHttpServer(opts) {
|
|
|
319
455
|
if (!isInitializeRequest(parsed)) {
|
|
320
456
|
return sendError(res, 400, 'bad-input: no valid session; expected an initialize request');
|
|
321
457
|
}
|
|
322
|
-
transport = await openSession(identity);
|
|
458
|
+
transport = await openSession(identity, caller);
|
|
323
459
|
}
|
|
324
460
|
// Dispatch totality + typed envelopes (unknown tool / bad args -> JSON-RPC
|
|
325
461
|
// error) are the SDK server's job from here — never a raw throw out.
|
package/dist/ui/handler.d.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
export interface UiOptions {
|
|
3
3
|
contextDir: string;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/** Who may log in to the page. */
|
|
5
|
+
auth: UiAuth;
|
|
6
6
|
/** Env for piece discovery and piece processes (PATH decides which pieces exist). */
|
|
7
7
|
env: NodeJS.ProcessEnv;
|
|
8
8
|
}
|
|
9
9
|
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
10
10
|
export declare function isUiRequest(req: IncomingMessage, pathname: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Who may log in. `check` answers a presented token with its digest when the
|
|
13
|
+
* token may use the page, `live` says whether a digest still may (so revoking
|
|
14
|
+
* or rotating the token ends its sessions). `enabled` false means no token is
|
|
15
|
+
* configured, and the page refuses to serve.
|
|
16
|
+
*/
|
|
17
|
+
export interface UiAuth {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
check(token: string): Buffer | null;
|
|
20
|
+
live(digest: Buffer): boolean;
|
|
21
|
+
}
|
|
22
|
+
/** A fixed list of tokens (a single bearer, or a token map's values). */
|
|
23
|
+
export declare function legacyUiAuth(secrets: string[]): UiAuth;
|
|
11
24
|
/** Handle a request for the page. The caller has already matched isUiRequest. */
|
|
12
25
|
export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
|
package/dist/ui/handler.js
CHANGED
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
* message is kept in memory under a random id for a minute.
|
|
34
34
|
*/
|
|
35
35
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
36
|
-
import { checkBearer } from '../transport/guards.js';
|
|
37
36
|
import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
|
|
38
37
|
import { pieceAct, pieceManifests, piecePage } from './pieces.js';
|
|
39
38
|
import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
|
|
@@ -72,9 +71,21 @@ export function isUiRequest(req, pathname) {
|
|
|
72
71
|
const digest = (s) => createHash('sha256').update(s).digest();
|
|
73
72
|
const sessions = new Map();
|
|
74
73
|
const MAX_SESSIONS = 50;
|
|
75
|
-
|
|
74
|
+
/** A fixed list of tokens (a single bearer, or a token map's values). */
|
|
75
|
+
export function legacyUiAuth(secrets) {
|
|
76
|
+
const digests = secrets.map((t) => digest(t));
|
|
77
|
+
return {
|
|
78
|
+
enabled: secrets.length > 0,
|
|
79
|
+
check: (t) => {
|
|
80
|
+
const d = digest(t);
|
|
81
|
+
return digests.some((x) => timingSafeEqual(x, d)) ? d : null;
|
|
82
|
+
},
|
|
83
|
+
live: (d) => digests.some((x) => timingSafeEqual(x, d)),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function newSession(tokenDigest) {
|
|
76
87
|
const id = randomBytes(32).toString('hex');
|
|
77
|
-
sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret:
|
|
88
|
+
sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: tokenDigest });
|
|
78
89
|
// Oldest first in a Map: drop the oldest past the cap.
|
|
79
90
|
while (sessions.size > MAX_SESSIONS)
|
|
80
91
|
sessions.delete(sessions.keys().next().value);
|
|
@@ -88,7 +99,7 @@ function csrfFor(id) {
|
|
|
88
99
|
function safeEq(a, b) {
|
|
89
100
|
return timingSafeEqual(digest(a), digest(b));
|
|
90
101
|
}
|
|
91
|
-
function validSession(id,
|
|
102
|
+
function validSession(id, auth, nowS = Math.floor(Date.now() / 1000)) {
|
|
92
103
|
if (!/^[0-9a-f]{64}$/.test(id))
|
|
93
104
|
return false;
|
|
94
105
|
const s = sessions.get(id);
|
|
@@ -99,7 +110,7 @@ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
|
|
|
99
110
|
return false;
|
|
100
111
|
}
|
|
101
112
|
// A session dies with its token: rotate the token and old sessions stop.
|
|
102
|
-
return
|
|
113
|
+
return auth.live(s.secret);
|
|
103
114
|
}
|
|
104
115
|
function readCookie(req, name) {
|
|
105
116
|
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
@@ -110,13 +121,14 @@ function readCookie(req, name) {
|
|
|
110
121
|
return undefined;
|
|
111
122
|
}
|
|
112
123
|
function whoIs(req, opts) {
|
|
113
|
-
if (opts.
|
|
124
|
+
if (!opts.auth.enabled)
|
|
114
125
|
return null;
|
|
115
126
|
const c = readCookie(req, COOKIE);
|
|
116
|
-
if (c && validSession(c, opts.
|
|
127
|
+
if (c && validSession(c, opts.auth))
|
|
117
128
|
return { kind: 'session', cookie: c };
|
|
118
129
|
const header = req.headers['authorization'];
|
|
119
|
-
|
|
130
|
+
const presented = header?.replace(/^Bearer\s+/i, '').trim();
|
|
131
|
+
if (presented && opts.auth.check(presented))
|
|
120
132
|
return { kind: 'bearer' };
|
|
121
133
|
return null;
|
|
122
134
|
}
|
|
@@ -269,7 +281,7 @@ function decode(s) {
|
|
|
269
281
|
export async function handleUi(req, res, pathname, opts) {
|
|
270
282
|
// No token, no page: an open /ui would be readable by any site that
|
|
271
283
|
// DNS-rebinds a name to the loopback address (the same rule as /cli).
|
|
272
|
-
if (opts.
|
|
284
|
+
if (!opts.auth.enabled) {
|
|
273
285
|
return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
|
|
274
286
|
}
|
|
275
287
|
// The page used to live under /ui. Old links and bookmarks land on the same page.
|
|
@@ -294,7 +306,7 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
294
306
|
if (!form)
|
|
295
307
|
return send(res, 413, 'text/plain', 'too large');
|
|
296
308
|
const token = form.get('token')?.trim() ?? '';
|
|
297
|
-
const match = opts.
|
|
309
|
+
const match = token ? opts.auth.check(token) : null;
|
|
298
310
|
if (!match)
|
|
299
311
|
return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
|
|
300
312
|
return redirect(res, '/', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|