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.
- package/.claude-plugin/plugin.json +1 -1
- package/hub/auth.mjs +58 -14
- package/hub/public.mjs +14 -1
- package/hub/routes/session.mjs +149 -26
- package/hub.mjs +20 -4
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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
|
-
|
|
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 => {
|
|
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
|
|
121
|
-
|
|
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
|
|
package/hub/routes/session.mjs
CHANGED
|
@@ -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
|
-
//
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
p.status
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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,
|
|
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
|
|
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
|
}
|