@jbarket/loomai 0.13.1 → 0.14.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 CHANGED
@@ -12,6 +12,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.14.0] - 2026-10-25
16
+
17
+ ### Added
18
+
19
+ - **The piece API** (docs/extensions.md, "The API"). In a household,
20
+ `POST /api/<piece>/<path>` runs `loom-<piece> api --json` with
21
+ `{caller, path, body}` and passes the piece's `{status, body}` back. It's for
22
+ callers that aren't a browser: a box calling home to the runtime piece, a
23
+ body asking for a house. POST, JSON and no Origin, like `/cli`. It has its
24
+ own pool (four running, eight waiting, then 503), so a flood can't starve
25
+ the page.
26
+ - **Piece credentials:** a token role `piece:<piece>/<subject>`
27
+ (`loom token create --piece box/thor`). It reaches only its own piece's
28
+ API, as its subject: no MCP, no `/cli`, no page, no other piece.
29
+
30
+ ### Security
31
+
32
+ - The household daemon's caller resolution now handles every role kind by
33
+ name, and refuses anything that isn't one of them. Before this, a role
34
+ kind it didn't know would have been served as the owner.
35
+
15
36
  ## [0.13.1] - 2026-10-24
16
37
 
17
38
  ### Changed
@@ -779,7 +800,8 @@ Initial public release.
779
800
  stack and all added external-service dependencies or operational
780
801
  overhead.
781
802
 
782
- [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.13.1...HEAD
803
+ [Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.14.0...HEAD
804
+ [0.14.0]: https://github.com/sleepunit-agents/loom/compare/v0.13.1...v0.14.0
783
805
  [0.13.1]: https://github.com/sleepunit-agents/loom/compare/v0.13.0...v0.13.1
784
806
  [0.13.0]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...v0.13.0
785
807
  [0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
@@ -21,13 +21,14 @@ $LOOM_HOME defaults to ~/.config/loom.
21
21
  `;
22
22
  const TOKEN_USAGE = `Usage: loom token <command>
23
23
 
24
- create (--agent <name> | --owner | --peer <name>) --label <text>
24
+ create (--agent <name> | --owner | --peer <name> | --piece <piece>/<subject>) --label <text>
25
25
  Mint a token. It's printed once; only its sha256 is kept
26
26
  list Every token: label, role, created, last used, revoked
27
27
  revoke <label> Revoke the live token with that label (takes effect on the next request)
28
28
 
29
29
  Roles: agent:<name> is MCP and the CLI as that agent; owner sees everything and
30
- the page; peer:<name> reaches commons knowledge only.
30
+ the page; peer:<name> reaches commons knowledge only; piece:<piece>/<subject> is
31
+ a piece's own credential (e.g. a box's), reaching only POST /api/<piece>/.
31
32
  `;
32
33
  function flag(args, name) {
33
34
  const i = args.indexOf(name);
@@ -107,7 +108,7 @@ export async function runToken(argv, io) {
107
108
  if (!h)
108
109
  return 1;
109
110
  const label = flag(rest, '--label');
110
- const picked = [flag(rest, '--agent') && `agent:${flag(rest, '--agent')}`, rest.includes('--owner') && 'owner', flag(rest, '--peer') && `peer:${flag(rest, '--peer')}`].filter(Boolean);
111
+ const picked = [flag(rest, '--agent') && `agent:${flag(rest, '--agent')}`, rest.includes('--owner') && 'owner', flag(rest, '--peer') && `peer:${flag(rest, '--peer')}`, flag(rest, '--piece') && `piece:${flag(rest, '--piece')}`].filter(Boolean);
111
112
  const role = picked.length === 1 ? parseRole(picked[0]) : null;
112
113
  if (!label || !role) {
113
114
  io.stderr(TOKEN_USAGE);
@@ -15,10 +15,19 @@ export type Role = {
15
15
  } | {
16
16
  kind: 'peer';
17
17
  name: string;
18
+ }
19
+ /**
20
+ * A piece's credential (docs/extensions.md, "The API"): reaches only
21
+ * POST /api/<piece>/…, as `subject`. No MCP, no /cli, no page, no store.
22
+ */
23
+ | {
24
+ kind: 'piece';
25
+ piece: string;
26
+ subject: string;
18
27
  };
19
28
  export interface TokenRecord {
20
29
  label: string;
21
- /** `agent:<name>`, `owner` or `peer:<name>`. */
30
+ /** `agent:<name>`, `owner`, `peer:<name>` or `piece:<piece>/<subject>`. */
22
31
  role: string;
23
32
  /** sha256 of the token, hex. The token itself is never stored. */
24
33
  sha256: string;
package/dist/household.js CHANGED
@@ -86,13 +86,21 @@ export function householdOf(contextDir) {
86
86
  export function parseRole(s) {
87
87
  if (s === 'owner')
88
88
  return { kind: 'owner' };
89
+ const p = /^piece:([a-z][a-z0-9-]{0,39})\/([a-z0-9][a-z0-9._-]{0,63})$/.exec(s);
90
+ if (p)
91
+ return p[1] === 'loom' ? null : { kind: 'piece', piece: p[1], subject: p[2] };
89
92
  const m = /^(agent|peer):([a-z][a-z0-9-]{0,39})$/.exec(s);
90
93
  if (!m)
91
94
  return null;
92
95
  return m[1] === 'agent' ? { kind: 'agent', agent: m[2] } : { kind: 'peer', name: m[2] };
93
96
  }
94
97
  export function roleString(r) {
95
- return r.kind === 'owner' ? 'owner' : r.kind === 'agent' ? `agent:${r.agent}` : `peer:${r.name}`;
98
+ switch (r.kind) {
99
+ case 'owner': return 'owner';
100
+ case 'agent': return `agent:${r.agent}`;
101
+ case 'peer': return `peer:${r.name}`;
102
+ case 'piece': return `piece:${r.piece}/${r.subject}`;
103
+ }
96
104
  }
97
105
  const sha = (s) => createHash('sha256').update(s).digest('hex');
98
106
  function writePrivate(path, body) {
@@ -40,6 +40,8 @@ import { createLoomServer } from '../server.js';
40
40
  import { createBackend } from '../backends/index.js';
41
41
  import { handleCliBody } from './cli-endpoint.js';
42
42
  import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
43
+ import { pieceApi } from '../ui/pieces.js';
44
+ import { findPiece } from '../cli/pieces.js';
43
45
  import { createKnowledgeOnlyServer } from './knowledge-server.js';
44
46
  import { hasUnsplitPeople } from '../backends/people-household.js';
45
47
  import { agentDir, assertRealAgentDir, commonsProblem, liveHousehold, roleString, TokenResolver } from '../household.js';
@@ -156,8 +158,8 @@ export async function startHttpServer(opts) {
156
158
  const live = household ? liveHousehold(household) : undefined;
157
159
  const resolver = live ? new TokenResolver(live) : undefined;
158
160
  const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
159
- /** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
160
- function callerFor(header, target) {
161
+ /** The role a presented bearer has: 401 (none/unknown) or 503 (tokens.json unreadable). */
162
+ function roleFor(header) {
161
163
  const presented = (header ?? '').replace(/^Bearer\s+/i, '').trim();
162
164
  if (!presented)
163
165
  return { status: 401, error: 'unauthorized: missing bearer token' };
@@ -177,6 +179,16 @@ export async function startHttpServer(opts) {
177
179
  }
178
180
  if (!role)
179
181
  return { status: 401, error: 'unauthorized: bearer token not recognized' };
182
+ return role;
183
+ }
184
+ /** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
185
+ function callerFor(header, target) {
186
+ const role = roleFor(header);
187
+ if ('status' in role)
188
+ return role;
189
+ // Every kind is handled by name: an unlisted one must never fall through to the owner's branch.
190
+ if (role.kind === 'piece')
191
+ return { status: 403, error: `forbidden: a piece credential reaches only /api/${role.piece}/` };
180
192
  const h = live();
181
193
  if (role.kind === 'peer') {
182
194
  if (target)
@@ -188,6 +200,8 @@ export async function startHttpServer(opts) {
188
200
  return { status: 403, error: `forbidden: this token is ${role.agent}'s` };
189
201
  return { role, agent: role.agent, contextDir: agentDir(h, role.agent), key: `${roleString(role)}>${role.agent}` };
190
202
  }
203
+ if (role.kind !== 'owner')
204
+ return { status: 403, error: 'forbidden' };
191
205
  const agent = target ?? h.default;
192
206
  if (!h.agents.includes(agent))
193
207
  return { status: 404, error: `not-found: no agent named ${agent}` };
@@ -306,6 +320,66 @@ export async function startHttpServer(opts) {
306
320
  await server.connect(transport);
307
321
  return transport;
308
322
  }
323
+ /**
324
+ * POST /api/<piece>/<path>. Like /cli: JSON only, no Origin (a browser
325
+ * always sends one, and this route grants no CORS), within the body cap.
326
+ * An owner or agent token calls as itself; a piece credential only its own
327
+ * piece, as its subject; a peer never.
328
+ */
329
+ async function handlePieceApi(req, res, piece, subpath) {
330
+ if (req.method !== 'POST')
331
+ return sendError(res, 405, `method-not-allowed: POST /api/${piece}/`);
332
+ const role = roleFor(req.headers['authorization']);
333
+ if ('status' in role)
334
+ return sendError(res, role.status, role.error);
335
+ let caller;
336
+ switch (role.kind) {
337
+ case 'owner':
338
+ caller = { kind: 'owner' };
339
+ break;
340
+ case 'agent':
341
+ caller = { kind: 'agent', agent: role.agent };
342
+ break;
343
+ case 'piece':
344
+ if (role.piece !== piece)
345
+ return sendError(res, 403, `forbidden: this credential is ${role.piece}'s`);
346
+ caller = { kind: 'piece', subject: role.subject };
347
+ break;
348
+ case 'peer': return sendError(res, 403, 'forbidden: a peer token reaches the commons only');
349
+ }
350
+ if (req.headers['origin'] !== undefined)
351
+ return sendError(res, 403, 'forbidden: /api refuses browser requests');
352
+ if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {
353
+ return sendError(res, 415, 'unsupported-media-type: POST /api takes application/json');
354
+ }
355
+ const declared = Number(req.headers['content-length'] ?? 0);
356
+ if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
357
+ return sendError(res, 413, `oversized: declared ${declared} bytes exceeds cap ${maxBytes}`);
358
+ }
359
+ const raw = await collectBody(req, maxBytes);
360
+ if (raw === OVERSIZED)
361
+ return sendError(res, 413, `oversized: body exceeds cap ${maxBytes}`);
362
+ let body;
363
+ try {
364
+ body = raw === '' ? {} : JSON.parse(raw);
365
+ }
366
+ catch (e) {
367
+ return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
368
+ }
369
+ // "loom" is core's own name; a piece can't take it.
370
+ const path = piece === 'loom' ? null : findPiece(piece, process.env);
371
+ if (!path)
372
+ return sendError(res, 404, `not-found: no piece named ${piece}`);
373
+ // A caller that hangs up while queued gives its place back.
374
+ const gone = new AbortController();
375
+ res.on('close', () => { if (!res.writableEnded)
376
+ gone.abort(); });
377
+ const out = await pieceApi(path, { caller, path: subpath, body }, process.env, gone.signal);
378
+ if (gone.signal.aborted)
379
+ return;
380
+ res.writeHead(out.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
381
+ res.end(JSON.stringify(out.body));
382
+ }
309
383
  const http = createServer((req, res) => {
310
384
  void (async () => {
311
385
  // loom's web page (src/ui), at the root. Its own auth (a session from
@@ -322,6 +396,15 @@ export async function startHttpServer(opts) {
322
396
  household: live,
323
397
  });
324
398
  }
399
+ // POST /api/<piece>/…: a piece's API (docs/extensions.md, "The API"),
400
+ // for callers that aren't a browser: a box calling home, a body asking
401
+ // for a house. Household only, bearer only, JSON only.
402
+ if (household && (fullPath === '/api' || fullPath.startsWith('/api/'))) {
403
+ const api = /^\/api\/([a-z][a-z0-9-]{0,39})((?:\/[A-Za-z0-9._~-]+)*)\/?$/.exec(fullPath);
404
+ if (!api || api[2].split('/').some((s) => s === '.' || s === '..'))
405
+ return sendError(res, 404, 'not-found: /api/<piece>/<path>');
406
+ return handlePieceApi(req, res, api[1], api[2].replace(/^\//, ''));
407
+ }
325
408
  // Household: `/agents/<name>/…` names the agent (the owner's way to
326
409
  // reach a non-default one; an agent's own token may use its own name).
327
410
  const scoped = household ? /^\/agents\/([a-z][a-z0-9-]{0,39})(\/.*)?$/.exec(fullPath) : null;
@@ -22,6 +22,9 @@ export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
22
22
  * when a piece actually runs.
23
23
  */
24
24
  export declare const MAX_RUNNING = 6;
25
+ /** /api: at most four at once, eight more waiting. */
26
+ export declare const MAX_API_RUNNING = 4;
27
+ export declare const MAX_API_WAITING = 8;
25
28
  /** Every installed piece's manifest, plus the ones that had nothing to show and why. */
26
29
  export declare function pieceManifests(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
27
30
  pieces: PieceInfo[];
@@ -35,3 +38,29 @@ export declare function pieceAct(info: PieceInfo, request: {
35
38
  path: string;
36
39
  fields: Record<string, string>;
37
40
  }, env: NodeJS.ProcessEnv): Promise<ActResult>;
41
+ /** A piece's API answer is its own JSON; these bound how long one call may take. */
42
+ export declare const API_TIMEOUT_MS = 15000;
43
+ /** Who is calling a piece's API, as core resolved the bearer. */
44
+ export type ApiCaller = {
45
+ kind: 'owner';
46
+ } | {
47
+ kind: 'agent';
48
+ agent: string;
49
+ } | {
50
+ kind: 'piece';
51
+ subject: string;
52
+ };
53
+ /**
54
+ * Call a piece's API (docs/extensions.md, "The API"): `loom-<piece> api --json`
55
+ * with {caller, path, body} on stdin. The piece answers {status?, body}; a
56
+ * status outside 200-499, or anything that isn't that shape, is the piece's
57
+ * failure, which the caller sees as a 502.
58
+ */
59
+ export declare function pieceApi(path: string, request: {
60
+ caller: ApiCaller;
61
+ path: string;
62
+ body: unknown;
63
+ }, env: NodeJS.ProcessEnv, gone?: AbortSignal): Promise<{
64
+ status: number;
65
+ body: unknown;
66
+ }>;
package/dist/ui/pieces.js CHANGED
@@ -56,22 +56,56 @@ function killGroup(pid) {
56
56
  * when a piece actually runs.
57
57
  */
58
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();
59
+ /**
60
+ * A pool of piece processes. The page's and the API's are separate, so callers
61
+ * of /api (a box, which may be compromised) can never starve the owner's page.
62
+ * The API's queue is bounded too: past it, a call is refused, not parked.
63
+ */
64
+ class Pool {
65
+ max;
66
+ maxWaiting;
67
+ running = 0;
68
+ waiting = [];
69
+ constructor(max, maxWaiting = Infinity) {
70
+ this.max = max;
71
+ this.maxWaiting = maxWaiting;
67
72
  }
68
- finally {
69
- running--;
70
- waiting.shift()?.();
73
+ /** null when the queue is full, or when `gone` fired before a slot came up. */
74
+ async run(f, gone) {
75
+ if (this.running >= this.max) {
76
+ if (this.waiting.length >= this.maxWaiting)
77
+ return null;
78
+ const got = await new Promise((resolve) => {
79
+ const go = () => { gone?.removeEventListener('abort', drop); resolve(true); };
80
+ const drop = () => { const i = this.waiting.indexOf(go); if (i >= 0)
81
+ this.waiting.splice(i, 1); resolve(false); };
82
+ this.waiting.push(go);
83
+ gone?.addEventListener('abort', drop, { once: true });
84
+ });
85
+ if (!got)
86
+ return null;
87
+ }
88
+ if (gone?.aborted) {
89
+ this.waiting.shift()?.();
90
+ return null;
91
+ }
92
+ this.running++;
93
+ try {
94
+ return await f();
95
+ }
96
+ finally {
97
+ this.running--;
98
+ this.waiting.shift()?.();
99
+ }
71
100
  }
72
101
  }
73
- function ask(path, args, env, opts = {}) {
74
- return slot(() => run(path, args, env, opts));
102
+ const pagePool = new Pool(MAX_RUNNING);
103
+ /** /api: at most four at once, eight more waiting. */
104
+ export const MAX_API_RUNNING = 4;
105
+ export const MAX_API_WAITING = 8;
106
+ const apiPool = new Pool(MAX_API_RUNNING, MAX_API_WAITING);
107
+ async function ask(path, args, env, opts = {}) {
108
+ return (await pagePool.run(() => run(path, args, env, opts)));
75
109
  }
76
110
  function run(path, args, env, opts) {
77
111
  return new Promise((resolve) => {
@@ -189,3 +223,27 @@ export async function pieceAct(info, request, env) {
189
223
  const result = parsed === undefined ? 'not JSON' : parseActResult(parsed);
190
224
  return typeof result === 'string' ? { ok: false, message: `${info.piece} sent an answer loom couldn't read (${result}).` } : result;
191
225
  }
226
+ /** A piece's API answer is its own JSON; these bound how long one call may take. */
227
+ export const API_TIMEOUT_MS = 15_000;
228
+ /**
229
+ * Call a piece's API (docs/extensions.md, "The API"): `loom-<piece> api --json`
230
+ * with {caller, path, body} on stdin. The piece answers {status?, body}; a
231
+ * status outside 200-499, or anything that isn't that shape, is the piece's
232
+ * failure, which the caller sees as a 502.
233
+ */
234
+ export async function pieceApi(path, request, env, gone) {
235
+ const r = await apiPool.run(() => run(path, ['api', '--json'], env, { stdin: JSON.stringify(request), timeoutMs: API_TIMEOUT_MS }), gone);
236
+ if (r === null)
237
+ return { status: 503, body: { error: 'busy: too many piece API calls at once; try again shortly' } };
238
+ if (!r.ok)
239
+ return { status: 502, body: { error: `the piece didn't answer (${r.reason})` } };
240
+ const parsed = json(r.stdout);
241
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('body' in parsed)) {
242
+ return { status: 502, body: { error: 'the piece answered with something other than {status?, body}' } };
243
+ }
244
+ const status = parsed.status === undefined ? 200 : parsed.status;
245
+ if (typeof status !== 'number' || !Number.isInteger(status) || status < 200 || status > 499) {
246
+ return { status: 502, body: { error: 'the piece answered with a status outside 200-499' } };
247
+ }
248
+ return { status, body: parsed.body };
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.13.1",
3
+ "version": "0.14.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",