@jbarket/loomai 0.9.1 → 0.10.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.
@@ -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,10 @@ 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 { agentDir, assertRealAgentDir, roleString, TokenResolver } from '../household.js';
45
+ import { createHash, timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto';
36
46
  import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
37
47
  /**
38
48
  * SSE keep-alive: the SDK opens the standalone GET stream but sends no
@@ -119,6 +129,59 @@ export async function startHttpServer(opts) {
119
129
  if (opts.token && opts.tokens) {
120
130
  throw new Error('startHttpServer: pass either token or tokens, not both');
121
131
  }
132
+ if (opts.household && (opts.tokens || opts.createServer)) {
133
+ throw new Error('startHttpServer: household mode resolves roles itself; pass neither tokens nor createServer');
134
+ }
135
+ const household = opts.household;
136
+ if (household) {
137
+ // Each of these points every agent at one store or one name, which would
138
+ // quietly merge the household. Refuse rather than serve it that way.
139
+ for (const v of ['LOOM_SQLITE_DB_PATH', 'LOOM_KNOWLEDGE_DB_PATH', 'LOOM_IDENTITY']) {
140
+ if (process.env[v])
141
+ throw new Error(`startHttpServer: ${v} is set; a household gives each agent its own store and name, so unset it`);
142
+ }
143
+ for (const a of household.agents)
144
+ assertRealAgentDir(household, a);
145
+ }
146
+ const resolver = household ? new TokenResolver(household) : undefined;
147
+ const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
148
+ /** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
149
+ function callerFor(header, target) {
150
+ const presented = (header ?? '').replace(/^Bearer\s+/i, '').trim();
151
+ if (!presented)
152
+ return { status: 401, error: 'unauthorized: missing bearer token' };
153
+ // The env owner token first: a broken tokens.json must not lock it out.
154
+ let role = null;
155
+ if (ownerDigest && cryptoTimingSafeEqual(createHash('sha256').update(presented).digest(), ownerDigest))
156
+ role = { kind: 'owner' };
157
+ if (!role) {
158
+ try {
159
+ role = resolver.resolve(presented);
160
+ }
161
+ catch (err) {
162
+ // Fail closed, and keep the details (paths) out of the response.
163
+ process.stderr.write(`loom: household tokens unreadable: ${err.message}\n`);
164
+ return { status: 503, error: 'unavailable: the household token file is unreadable; see the daemon log' };
165
+ }
166
+ }
167
+ if (!role)
168
+ return { status: 401, error: 'unauthorized: bearer token not recognized' };
169
+ const h = household;
170
+ if (role.kind === 'peer') {
171
+ if (target)
172
+ return { status: 403, error: 'forbidden: a peer token reaches the commons only' };
173
+ return { role, agent: h.default, contextDir: agentDir(h, h.default), key: `${roleString(role)}>${h.default}` };
174
+ }
175
+ if (role.kind === 'agent') {
176
+ if (target && target !== role.agent)
177
+ return { status: 403, error: `forbidden: this token is ${role.agent}'s` };
178
+ return { role, agent: role.agent, contextDir: agentDir(h, role.agent), key: `${roleString(role)}>${role.agent}` };
179
+ }
180
+ const agent = target ?? h.default;
181
+ if (!h.agents.includes(agent))
182
+ return { status: 404, error: `not-found: no agent named ${agent}` };
183
+ return { role, agent, contextDir: agentDir(h, agent), key: `owner>${agent}` };
184
+ }
122
185
  // A scoped server factory (e.g. the knowledge-only service) always needs
123
186
  // to know WHO is calling for attribution — refuse to boot it open or on a
124
187
  // single shared secret with no identity behind it.
@@ -140,15 +203,52 @@ export async function startHttpServer(opts) {
140
203
  // a session three hops later. Scoped to the default server only — a scoped
141
204
  // createServer (e.g. the knowledge-only service) never touches this
142
205
  // backend, so forcing it there would just pay for an unused embedder load.
143
- if (!opts.createServer)
206
+ if (household)
207
+ for (const a of household.agents)
208
+ createBackend(agentDir(household, a));
209
+ else if (!opts.createServer)
144
210
  createBackend(opts.contextDir);
145
- const makeServer = opts.createServer ?? ((ctx) => createLoomServer(ctx));
211
+ // The page is the owner's: in a household, only owner tokens log in to it.
212
+ const uiAuth = household
213
+ ? {
214
+ enabled: true,
215
+ check: (t) => {
216
+ const d = createHash('sha256').update(t).digest();
217
+ if (ownerDigest && cryptoTimingSafeEqual(d, ownerDigest))
218
+ return d;
219
+ try {
220
+ return resolver.resolve(t)?.kind === 'owner' ? d : null;
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ },
226
+ live: (d) => {
227
+ if (ownerDigest !== undefined && cryptoTimingSafeEqual(d, ownerDigest))
228
+ return true;
229
+ try {
230
+ return resolver.ownerDigestLive(d.toString('hex'));
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ },
236
+ }
237
+ : legacyUiAuth(opts.token ? [opts.token] : Object.values(opts.tokens ?? {}));
238
+ const makeServer = opts.createServer ?? ((ctx) => createLoomServer({ ...ctx, remote: true }));
239
+ const serverFor = (c, identity) => {
240
+ if (!c)
241
+ return makeServer({ contextDir: opts.contextDir, gapsDir: opts.gapsDir, identity });
242
+ if (c.role.kind === 'peer')
243
+ return createKnowledgeOnlyServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, identity: c.role.name });
244
+ return createLoomServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, remote: true });
245
+ };
146
246
  // One transport (+ its connected server) per live MCP session, plus the
147
247
  // identity it was opened for — a multi-token session is pinned to that
148
248
  // identity for its lifetime (see the mismatch check in the request handler).
149
249
  const sessions = new Map();
150
- async function openSession(identity) {
151
- const { server } = makeServer({ contextDir: opts.contextDir, gapsDir: opts.gapsDir, identity });
250
+ async function openSession(identity, caller) {
251
+ const { server } = serverFor(caller, identity);
152
252
  // Fires AFTER the initialize handshake completes — getClientVersion() is
153
253
  // populated here (it is not yet at onsessioninitialized). Logging the peer
154
254
  // makes the resolved harness observable and reveals an unmapped client's
@@ -200,26 +300,53 @@ export async function startHttpServer(opts) {
200
300
  // loom's web page (src/ui), at the root. Its own auth (a session from
201
301
  // the bearer, or the bearer header), so it comes before the bearer gate.
202
302
  // Full scope only: a scoped server has no memory to show.
203
- const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
204
- if (isUiRequest(req, uiPath)) {
303
+ const fullPath = new URL(req.url ?? '/', 'http://localhost').pathname;
304
+ if (isUiRequest(req, fullPath)) {
205
305
  if (opts.createServer)
206
306
  return sendError(res, 404, 'not-found: this server has no UI');
207
- return handleUi(req, res, uiPath, {
307
+ return handleUi(req, res, fullPath, {
208
308
  contextDir: opts.contextDir,
209
- secrets: opts.token ? [opts.token] : Object.values(opts.tokens ?? {}),
309
+ auth: uiAuth,
210
310
  env: process.env,
211
311
  });
212
312
  }
313
+ // Household: `/agents/<name>/…` names the agent (the owner's way to
314
+ // reach a non-default one; an agent's own token may use its own name).
315
+ const scoped = household ? /^\/agents\/([a-z][a-z0-9-]{0,39})(\/.*)?$/.exec(fullPath) : null;
316
+ // A path under /agents/ that doesn't name an agent cleanly is a mistake,
317
+ // not a request for the default agent.
318
+ if (household && !scoped && (fullPath === '/agents' || fullPath.startsWith('/agents/'))) {
319
+ return sendError(res, 404, 'not-found: /agents/<name>/ takes a lowercase agent name');
320
+ }
321
+ const target = scoped?.[1];
322
+ const path = scoped ? (scoped[2] ?? '/') : fullPath;
323
+ // In a household the only routes past the page are MCP and /cli: any
324
+ // other path (/AGENTS/mark/mcp, /x/agents/mark/mcp) is a 404, not the
325
+ // default agent.
326
+ if (household && path !== '/' && path !== '/mcp' && path !== '/cli') {
327
+ return sendError(res, 404, 'not-found: in a household, use /, /mcp or /cli, optionally under /agents/<name>/');
328
+ }
213
329
  // Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
214
330
  // token map) is configured. Multi-token mode additionally resolves
215
331
  // WHICH identity made this call — the bearer itself is the identity
216
332
  // claim (t-677); there is no separate login step.
217
- const auth = opts.tokens
218
- ? checkBearerMulti(opts.tokens, req.headers['authorization'])
219
- : checkBearer(opts.token, req.headers['authorization']);
220
- if (!auth.ok)
221
- return sendError(res, 401, auth.error);
222
- const identity = auth.identity ?? 'art';
333
+ let caller;
334
+ let identity;
335
+ if (household) {
336
+ const c = callerFor(req.headers['authorization'], target);
337
+ if ('status' in c)
338
+ return sendError(res, c.status, c.error);
339
+ caller = c;
340
+ identity = c.key;
341
+ }
342
+ else {
343
+ const auth = opts.tokens
344
+ ? checkBearerMulti(opts.tokens, req.headers['authorization'])
345
+ : checkBearer(opts.token, req.headers['authorization']);
346
+ if (!auth.ok)
347
+ return sendError(res, 401, auth.error);
348
+ identity = auth.identity ?? 'art';
349
+ }
223
350
  // Oversized guard (ac-lt-oversized-guard): fast path on declared length.
224
351
  const declared = Number(req.headers['content-length'] ?? 0);
225
352
  if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
@@ -228,15 +355,15 @@ export async function startHttpServer(opts) {
228
355
  // POST /cli: the CLI's remote mode (cli-endpoint.ts). Same gate and cap
229
356
  // as /mcp above. Full server only: a scoped server (knowledge-only)
230
357
  // can't offer the memory verbs, so it has no /cli at all.
231
- if (new URL(req.url ?? '/', 'http://localhost').pathname === '/cli') {
232
- if (opts.createServer)
358
+ if (path === '/cli') {
359
+ if (opts.createServer || caller?.role.kind === 'peer')
233
360
  return sendError(res, 404, 'not-found: this server has no CLI endpoint');
234
361
  // Unlike /mcp (initialize handshake + mcp-session-id header), a bare
235
362
  // POST could be a browser's "simple" cross-site request. So /cli needs
236
363
  // a configured token, a JSON content type (forces a CORS preflight,
237
364
  // which this server never grants), and no Origin header at all: the
238
365
  // CLI never sends one, and a browser always does.
239
- if (!opts.token && !opts.tokens)
366
+ if (!opts.token && !opts.tokens && !household)
240
367
  return sendError(res, 403, 'forbidden: /cli requires LOOM_BEARER_TOKEN');
241
368
  if (req.headers['origin'] !== undefined)
242
369
  return sendError(res, 403, 'forbidden: /cli refuses browser requests');
@@ -255,7 +382,7 @@ export async function startHttpServer(opts) {
255
382
  catch (e) {
256
383
  return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
257
384
  }
258
- const outcome = await handleCliBody(opts.contextDir, parsed);
385
+ const outcome = await handleCliBody(caller?.contextDir ?? opts.contextDir, parsed);
259
386
  if (!outcome.ok)
260
387
  return sendError(res, 400, outcome.error);
261
388
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -319,7 +446,7 @@ export async function startHttpServer(opts) {
319
446
  if (!isInitializeRequest(parsed)) {
320
447
  return sendError(res, 400, 'bad-input: no valid session; expected an initialize request');
321
448
  }
322
- transport = await openSession(identity);
449
+ transport = await openSession(identity, caller);
323
450
  }
324
451
  // Dispatch totality + typed envelopes (unknown tool / bad args -> JSON-RPC
325
452
  // error) are the SDK server's job from here — never a raw throw out.
@@ -1,12 +1,25 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  export interface UiOptions {
3
3
  contextDir: string;
4
- /** Configured bearer secrets (the single token, or the token map's values). Empty = open. */
5
- secrets: string[];
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>;
@@ -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';
@@ -49,7 +48,10 @@ const COOKIE_KEY = randomBytes(32);
49
48
  const SECURITY_HEADERS = {
50
49
  'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
51
50
  'x-content-type-options': 'nosniff',
52
- 'referrer-policy': 'no-referrer',
51
+ // same-origin, not no-referrer: under no-referrer, browsers send `Origin:
52
+ // null` on the page's own form posts, which the cross-site guard has to
53
+ // refuse. same-origin still sends nothing to links that leave the page.
54
+ 'referrer-policy': 'same-origin',
53
55
  'cache-control': 'no-store',
54
56
  };
55
57
  const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act'];
@@ -69,9 +71,21 @@ export function isUiRequest(req, pathname) {
69
71
  const digest = (s) => createHash('sha256').update(s).digest();
70
72
  const sessions = new Map();
71
73
  const MAX_SESSIONS = 50;
72
- function newSession(secret) {
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) {
73
87
  const id = randomBytes(32).toString('hex');
74
- sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: digest(secret) });
88
+ sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: tokenDigest });
75
89
  // Oldest first in a Map: drop the oldest past the cap.
76
90
  while (sessions.size > MAX_SESSIONS)
77
91
  sessions.delete(sessions.keys().next().value);
@@ -85,7 +99,7 @@ function csrfFor(id) {
85
99
  function safeEq(a, b) {
86
100
  return timingSafeEqual(digest(a), digest(b));
87
101
  }
88
- function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
102
+ function validSession(id, auth, nowS = Math.floor(Date.now() / 1000)) {
89
103
  if (!/^[0-9a-f]{64}$/.test(id))
90
104
  return false;
91
105
  const s = sessions.get(id);
@@ -96,7 +110,7 @@ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
96
110
  return false;
97
111
  }
98
112
  // A session dies with its token: rotate the token and old sessions stop.
99
- return secrets.some((t) => timingSafeEqual(digest(t), s.secret));
113
+ return auth.live(s.secret);
100
114
  }
101
115
  function readCookie(req, name) {
102
116
  for (const part of (req.headers.cookie ?? '').split(';')) {
@@ -107,13 +121,14 @@ function readCookie(req, name) {
107
121
  return undefined;
108
122
  }
109
123
  function whoIs(req, opts) {
110
- if (opts.secrets.length === 0)
124
+ if (!opts.auth.enabled)
111
125
  return null;
112
126
  const c = readCookie(req, COOKIE);
113
- if (c && validSession(c, opts.secrets))
127
+ if (c && validSession(c, opts.auth))
114
128
  return { kind: 'session', cookie: c };
115
129
  const header = req.headers['authorization'];
116
- if (header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok))
130
+ const presented = header?.replace(/^Bearer\s+/i, '').trim();
131
+ if (presented && opts.auth.check(presented))
117
132
  return { kind: 'bearer' };
118
133
  return null;
119
134
  }
@@ -266,7 +281,7 @@ function decode(s) {
266
281
  export async function handleUi(req, res, pathname, opts) {
267
282
  // No token, no page: an open /ui would be readable by any site that
268
283
  // DNS-rebinds a name to the loopback address (the same rule as /cli).
269
- if (opts.secrets.length === 0) {
284
+ if (!opts.auth.enabled) {
270
285
  return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
271
286
  }
272
287
  // The page used to live under /ui. Old links and bookmarks land on the same page.
@@ -291,7 +306,7 @@ export async function handleUi(req, res, pathname, opts) {
291
306
  if (!form)
292
307
  return send(res, 413, 'text/plain', 'too large');
293
308
  const token = form.get('token')?.trim() ?? '';
294
- const match = opts.secrets.find((s) => safeEq(token, s));
309
+ const match = token ? opts.auth.check(token) : null;
295
310
  if (!match)
296
311
  return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
297
312
  return redirect(res, '/', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",