trantor 0.18.116 → 0.18.117

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.116",
3
+ "version": "0.18.117",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/hub/auth.mjs CHANGED
@@ -1,19 +1,60 @@
1
1
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: Auth normalizes untrusted request and persisted identity shapes at their established hub boundary; this split preserves those guards unchanged. */
2
- import { verifyRequest, verifyEndorsement } from "../lib/identity.mjs";
2
+ import { verifyRequest, verifyEndorsement, HDR } from "../lib/identity.mjs";
3
3
  import { readFileSync } from "node:fs";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { timingSafeEqual, randomBytes } from "node:crypto";
5
6
  import { publicView } from "../lib/identity.mjs";
6
7
  import { IDENTITY_KINDS } from "../lib/store-contract.mjs";
7
8
 
8
9
  export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE, seenNonces = new Map() }) {
9
10
  const now = () => Date.now();
10
- function rawBody(req) {
11
+ // Route measurements and supported envelopes: docs/REQUEST-BODY-LIMITS.md.
12
+ const BODY_CAP_DEFAULT = 4 * 1024 * 1024;
13
+ const BODY_CAP_PROMPT = 16 * 1024 * 1024;
14
+ // More than 100x the measured 115,194-byte desktop snapshot.
15
+ const BODY_CAP_OWNER_CHAT = 16 * 1024 * 1024;
16
+ // About 6x the measured 10,659,828-byte real state.
17
+ const BODY_CAP_OWNER_IMPORT = 64 * 1024 * 1024;
18
+ const OWNER_BODY_CAPS = new Map([
19
+ ["/session/chat", BODY_CAP_OWNER_CHAT],
20
+ ["/import", BODY_CAP_OWNER_IMPORT],
21
+ ["/handoff", BODY_CAP_DEFAULT], ["/task", BODY_CAP_DEFAULT], ["/task/update", BODY_CAP_DEFAULT],
22
+ ]);
23
+ function bodyCap(P) { return P === "/session/prompt" ? BODY_CAP_PROMPT : BODY_CAP_DEFAULT; }
24
+ function ownerBodyCap(req, P) {
25
+ const cap = OWNER_BODY_CAPS.get(P) || bodyCap(P);
26
+ if (cap <= bodyCap(P) || !Object.values(HDR).every(h => req.headers[h])) return bodyCap(P);
27
+ // Header identity is only a read-budget hint. Larger bodies require strict owner auth before parsing.
28
+ const identity = findIdentity(req.headers[HDR.pubkey]) || findIdentity(req.headers["x-trantor-durable"]);
29
+ return identity?.scopes?.some(s => s.role === "owner") ? cap : bodyCap(P);
30
+ }
31
+ function rawBody(req, maxBytes = BODY_CAP_DEFAULT) {
32
+ if (req._bodyTooLarge) return Promise.resolve(undefined);
11
33
  if (req._rawBody !== undefined) return Promise.resolve(req._rawBody);
12
- return new Promise(r => { let d = ""; req.on("data", c => (d += c)); req.on("end", () => { req._rawBody = d; r(d); }); });
34
+ return new Promise(r => {
35
+ let d = "", bytes = 0, done = false;
36
+ const decoder = new StringDecoder("utf8");
37
+ const finish = (v) => { if (!done) { done = true; r(v); } };
38
+ req.on("data", c => {
39
+ if (done) return;
40
+ bytes += c.length;
41
+ if (bytes > maxBytes) {
42
+ req._bodyTooLarge = true;
43
+ finish(undefined);
44
+ req.resume(); // keep draining (and discarding) so the socket can close cleanly
45
+ return;
46
+ }
47
+ d += decoder.write(c);
48
+ });
49
+ req.on("end", () => { if (!req._bodyTooLarge) { req._bodyBytes = bytes; req._rawBody = d + decoder.end(); finish(req._rawBody); } });
50
+ req.on("error", () => finish(undefined));
51
+ });
13
52
  }
14
- async function body(req) {
53
+ async function body(req, maxBytes = BODY_CAP_DEFAULT) {
54
+ if (req._bodyTooLarge) return undefined;
15
55
  if (req._jsonBody !== undefined) return req._jsonBody;
16
- const d = await rawBody(req);
56
+ const d = await rawBody(req, maxBytes);
57
+ if (d === undefined) return undefined;
17
58
  try { req._jsonBody = d ? JSON.parse(d) : {}; } catch { req._jsonBody = {}; }
18
59
  return req._jsonBody;
19
60
  }
@@ -53,7 +94,7 @@ function cmpSemver(a, b) {
53
94
  }
54
95
  const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
55
96
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
56
- const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
97
+ const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack", "/session/attachment"]);
57
98
  const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
58
99
  const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status", "/session/chat", "/session/prompt", "/crew/roster"]);
59
100
  const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
@@ -99,7 +140,7 @@ function isDutyIdentity(auth) {
99
140
  const canRead = (auth, project) => AUTH_MODE !== "enforce" && !auth?.identity
100
141
  ? true
101
142
  : isDutyIdentity(auth) || scopeAllows(auth?.identity, project, "read");
102
- function projectFromRequest(P, q, b) {
143
+ function projectFromRequest(P, q, b, method) {
103
144
  if (P === "/task/update" || P === "/card") {
104
145
  const t = state.tasks.find(x => x.id === Number(b?.id ?? q?.id));
105
146
  return t?.project || "";
@@ -117,8 +158,11 @@ function projectFromRequest(P, q, b) {
117
158
  return p?.project || "";
118
159
  }
119
160
  // claim/ack and the single-prompt GET carry only an id — authorization runs against the
120
- // PROMPT's project, or a project-scoped key could claim any project's queued prompt (#12129).
121
- if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack") {
161
+ // PROMPT's project (#12129); the attachment download is keyed the same way. POST
162
+ // /session/prompt is the exception: it CREATES a row in b.project, so an id in the body must
163
+ // not borrow an existing row's project to write elsewhere (#12161 review).
164
+ if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack" || P === "/session/attachment") {
165
+ if (P === "/session/prompt" && method === "POST") return canon(String(b?.project || q?.project || "").slice(0, 80));
122
166
  const pr = (state.sessionPrompts || []).find(x => x.id === Number(b?.id ?? q?.id));
123
167
  if (pr) return pr.project || "";
124
168
  if (P !== "/session/prompt") return "";
@@ -198,11 +242,11 @@ function supersessionActive(rec) {
198
242
  }
199
243
  return insts.some(i => i !== rec && i.name === rec.name && !i.superseded && (i.lastSeen || 0) > cut);
200
244
  }
201
- async function authenticate(req, path) {
202
- if (AUTH_MODE === "off") return { ok: true, mode: AUTH_MODE, trusted: true };
245
+ async function authenticate(req, path, strict = false) {
246
+ if (AUTH_MODE === "off" && !strict) return { ok: true, mode: AUTH_MODE, trusted: true };
203
247
  const signed = hasAuthHeaders(req);
204
248
  if (!signed) {
205
- if (AUTH_MODE === "warn") {
249
+ if (AUTH_MODE === "warn" && !strict) {
206
250
  process.stderr.write(`[trantor] auth warn: unsigned ${req.method} ${path}\n`);
207
251
  return { ok: true, mode: AUTH_MODE, trusted: false, warning: "unsigned" };
208
252
  }
@@ -210,7 +254,7 @@ async function authenticate(req, path) {
210
254
  }
211
255
  const raw = req.method === "GET" || req.method === "HEAD" ? undefined : await rawBody(req);
212
256
  // Auth warn mode annotates signature, replay and enrollment failures; enforce mode rejects them.
213
- const soft = (warning) => AUTH_MODE === "warn"
257
+ const soft = (warning) => AUTH_MODE === "warn" && !strict
214
258
  ? { ok: true, mode: AUTH_MODE, trusted: false, warning }
215
259
  : { ok: false, code: 401, error: warning };
216
260
  const verified = verifyRequest({ headers: req.headers, method: req.method, path, body: raw });
@@ -351,7 +395,7 @@ function canUseInboxSession(auth, session) {
351
395
  }
352
396
 
353
397
  return {
354
- PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, rawBody, json,
398
+ PUBLIC_ENDPOINTS, authPath, authenticate, authorize, body, bodyCap, ownerBodyCap, rawBody, json,
355
399
  canon, cleanScope, defaultScopesFor, findIdentity, scopeAllows, canRead, isDutyIdentity,
356
400
  projectFromRequest, crossProjectGuard, filterReadable, filterDiscoverable,
357
401
  inboxReadable, canUseInboxSession, overseerPolicy, subFp, PROPOSAL_CAP,
package/hub/public.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isIP } from "node:net";
2
+ import { HDR } from "../lib/identity.mjs";
2
3
 
3
4
  const ALLOWED = new Set([
4
5
  ...["/health", "/projects", "/catchup", "/tasks", "/card", "/events", "/stream",
@@ -31,7 +32,7 @@ export function publicClientIP(req) {
31
32
  return normalizedIP(forwarded) || socket;
32
33
  }
33
34
 
34
- export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`) } = {}) {
35
+ export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`), canUpload = () => false } = {}) {
35
36
  const clients = new Map();
36
37
  const admissions = new WeakMap();
37
38
  const refusals = new Map();
@@ -92,6 +93,18 @@ export function createPublicGate({ now = Date.now, log = line => process.stderr.
92
93
  count("auth");
93
94
  } else if (res.statusCode === 403) count(path === "/enroll" ? "enrollment" : "authorization");
94
95
  });
96
+ // Only health is unsigned; reject absent signature fields before the handler reads a body.
97
+ if (path !== "/health" && Object.values(HDR).some(header => !req.headers[header])) {
98
+ res.writeHead(401, { "content-type": "application/json", "connection": "close" });
99
+ res.end(JSON.stringify({ error: "signature required" }));
100
+ return false;
101
+ }
102
+ // The photo endpoint has a larger body budget; unknown or read-only keys get no read budget.
103
+ if (req.method === "POST" && path === "/session/prompt" && !canUpload(req.headers[HDR.pubkey])) {
104
+ res.writeHead(401, { "content-type": "application/json", "connection": "close" });
105
+ res.end(JSON.stringify({ error: "enrolled phone write scope required" }));
106
+ return false;
107
+ }
95
108
  return true;
96
109
  }
97
110
 
@@ -2,6 +2,8 @@
2
2
  // is MEMORY-ONLY (a restart answers have:0, the desktop resends its window); the prompt queue
3
3
  // lives in state so a restart never strands a queued prompt. Live frames ride pushEventToStreams
4
4
  // (scope-filtered, never logged); only chat.prompt.queued/.delivered/.failed are audit-appended.
5
+ import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
5
7
  import { setInterval } from "node:timers";
6
8
 
7
9
  const CHAT_CAP = 400; // turns kept per project, highest idx wins
@@ -10,11 +12,22 @@ const PROMPT_CAP = 200;
10
12
  const PROMPT_TEXT_MAX = 8000;
11
13
  const EVENT_TEXT_MAX = 500;
12
14
  const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
15
+ // Claims cover the desktop's 240s crew timeout plus ack slack; heartbeats cover lock waits.
16
+ const CLAIM_TTL_MS = 300000;
13
17
  const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
14
18
  const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
19
+ const CLAIMED_EXPIRED_REASON = "The Mac claimed this prompt but never delivered it. Try sending it again.";
15
20
  const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
16
21
  const ANSWER_PICKS_MAX = 10;
17
22
  const ANSWER_OTHER_MAX = 2000;
23
+ // #12161: photo attachments ride POST /session/prompt as base64 JPEGs. The phone downscales to
24
+ // the byte cap; the hub stores the bytes on disk next to its state (never in bus.json) and hands
25
+ // metadata to the claiming desktop, which downloads them through GET /session/attachment.
26
+ const ATTACHMENTS_MAX = 4;
27
+ const PROJECT_ATTACHMENT_BYTES_MAX = 64 * 1024 * 1024;
28
+ const ATTACHMENT_BYTES_MAX = 2 * 1024 * 1024;
29
+ const ATTACHMENT_B64_MAX = Math.ceil(ATTACHMENT_BYTES_MAX / 3) * 4;
30
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
18
31
  // #12140: crew commands from the phone. The hub validates and queues; the desktop relay runs the
19
32
  // trantor CLI and acks done|failed with the output tail. A seat spec is "agent[:project]".
20
33
  const CREW_SPEC = /^[a-z][a-z0-9-]{0,30}(:[A-Za-z0-9._/-]{1,80})?$/;
@@ -24,7 +37,7 @@ const CREW_CLAUDE_REFUSAL = "The claude seat draws your own Claude quota; start
24
37
  // oxlint-disable-next-line no-control-regex -- stripping ANSI escape sequences IS matching control characters
25
38
  const stripAnsi = (s) => String(s).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
26
39
 
27
- export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
40
+ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier, attachmentsDir }) {
28
41
  const now = () => Date.now();
29
42
  const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
30
43
  state.sessionPrompts ||= [];
@@ -32,21 +45,93 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
32
45
 
33
46
  const findPrompt = (id) => state.sessionPrompts.find(p => p.id === Number(id));
34
47
 
35
- // A prompt still queued past the TTL means no desktop relay claimed it. Expiry is lazy (on
36
- // read) plus a sweep that is a no-op when nothing is queued, so an idle hub does no work.
48
+ // ---- attachments (#12161): bytes live on disk as <id>-<n>.jpg; the row keeps metadata ----
49
+ function attachmentPath(file) { return join(attachmentsDir, file); }
50
+ function unlinkAttachments(p) {
51
+ if (!attachmentsDir) return;
52
+ for (const a of p.attachments || []) { try { unlinkSync(attachmentPath(a.file)); } catch {} }
53
+ }
54
+ // Startup/sweep GC: remove files no queued-or-claimed row references (evicted rows, restarts,
55
+ // lost races). Cheap: a directory listing every sweep.
56
+ function sweepAttachments() {
57
+ if (!attachmentsDir) return;
58
+ let live;
59
+ try { live = new Set(state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed")
60
+ .flatMap(p => (p.attachments || []).map(a => a.file))); } catch { return; }
61
+ let entries;
62
+ try { entries = readdirSync(attachmentsDir); } catch { return; }
63
+ for (const name of entries) {
64
+ if (!live.has(name)) { try { unlinkSync(attachmentPath(name)); } catch {} }
65
+ }
66
+ }
67
+ try { mkdirSync(attachmentsDir, { recursive: true }); } catch {}
68
+ sweepAttachments();
69
+ // Validate and decode the wire shape: [{name, data(base64 JPEG)}]. Returns rows for the prompt
70
+ // (files not yet written) or { error }.
71
+ function parseAttachments(raw) {
72
+ if (raw == null) return { attachments: [] };
73
+ if (!Array.isArray(raw) || raw.length > ATTACHMENTS_MAX) return { error: `at most ${ATTACHMENTS_MAX} attachments` };
74
+ const attachments = [];
75
+ for (const a of raw) {
76
+ const name = String(a?.name || "photo.jpg").slice(0, 120);
77
+ // Coerce, then judge the value: any non-base64 or non-JPEG bytes dies at the magic check,
78
+ // so the wire shape never needs a runtime type probe.
79
+ const data = String(a?.data ?? "");
80
+ if (!data.length || data.length > ATTACHMENT_B64_MAX || !/^[A-Za-z0-9+/=\s]+$/.test(data)) {
81
+ return { error: "attachment must be base64 JPEG of 2 MB or less" };
82
+ }
83
+ const bytes = Buffer.from(data, "base64");
84
+ if (!bytes.length || bytes.length > ATTACHMENT_BYTES_MAX || !bytes.subarray(0, 3).equals(JPEG_MAGIC)) {
85
+ return { error: "attachment must be a JPEG of 2 MB or less" };
86
+ }
87
+ attachments.push({ name, size: bytes.length, bytes });
88
+ }
89
+ return { attachments };
90
+ }
91
+ function storeAttachments(id, attachments) {
92
+ if (!attachmentsDir) {
93
+ if (attachments.length) throw new Error("attachment storage is not configured");
94
+ return [];
95
+ }
96
+ mkdirSync(attachmentsDir, { recursive: true });
97
+ return attachments.map((a, n) => {
98
+ const file = `${id}-${n}.jpg`;
99
+ writeFileSync(attachmentPath(file), a.bytes);
100
+ return { name: a.name, size: a.size, file };
101
+ });
102
+ }
103
+
104
+ // Queued work expires quickly; claimed work keeps its bytes while the relay renews its lease.
37
105
  function expireQueued() {
38
106
  let changed = false;
39
107
  for (const p of state.sessionPrompts) {
40
- if (p.status !== "queued" || now() - p.ts <= PROMPT_TTL_MS) continue;
41
- p.status = "expired"; p.outcome = "expired"; p.error = EXPIRED_REASON;
42
- appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: EXPIRED_REASON });
108
+ const claimed = p.status === "claimed";
109
+ const from = p.status === "queued" ? p.ts : claimed ? (p.renewedAt || p.claimedAt) : 0;
110
+ if (!from || now() - from <= (claimed ? CLAIM_TTL_MS : PROMPT_TTL_MS)) continue;
111
+ p.status = "expired"; p.outcome = "expired";
112
+ p.error = claimed ? CLAIMED_EXPIRED_REASON : EXPIRED_REASON;
113
+ unlinkAttachments(p);
114
+ appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: p.error });
43
115
  changed = true;
44
116
  }
45
117
  if (changed) markDirty();
46
118
  return changed;
47
119
  }
120
+ // Only terminal rows can be discarded; live work keeps its queue slot and attachment bytes.
121
+ function trimPrompts() {
122
+ while (state.sessionPrompts.length > PROMPT_CAP) {
123
+ const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
124
+ if (ti < 0) break;
125
+ const [evicted] = state.sessionPrompts.splice(ti, 1);
126
+ unlinkAttachments(evicted);
127
+ }
128
+ }
129
+ function queueFull() {
130
+ return state.sessionPrompts.filter(p => p.status === "queued" || p.status === "claimed").length >= PROMPT_CAP;
131
+ }
48
132
  const sweep = setInterval(() => {
49
- if (state.sessionPrompts.some(p => p.status === "queued")) expireQueued();
133
+ sweepAttachments();
134
+ if (state.sessionPrompts.some(p => p.status === "queued" || p.status === "claimed")) expireQueued();
50
135
  }, PROMPT_SWEEP_MS);
51
136
  sweep.unref?.();
52
137
 
@@ -140,10 +225,11 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
140
225
  const isChat = P === "/session/chat";
141
226
  const isPrompts = P === "/session/prompts";
142
227
  const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
228
+ const isAttachment = P === "/session/attachment";
143
229
  const isAnswer = P === "/session/answer";
144
230
  const isCrewCommand = P === "/crew/command";
145
231
  const isCrewRoster = P === "/crew/roster";
146
- if (!isChat && !isPrompts && !isPrompt && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
232
+ if (!isChat && !isPrompts && !isPrompt && !isAttachment && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
147
233
  const { body, json, canon, scopeAllows } = ctx;
148
234
 
149
235
  // #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
@@ -161,15 +247,13 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
161
247
  const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
162
248
  if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
163
249
  expireQueued();
250
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
164
251
  state.sessionPromptSeq += 1;
165
252
  const text = other.trim() ? other : `picks ${picks.join(", ")}`;
166
253
  const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
167
254
  text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
168
255
  state.sessionPrompts.push(p);
169
- while (state.sessionPrompts.length > PROMPT_CAP) {
170
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
171
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
172
- }
256
+ trimPrompts();
173
257
  markDirty();
174
258
  appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
175
259
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
@@ -201,6 +285,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
201
285
  if (action === "up" && !seats.length) return json(res, 400, { error: "up needs at least one seat" });
202
286
  if (action === "swap" && (!from || !to)) return json(res, 400, { error: "swap needs from and to" });
203
287
  expireQueued();
288
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
204
289
  state.sessionPromptSeq += 1;
205
290
  // down with an empty seat list means the whole project.
206
291
  const text = action === "swap" ? `swap ${from} ${to}` : seats.length ? `${action} ${seats.join(" ")}` : `${action} ${project}`;
@@ -209,10 +294,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
209
294
  if (from) p.from = from;
210
295
  if (to) p.to = to;
211
296
  state.sessionPrompts.push(p);
212
- while (state.sessionPrompts.length > PROMPT_CAP) {
213
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
214
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
215
- }
297
+ trimPrompts();
216
298
  markDirty();
217
299
  appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "crew", action, text: text.slice(0, EVENT_TEXT_MAX) });
218
300
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
@@ -268,26 +350,41 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
268
350
  const text = String(b.text || "");
269
351
  if (!project || !text.trim()) return json(res, 400, { error: "project and non-empty text required" });
270
352
  if (text.length > PROMPT_TEXT_MAX) return json(res, 400, { error: `text must be <= ${PROMPT_TEXT_MAX} chars` });
353
+ const parsed = parseAttachments(b.attachments);
354
+ if (parsed.error) return json(res, 400, { error: parsed.error });
271
355
  expireQueued();
356
+ if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
357
+ const liveBytes = state.sessionPrompts
358
+ .filter(p => p.project === project && (p.status === "queued" || p.status === "claimed"))
359
+ .reduce((total, p) => total + (p.attachments || []).reduce((sum, a) => sum + a.size, 0), 0);
360
+ const incomingBytes = parsed.attachments.reduce((sum, a) => sum + a.bytes.length, 0);
361
+ if (liveBytes + incomingBytes > PROJECT_ATTACHMENT_BYTES_MAX) {
362
+ return json(res, 413, { error: "project queued and claimed attachments exceed 64 MiB" });
363
+ }
272
364
  state.sessionPromptSeq += 1;
273
365
  const p = { id: state.sessionPromptSeq, project, text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
274
- state.sessionPrompts.push(p);
275
- // Cap: evict oldest terminal rows first so live work is never the first casualty.
276
- while (state.sessionPrompts.length > PROMPT_CAP) {
277
- const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
278
- state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
366
+ try {
367
+ p.attachments = storeAttachments(p.id, parsed.attachments);
368
+ } catch (error) {
369
+ return json(res, 500, { error: String(error?.message || error) });
279
370
  }
371
+ state.sessionPrompts.push(p);
372
+ trimPrompts();
280
373
  markDirty();
281
- appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
374
+ if (p.attachments.length) appendEvent("chat.prompt.queued", project, p.by,
375
+ { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX), attachments: p.attachments.length });
376
+ else appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
282
377
  pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
283
378
  return json(res, 200, { id: p.id, status: "queued" });
284
379
  }
285
380
 
286
381
  // The public view of a queue row: plain prompts are kind "prompt"; answers carry their
287
382
  // session, toolId, picks and other; crew commands carry action, seats, from and to —
288
- // so the claiming desktop has everything it needs.
383
+ // so the claiming desktop has everything it needs. Attachments ride as metadata only;
384
+ // the bytes go to the claimer through GET /session/attachment.
289
385
  const promptView = (p) => {
290
386
  const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
387
+ if (p.attachments?.length) view.attachments = p.attachments.map(({ name, size }) => ({ name, size }));
291
388
  if (p.kind === "answer") {
292
389
  view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
293
390
  if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
@@ -315,6 +412,15 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
315
412
  expireQueued();
316
413
  const p = findPrompt(b.id);
317
414
  if (!p) return json(res, 404, { error: "prompt not found" });
415
+ // Renewal never grants a second delivery; only the original host can extend a live claim.
416
+ if (b.renew === true) {
417
+ if (p.status !== "claimed" || p.host !== String(b.host || "").slice(0, 120)) {
418
+ return json(res, 409, { error: "no matching live claim", status: p.status });
419
+ }
420
+ p.renewedAt = now();
421
+ markDirty();
422
+ return json(res, 200, { ok: true });
423
+ }
318
424
  // The first claim wins; a second claim — even from the same host — gets 409.
319
425
  if (p.status !== "queued") return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
320
426
  p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
@@ -327,6 +433,9 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
327
433
  const b = await body(req);
328
434
  const p = findPrompt(b.id);
329
435
  if (!p) return json(res, 404, { error: "prompt not found" });
436
+ if (p.status !== "claimed" || p.host !== b.host) {
437
+ return json(res, 409, { error: "no matching live claim", status: p.status });
438
+ }
330
439
  const outcome = String(b.outcome || "");
331
440
  // #12140: a crew command acks done|failed with the CLI output tail, and the status IS the
332
441
  // outcome — never "delivered". Other kinds keep the delivered/blocked/… vocabulary.
@@ -335,9 +444,6 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
335
444
  } else if (!ACK_OUTCOMES.has(outcome)) {
336
445
  return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
337
446
  }
338
- if (p.status === "delivered" || p.status === "done" || p.status === "failed" || p.status === "expired") {
339
- return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
340
- }
341
447
  const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
342
448
  p.outcome = outcome; p.error = error; p.ackedAt = now();
343
449
  if (p.kind === "crew") {
@@ -346,6 +452,8 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
346
452
  } else {
347
453
  p.status = outcome === "delivered" ? "delivered" : "failed";
348
454
  }
455
+ // A terminal row will never be claimed or downloaded again: its bytes go now.
456
+ unlinkAttachments(p);
349
457
  markDirty();
350
458
  const extra = { promptId: p.id, outcome };
351
459
  if (error) extra.error = error;
@@ -361,6 +469,21 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
361
469
  return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", output: p.output || "", error: p.error || "" });
362
470
  }
363
471
 
472
+ // The claiming desktop fetches one attachment's bytes. Owner-gated in auth.mjs, absent from
473
+ // the public listener's allowlist — phones upload, they never read back.
474
+ if (req.method === "GET" && P === "/session/attachment") {
475
+ const p = findPrompt(q.id);
476
+ const n = Number(q.n);
477
+ const meta = p && Number.isInteger(n) ? (p.attachments || [])[n] : null;
478
+ let bytes = null;
479
+ if (meta && attachmentsDir) { try { bytes = readFileSync(attachmentPath(meta.file)); } catch {} }
480
+ if (!bytes) return json(res, 404, { error: "attachment not found" });
481
+ if (res.headersSent || res.writableEnded || res.destroyed) return true;
482
+ res.writeHead(200, { "content-type": "image/jpeg", "content-length": bytes.length });
483
+ res.end(bytes);
484
+ return true;
485
+ }
486
+
364
487
  return false;
365
488
  }
366
489
 
package/hub.mjs CHANGED
@@ -134,6 +134,7 @@ const context = {
134
134
  const sessionChat = createSessionRuntime({
135
135
  state: store.state, markDirty: store.markDirty,
136
136
  appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams, notifier,
137
+ attachmentsDir: join(DATA_DIR, "attachments"),
137
138
  });
138
139
  const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
139
140
 
@@ -150,14 +151,27 @@ function requestHandler(runtime, ctx, gate = null) {
150
151
  const q = Object.fromEntries(u.searchParams);
151
152
  const P = u.pathname;
152
153
  if (gate && !gate.admit(req, res, P)) return;
154
+ const cap = gate ? runtime.bodyCap(P) : runtime.ownerBodyCap(req, P);
155
+ let ownerAuth;
156
+ if (req.method === "POST") {
157
+ const raw = await runtime.rawBody(req, cap);
158
+ if (raw === undefined) return runtime.json(res, 413, { error: `request body over the ${cap} byte cap` });
159
+ if (req._bodyBytes > runtime.bodyCap(P)) {
160
+ ownerAuth = await runtime.authenticate(req, runtime.authPath(u), true);
161
+ if (!ownerAuth.ok) return runtime.json(res, ownerAuth.code || 401, { error: ownerAuth.error });
162
+ }
163
+ }
153
164
  const b0 = req.method === "POST" ? await runtime.body(req) : {};
165
+ if (ownerAuth && !runtime.scopeAllows(ownerAuth.identity, runtime.projectFromRequest(P, q, b0, req.method), "owner")) {
166
+ return runtime.json(res, 403, { error: "large request body requires a project owner" });
167
+ }
154
168
  if (runtime.handleEnrollment({ req, res, P, u, b0, inviteOnly: !!gate })) return;
155
- const auth = runtime.PUBLIC_ENDPOINTS.has(P)
169
+ const auth = ownerAuth || (runtime.PUBLIC_ENDPOINTS.has(P)
156
170
  ? { ok: true, mode: ctx.AUTH_MODE, trusted: false }
157
- : await runtime.authenticate(req, runtime.authPath(u));
171
+ : await runtime.authenticate(req, runtime.authPath(u)));
158
172
  if (runtime.handleInvite({ req, res, P, auth, b0 })) return;
159
173
  if (!auth.ok) return runtime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
160
- const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0));
174
+ const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0, req.method));
161
175
  if (!authorization.ok) return runtime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
162
176
  if (gate && P === "/stream" && !gate.openStream(req, res)) return;
163
177
  if (req.method === "GET" && P === "/_op") {
@@ -181,6 +195,8 @@ server.listen(PORT, HOST, () => console.error(`[trantor] hub on http://${HOST}:$
181
195
 
182
196
  if (process.env.RELAY_PUBLIC_PORT) {
183
197
  const publicPort = Number(process.env.RELAY_PUBLIC_PORT);
184
- const publicServer = http.createServer(requestHandler(publicAuth, publicContext, createPublicGate()));
198
+ const publicGate = createPublicGate({ canUpload: pubkey =>
199
+ publicAuth.scopeAllows(publicAuth.findIdentity(pubkey), "", "write") });
200
+ const publicServer = http.createServer(requestHandler(publicAuth, publicContext, publicGate));
185
201
  publicServer.listen(publicPort, "127.0.0.1", () => console.error(`[trantor] public hub on http://127.0.0.1:${publicServer.address().port} (auth: enforce)`));
186
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.116",
3
+ "version": "0.18.117",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"