trantor 0.18.112 → 0.18.114
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/bin/pair.mjs +21 -4
- package/bin/provider.mjs +20 -10
- package/hub/auth.mjs +6 -6
- package/hub/public.mjs +108 -0
- package/hub/routes/messages.mjs +3 -1
- package/hub/routes/session.mjs +108 -8
- package/hub.mjs +51 -29
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.114",
|
|
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/bin/pair.mjs
CHANGED
|
@@ -9,10 +9,11 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
|
|
10
10
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
11
|
const args = process.argv.slice(2);
|
|
12
|
-
const USAGE = "usage: trantor pair [--role owner] [--sim] [--json]";
|
|
12
|
+
const USAGE = "usage: trantor pair [--role owner] [--sim] [--tailnet] [--json]";
|
|
13
13
|
if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
14
14
|
const asJson = args.includes("--json");
|
|
15
15
|
const openSim = args.includes("--sim");
|
|
16
|
+
const forceTailnet = args.includes("--tailnet");
|
|
16
17
|
const roleIdx = args.indexOf("--role");
|
|
17
18
|
const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
|
|
18
19
|
if (!role || role.startsWith("--")) {
|
|
@@ -63,14 +64,30 @@ if (!r.ok) {
|
|
|
63
64
|
process.exit(1);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
// v1.4: the link carries the hub's public address (Tailscale Funnel) so the phone pairs without
|
|
68
|
+
// Tailscale. /invite itself always goes to the tailnet hub — owner routes never answer in public.
|
|
69
|
+
// /health reports publicUrl; --tailnet forces the tailnet address. No publicUrl (or /health
|
|
70
|
+
// unreachable — the invite already minted) falls back to the tailnet hub.
|
|
71
|
+
const linkHub = (async () => {
|
|
72
|
+
if (forceTailnet) return { hub, public: false };
|
|
73
|
+
try {
|
|
74
|
+
const h = await fetch(`${hub}/health`).then((r) => r.json()).catch(() => ({}));
|
|
75
|
+
const pub = String(h.publicUrl || "").trim().replace(/\/+$/, "");
|
|
76
|
+
if (pub) return { hub: pub, public: true };
|
|
77
|
+
} catch {}
|
|
78
|
+
return { hub, public: false };
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
const used = await linkHub;
|
|
82
|
+
const link = `trantor://enroll?hub=${encodeURIComponent(used.hub)}&token=${j.token}`;
|
|
67
83
|
const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
|
|
68
84
|
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
|
|
69
85
|
|
|
70
86
|
if (asJson) {
|
|
71
|
-
console.log(JSON.stringify({ link, token: j.token, hub, expiresAt }));
|
|
87
|
+
console.log(JSON.stringify({ link, token: j.token, hub: used.hub, hubPublic: used.public, expiresAt }));
|
|
72
88
|
} else {
|
|
73
|
-
|
|
89
|
+
const where = used.public ? "public — the phone needs no Tailscale" : "tailnet — the phone needs Tailscale";
|
|
90
|
+
console.log(`Pair link (single use):\n${link}\nHub in the link: ${used.hub} (${where}).\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
if (openSim) {
|
package/bin/provider.mjs
CHANGED
|
@@ -46,18 +46,27 @@ function opencodeModelCount(providerOc) {
|
|
|
46
46
|
catch { return 0; }
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function
|
|
50
|
-
const world = loadWorld();
|
|
49
|
+
export function crewSeats(world = loadWorld(), countModels = opencodeModelCount) {
|
|
51
50
|
const { roster, agents, profile } = world;
|
|
51
|
+
return Object.entries(roster).map(([agent, seat]) => ({
|
|
52
|
+
agent,
|
|
53
|
+
spec: seat.launch,
|
|
54
|
+
tier: profile?.providers?.[seat.provider]?.tier || (seat.cli === "opencode" ? "—" : ""),
|
|
55
|
+
available: agents.includes(agent),
|
|
56
|
+
brought: !!seat.discovered,
|
|
57
|
+
models: seat.cli === "opencode" ? countModels(seat.providerOc) : null,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listSeats(opts) {
|
|
62
|
+
const rows = crewSeats();
|
|
63
|
+
if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; }
|
|
52
64
|
console.log("CREW SEATS — built-in + brought (BYOM). ● = available now\n");
|
|
53
|
-
for (const
|
|
54
|
-
const
|
|
55
|
-
const tier = profile?.providers?.[s.provider]?.tier || (s.cli === "opencode" ? "—" : "");
|
|
56
|
-
const kind = s.discovered ? "brought " : "built-in";
|
|
57
|
-
const models = s.cli === "opencode" ? opencodeModelCount(s.providerOc) : null;
|
|
65
|
+
for (const { agent, spec, tier, available, brought, models } of rows) {
|
|
66
|
+
const kind = brought ? "brought " : "built-in";
|
|
58
67
|
const mtxt = models == null ? "" : `${models} models`;
|
|
59
|
-
const dot =
|
|
60
|
-
console.log(` ${dot} ${
|
|
68
|
+
const dot = available ? `${C.grn}●${C.off}` : `${C.dim}○${C.off}`;
|
|
69
|
+
console.log(` ${dot} ${agent.padEnd(14)} ${C.dim}${kind}${C.off} launch: ${spec.padEnd(26)} ${tier ? `tier=${tier}` : ""} ${C.dim}${mtxt}${C.off}`);
|
|
61
70
|
}
|
|
62
71
|
console.log(`\n${C.dim}add one:${C.off} trantor provider add <name> --key sk-… --plan api`);
|
|
63
72
|
console.log(`${C.dim}browse models:${C.off} trantor models [<provider>]`);
|
|
@@ -279,7 +288,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
279
288
|
// The default list leads with the seats roster and closes with the provider status board —
|
|
280
289
|
// one command answers both "what can I launch" and "what is actually alive" (#6390).
|
|
281
290
|
const defaultList = async () => {
|
|
282
|
-
listSeats();
|
|
291
|
+
listSeats(opts);
|
|
292
|
+
if (opts.json) return;
|
|
283
293
|
console.log("");
|
|
284
294
|
printStatusRows(await providerStatus());
|
|
285
295
|
};
|
package/hub/auth.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
|
5
5
|
import { publicView } from "../lib/identity.mjs";
|
|
6
6
|
import { IDENTITY_KINDS } from "../lib/store-contract.mjs";
|
|
7
7
|
|
|
8
|
-
export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE }) {
|
|
8
|
+
export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE, seenNonces = new Map() }) {
|
|
9
9
|
const now = () => Date.now();
|
|
10
10
|
function rawBody(req) {
|
|
11
11
|
if (req._rawBody !== undefined) return Promise.resolve(req._rawBody);
|
|
@@ -55,7 +55,7 @@ const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-tr
|
|
|
55
55
|
const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
|
|
56
56
|
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
|
|
57
57
|
const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
|
|
58
|
-
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"]);
|
|
58
|
+
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
59
|
const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
|
|
60
60
|
const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
|
|
61
61
|
const authPath = (u) => `${u.pathname}${u.search || ""}`;
|
|
@@ -287,13 +287,13 @@ function inboxReadable(auth, msg, session) {
|
|
|
287
287
|
function canUseInboxSession(auth, session) {
|
|
288
288
|
return !auth?.identity || String(auth.identity.name || "") === String(session || "");
|
|
289
289
|
}
|
|
290
|
-
const seenNonces = new Map();
|
|
291
290
|
|
|
292
|
-
function handleEnrollment({ req, res, P, u, b0 }) {
|
|
291
|
+
function handleEnrollment({ req, res, P, u, b0, inviteOnly = false }) {
|
|
293
292
|
if (req.method !== "POST" || P !== "/enroll") return false;
|
|
294
293
|
const raw = req._rawBody || "";
|
|
295
294
|
const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
|
|
296
295
|
if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
|
|
296
|
+
if (inviteOnly && !b0?.token) return json(res, 403, { error: "invite token required" });
|
|
297
297
|
const requestedKind = String(b0.kind || "agent").slice(0, 40);
|
|
298
298
|
if (!IDENTITY_KINDS.includes(requestedKind)) {
|
|
299
299
|
return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
|
|
@@ -305,9 +305,9 @@ const seenNonces = new Map();
|
|
|
305
305
|
const token = String(b0.token || "");
|
|
306
306
|
const bootstrap = String(process.env.RELAY_BOOTSTRAP_TOKEN || "");
|
|
307
307
|
const noIdentitiesYet = Object.keys(state.identities || {}).length === 0;
|
|
308
|
-
const tokenMatchesBootstrap = !!bootstrap && !!token && token.length === bootstrap.length &&
|
|
308
|
+
const tokenMatchesBootstrap = !inviteOnly && !!bootstrap && !!token && token.length === bootstrap.length &&
|
|
309
309
|
timingSafeEqual(Buffer.from(token), Buffer.from(bootstrap));
|
|
310
|
-
if (bootstrap && noIdentitiesYet && tokenMatchesBootstrap) {
|
|
310
|
+
if (!inviteOnly && bootstrap && noIdentitiesYet && tokenMatchesBootstrap) {
|
|
311
311
|
scopes = [cleanScope({ project: "*", role: "owner" })].filter(Boolean);
|
|
312
312
|
enrolledBy = "bootstrap";
|
|
313
313
|
} else if (token) {
|
package/hub/public.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
|
|
3
|
+
const ALLOWED = new Set([
|
|
4
|
+
...["/health", "/projects", "/catchup", "/tasks", "/card", "/events", "/stream",
|
|
5
|
+
"/inbox", "/poll", "/recent", "/peers", "/holds", "/verify-gates", "/proposals",
|
|
6
|
+
"/balances", "/economics", "/session/chat", "/session/prompt", "/crew/roster", "/_op"]
|
|
7
|
+
.map(path => `GET ${path}`),
|
|
8
|
+
...["/enroll", "/task/update", "/task/checklist-toggle", "/send", "/delivered",
|
|
9
|
+
"/verify-gate", "/hold/decide", "/proposal/decide", "/session/prompt", "/session/answer",
|
|
10
|
+
"/crew/command", "/device/register", "/device/unregister"].map(path => `POST ${path}`),
|
|
11
|
+
]);
|
|
12
|
+
const WINDOW_MS = 60000;
|
|
13
|
+
const MAX_CLIENTS = 10000;
|
|
14
|
+
|
|
15
|
+
function normalizedIP(address) {
|
|
16
|
+
if (!isIP(address) || address.includes("%")) return "";
|
|
17
|
+
if (isIP(address) === 4) return address;
|
|
18
|
+
const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1);
|
|
19
|
+
if (!canonical.startsWith("::ffff:")) return canonical;
|
|
20
|
+
const words = canonical.slice(7).split(":").map(word => parseInt(word, 16));
|
|
21
|
+
return [words[0] >> 8, words[0] & 255, words[1] >> 8, words[1] & 255].join(".");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function publicClientIP(req) {
|
|
25
|
+
const socket = normalizedIP(req.socket.remoteAddress || "") || "unknown";
|
|
26
|
+
const loopback = socket === "::1" || socket.startsWith("127.");
|
|
27
|
+
if (!loopback) return socket;
|
|
28
|
+
// The proxy (tailscale serve/funnel) APPENDS the address it saw, so only the last entry is
|
|
29
|
+
// trustworthy; the first is whatever the caller sent and would let anyone dodge the limits.
|
|
30
|
+
const forwarded = String(req.headers["x-forwarded-for"] || "").split(",").pop().trim();
|
|
31
|
+
return normalizedIP(forwarded) || socket;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`) } = {}) {
|
|
35
|
+
const clients = new Map();
|
|
36
|
+
const admissions = new WeakMap();
|
|
37
|
+
const refusals = new Map();
|
|
38
|
+
function count(kind) { refusals.set(kind, (refusals.get(kind) || 0) + 1); }
|
|
39
|
+
function prune() {
|
|
40
|
+
const cutoff = now() - WINDOW_MS;
|
|
41
|
+
for (const [ip, bucket] of clients) {
|
|
42
|
+
if (!bucket.streams && bucket.lastSeen <= cutoff) clients.delete(ip);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function report() {
|
|
46
|
+
prune();
|
|
47
|
+
for (const [kind, total] of refusals) log(`[trantor] public refusal ${kind}: count=${total}`);
|
|
48
|
+
refusals.clear();
|
|
49
|
+
}
|
|
50
|
+
const timer = setInterval(report, WINDOW_MS);
|
|
51
|
+
timer.unref();
|
|
52
|
+
|
|
53
|
+
function refuse(res, code, kind, retry = 0) {
|
|
54
|
+
count(kind);
|
|
55
|
+
const headers = { "content-type": "application/json" };
|
|
56
|
+
if (retry) headers["retry-after"] = String(retry);
|
|
57
|
+
res.writeHead(code, headers);
|
|
58
|
+
res.end(JSON.stringify({ error: code === 404 ? "not found" : "rate limit exceeded" }));
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function admit(req, res, path) {
|
|
63
|
+
const ts = now();
|
|
64
|
+
const ip = publicClientIP(req);
|
|
65
|
+
let bucket = clients.get(ip);
|
|
66
|
+
if (!bucket) {
|
|
67
|
+
if (clients.size >= MAX_CLIENTS) prune();
|
|
68
|
+
// Fail closed at capacity; evicting a live bucket would reset its limit.
|
|
69
|
+
if (clients.size >= MAX_CLIENTS) return refuse(res, 429, "capacity", 60);
|
|
70
|
+
bucket = { start: ts, lastSeen: ts, requests: 0, enrolls: 0, streams: 0 };
|
|
71
|
+
clients.set(ip, bucket);
|
|
72
|
+
}
|
|
73
|
+
bucket.lastSeen = ts;
|
|
74
|
+
if (ts - bucket.start >= WINDOW_MS) {
|
|
75
|
+
bucket.start = ts;
|
|
76
|
+
bucket.requests = 0;
|
|
77
|
+
bucket.enrolls = 0;
|
|
78
|
+
}
|
|
79
|
+
const retry = Math.max(1, Math.ceil((bucket.start + WINDOW_MS - ts) / 1000));
|
|
80
|
+
if (bucket.requests >= 120) return refuse(res, 429, "general-limit", retry);
|
|
81
|
+
bucket.requests++;
|
|
82
|
+
if (!ALLOWED.has(`${req.method} ${path}`)) return refuse(res, 404, "route");
|
|
83
|
+
if (path === "/enroll") {
|
|
84
|
+
if (bucket.enrolls >= 10) return refuse(res, 429, "enroll-limit", retry);
|
|
85
|
+
bucket.enrolls++;
|
|
86
|
+
}
|
|
87
|
+
admissions.set(req, bucket);
|
|
88
|
+
res.once("finish", () => {
|
|
89
|
+
if (res.statusCode === 401) {
|
|
90
|
+
// The first unit was charged at admission; a failed signature costs one more.
|
|
91
|
+
if (bucket.start + WINDOW_MS > now()) bucket.requests++;
|
|
92
|
+
count("auth");
|
|
93
|
+
} else if (res.statusCode === 403) count(path === "/enroll" ? "enrollment" : "authorization");
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openStream(req, res) {
|
|
99
|
+
if (res.destroyed) return false;
|
|
100
|
+
const bucket = admissions.get(req);
|
|
101
|
+
if (bucket.streams >= 5) return refuse(res, 429, "stream-limit", 60);
|
|
102
|
+
bucket.streams++;
|
|
103
|
+
res.once("close", () => { bucket.streams--; bucket.lastSeen = now(); });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { admit, openStream, close: () => clearInterval(timer) };
|
|
108
|
+
}
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
9
9
|
appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
|
10
10
|
deliverable, inboxReadable, inboxResponse, filterReadable, canRead, streams, UI,
|
|
11
11
|
AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
|
|
12
|
-
CONTRACT_WINDOW_MS,
|
|
12
|
+
CONTRACT_WINDOW_MS, PUBLIC_URL,
|
|
13
13
|
} = ctx;
|
|
14
14
|
if (req.method === "POST" && P === "/send") {
|
|
15
15
|
const b = await body(req);
|
|
@@ -168,6 +168,8 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
168
168
|
// #8723: the count is a listing in aggregate — memory rows (past PEER_TTL_MS) never count.
|
|
169
169
|
if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.values(state.peers).filter(v => (v.lastSeen || 0) >= now() - PEER_TTL_MS).length, messages: state.messages.length, streams: streams.length,
|
|
170
170
|
persist: persistHealth.view(),
|
|
171
|
+
// v1.4: the public front door, empty when RELAY_PUBLIC_URL is unset — pairing hands it out.
|
|
172
|
+
publicUrl: PUBLIC_URL,
|
|
171
173
|
// #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
|
|
172
174
|
duty: { ...duty.dutyLiveness(), darkSinceMs: duty.darkSince ? now() - duty.darkSince : 0, queuedEscalations: duty.dutyQueuedEscalations() } });
|
|
173
175
|
return false;
|
package/hub/routes/session.mjs
CHANGED
|
@@ -15,6 +15,14 @@ const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open
|
|
|
15
15
|
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
|
|
16
16
|
const ANSWER_PICKS_MAX = 10;
|
|
17
17
|
const ANSWER_OTHER_MAX = 2000;
|
|
18
|
+
// #12140: crew commands from the phone. The hub validates and queues; the desktop relay runs the
|
|
19
|
+
// trantor CLI and acks done|failed with the output tail. A seat spec is "agent[:project]".
|
|
20
|
+
const CREW_SPEC = /^[a-z][a-z0-9-]{0,30}(:[A-Za-z0-9._/-]{1,80})?$/;
|
|
21
|
+
const CREW_SEATS_MAX = 6;
|
|
22
|
+
const CREW_OUTPUT_MAX = 2000; // the CLI's LAST 2000 chars, ANSI stripped
|
|
23
|
+
const CREW_CLAUDE_REFUSAL = "The claude seat draws your own Claude quota; start it on the Mac.";
|
|
24
|
+
// oxlint-disable-next-line no-control-regex -- stripping ANSI escape sequences IS matching control characters
|
|
25
|
+
const stripAnsi = (s) => String(s).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
18
26
|
|
|
19
27
|
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
|
|
20
28
|
const now = () => Date.now();
|
|
@@ -123,13 +131,20 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
// #12140: the desktop publishes the seats its Mac can spawn (from `trantor provider status
|
|
135
|
+
// --json`) at boot and every 10 minutes. Memory-only like the chat mirror — a restarted hub
|
|
136
|
+
// has no roster until the desktop republishes.
|
|
137
|
+
const roster = new Map(); // machine -> { machine, updatedAt, seats }
|
|
138
|
+
|
|
126
139
|
async function route({ req, res, q, P, auth, ctx }) {
|
|
127
140
|
const isChat = P === "/session/chat";
|
|
128
141
|
const isPrompts = P === "/session/prompts";
|
|
129
142
|
const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
|
|
130
143
|
const isAnswer = P === "/session/answer";
|
|
131
|
-
|
|
132
|
-
const
|
|
144
|
+
const isCrewCommand = P === "/crew/command";
|
|
145
|
+
const isCrewRoster = P === "/crew/roster";
|
|
146
|
+
if (!isChat && !isPrompts && !isPrompt && !isAnswer && !isCrewCommand && !isCrewRoster) return false;
|
|
147
|
+
const { body, json, canon, scopeAllows } = ctx;
|
|
133
148
|
|
|
134
149
|
// #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
|
|
135
150
|
// as kind "answer" — same claim, ack, expiry and audit trail; the desktop relay maps the
|
|
@@ -161,6 +176,74 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
161
176
|
return json(res, 200, { id: p.id, status: "queued" });
|
|
162
177
|
}
|
|
163
178
|
|
|
179
|
+
// #12140: the phone drives crew lifecycle through the SAME prompt queue as chat prompts and
|
|
180
|
+
// answers, as kind "crew" — same claim, ack, TTL expiry and audit trail. The hub validates the
|
|
181
|
+
// specs and refuses the dangerous shapes; the desktop relay re-validates and runs the CLI.
|
|
182
|
+
if (req.method === "POST" && isCrewCommand) {
|
|
183
|
+
const b = await body(req);
|
|
184
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
185
|
+
if (!project) return json(res, 400, { error: "project required" });
|
|
186
|
+
// Approver or owner on the project — authorize() only proves write for an unknown POST path.
|
|
187
|
+
if (!scopeAllows(auth?.identity, project, "approver")) return json(res, 403, { error: "approver role required" });
|
|
188
|
+
const action = String(b.action || "");
|
|
189
|
+
if (!["up", "down", "swap"].includes(action)) return json(res, 400, { error: "action must be up, down or swap" });
|
|
190
|
+
const seats = (Array.isArray(b.seats) ? b.seats : []).map(s => String(s ?? "").slice(0, 120));
|
|
191
|
+
if (seats.length > CREW_SEATS_MAX) return json(res, 400, { error: `at most ${CREW_SEATS_MAX} seats per command` });
|
|
192
|
+
const from = String(b.from || "").slice(0, 120);
|
|
193
|
+
const to = String(b.to || "").slice(0, 120);
|
|
194
|
+
const specs = [...seats, from, to].filter(Boolean);
|
|
195
|
+
const flagged = specs.find(v => v.startsWith("-"));
|
|
196
|
+
if (flagged) return json(res, 400, { error: `flag-like value refused: ${flagged}` });
|
|
197
|
+
const invalid = specs.find(v => !CREW_SPEC.test(v));
|
|
198
|
+
if (invalid) return json(res, 400, { error: `invalid seat spec: ${invalid}` });
|
|
199
|
+
// numbered claude seats (claude-2) draw the same quota as claude
|
|
200
|
+
if (specs.some(v => /^claude(-\d+)?$/.test(v.split(":")[0]))) return json(res, 400, { error: CREW_CLAUDE_REFUSAL });
|
|
201
|
+
if (action === "up" && !seats.length) return json(res, 400, { error: "up needs at least one seat" });
|
|
202
|
+
if (action === "swap" && (!from || !to)) return json(res, 400, { error: "swap needs from and to" });
|
|
203
|
+
expireQueued();
|
|
204
|
+
state.sessionPromptSeq += 1;
|
|
205
|
+
// down with an empty seat list means the whole project.
|
|
206
|
+
const text = action === "swap" ? `swap ${from} ${to}` : seats.length ? `${action} ${seats.join(" ")}` : `${action} ${project}`;
|
|
207
|
+
const p = { id: state.sessionPromptSeq, project, kind: "crew", action, seats, text,
|
|
208
|
+
by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
209
|
+
if (from) p.from = from;
|
|
210
|
+
if (to) p.to = to;
|
|
211
|
+
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
|
+
}
|
|
216
|
+
markDirty();
|
|
217
|
+
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "crew", action, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
218
|
+
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
219
|
+
return json(res, 200, { id: p.id, status: "queued" });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The roster row is keyed on the SIGNER's identity name (any body machine is
|
|
223
|
+
// ignored) so it matches the chat mirror's host.
|
|
224
|
+
if (req.method === "POST" && isCrewRoster) {
|
|
225
|
+
const b = await body(req);
|
|
226
|
+
if (!scopeAllows(auth?.identity, "", "owner")) return json(res, 403, { error: "owner role required" });
|
|
227
|
+
const machine = String(auth?.identity?.name || b.machine || "").slice(0, 120);
|
|
228
|
+
if (!machine) return json(res, 400, { error: "machine required" });
|
|
229
|
+
const seats = (Array.isArray(b.seats) ? b.seats : []).slice(0, 50).map(s => ({
|
|
230
|
+
agent: String(s?.agent || "").slice(0, 40),
|
|
231
|
+
// what `trantor up` takes for this seat (glm:zai-coding-plan); only a valid spec is kept
|
|
232
|
+
spec: CREW_SPEC.test(String(s?.spec || "")) ? String(s.spec) : "",
|
|
233
|
+
label: String(s?.label || "").slice(0, 120),
|
|
234
|
+
plan: String(s?.plan || "").slice(0, 40),
|
|
235
|
+
metered: !!s?.metered,
|
|
236
|
+
available: !!s?.available,
|
|
237
|
+
reason: String(s?.reason || "").slice(0, 200),
|
|
238
|
+
})).filter(s => s.agent);
|
|
239
|
+
roster.set(machine, { machine, updatedAt: now(), seats });
|
|
240
|
+
return json(res, 200, { ok: true, machine, seats: seats.length });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (req.method === "GET" && isCrewRoster) {
|
|
244
|
+
return json(res, 200, { machines: [...roster.values()] });
|
|
245
|
+
}
|
|
246
|
+
|
|
164
247
|
if (req.method === "POST" && isChat) {
|
|
165
248
|
const b = await body(req);
|
|
166
249
|
// the same canon the GET reads with, or a publish and a read could name two mirrors
|
|
@@ -201,13 +284,19 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
201
284
|
}
|
|
202
285
|
|
|
203
286
|
// The public view of a queue row: plain prompts are kind "prompt"; answers carry their
|
|
204
|
-
// session, toolId, picks and other
|
|
287
|
+
// session, toolId, picks and other; crew commands carry action, seats, from and to —
|
|
288
|
+
// so the claiming desktop has everything it needs.
|
|
205
289
|
const promptView = (p) => {
|
|
206
290
|
const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
|
|
207
291
|
if (p.kind === "answer") {
|
|
208
292
|
view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
|
|
209
293
|
if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
|
|
210
294
|
}
|
|
295
|
+
if (p.kind === "crew") {
|
|
296
|
+
view.action = p.action; view.seats = p.seats;
|
|
297
|
+
if (p.from) view.from = p.from;
|
|
298
|
+
if (p.to) view.to = p.to;
|
|
299
|
+
}
|
|
211
300
|
return view;
|
|
212
301
|
};
|
|
213
302
|
|
|
@@ -239,17 +328,28 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
239
328
|
const p = findPrompt(b.id);
|
|
240
329
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
241
330
|
const outcome = String(b.outcome || "");
|
|
242
|
-
|
|
243
|
-
|
|
331
|
+
// #12140: a crew command acks done|failed with the CLI output tail, and the status IS the
|
|
332
|
+
// outcome — never "delivered". Other kinds keep the delivered/blocked/… vocabulary.
|
|
333
|
+
if (p.kind === "crew") {
|
|
334
|
+
if (outcome !== "done" && outcome !== "failed") return json(res, 400, { error: "crew outcome must be done or failed" });
|
|
335
|
+
} else if (!ACK_OUTCOMES.has(outcome)) {
|
|
336
|
+
return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
|
|
337
|
+
}
|
|
338
|
+
if (p.status === "delivered" || p.status === "done" || p.status === "failed" || p.status === "expired") {
|
|
244
339
|
return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
245
340
|
}
|
|
246
341
|
const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
|
|
247
342
|
p.outcome = outcome; p.error = error; p.ackedAt = now();
|
|
248
|
-
p.
|
|
343
|
+
if (p.kind === "crew") {
|
|
344
|
+
p.output = stripAnsi(String(b.output || "")).slice(-CREW_OUTPUT_MAX);
|
|
345
|
+
p.status = outcome; // "done" or "failed"
|
|
346
|
+
} else {
|
|
347
|
+
p.status = outcome === "delivered" ? "delivered" : "failed";
|
|
348
|
+
}
|
|
249
349
|
markDirty();
|
|
250
350
|
const extra = { promptId: p.id, outcome };
|
|
251
351
|
if (error) extra.error = error;
|
|
252
|
-
appendEvent(
|
|
352
|
+
appendEvent(p.status === "failed" ? "chat.prompt.failed" : "chat.prompt.delivered", p.project,
|
|
253
353
|
String(auth?.identity?.name || ""), extra);
|
|
254
354
|
return json(res, 200, { ok: true, status: p.status });
|
|
255
355
|
}
|
|
@@ -258,7 +358,7 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
258
358
|
expireQueued();
|
|
259
359
|
const p = findPrompt(q.id);
|
|
260
360
|
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
261
|
-
return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", error: p.error || "" });
|
|
361
|
+
return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", output: p.output || "", error: p.error || "" });
|
|
262
362
|
}
|
|
263
363
|
|
|
264
364
|
return false;
|
package/hub.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
|
7
7
|
import { DEFAULT_ORG } from "./lib/store-contract.mjs";
|
|
8
8
|
import { createStoreRuntime } from "./hub/store.mjs";
|
|
9
9
|
import { createAuthRuntime } from "./hub/auth.mjs";
|
|
10
|
+
import { createPublicGate } from "./hub/public.mjs";
|
|
10
11
|
import { createEventRuntime } from "./hub/events.mjs";
|
|
11
12
|
import { createReaper } from "./hub/reaper.mjs";
|
|
12
13
|
import { createDuty } from "./hub/duty.mjs";
|
|
@@ -31,6 +32,9 @@ const PG_URL = process.env.RELAY_DATABASE_URL || process.env.POSTGRES_URL ||
|
|
|
31
32
|
const STORE_KIND = RAW_STORE_KIND || (PG_URL ? "pg" : "json");
|
|
32
33
|
const ORG_ID = process.env.RELAY_ORG_ID || DEFAULT_ORG;
|
|
33
34
|
const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "enforce";
|
|
35
|
+
// The public front door (Tailscale Funnel, v1.4) — /health reports it so clients learn the
|
|
36
|
+
// address the phone can reach without Tailscale. Empty when the hub has none.
|
|
37
|
+
const PUBLIC_URL = String(process.env.RELAY_PUBLIC_URL || "").trim().replace(/\/+$/, "");
|
|
34
38
|
const ENROLL_MODE = process.env.RELAY_ENROLL || "tofu";
|
|
35
39
|
const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
|
|
36
40
|
const PEER_TTL_DEFAULT_MS = 21600000;
|
|
@@ -62,9 +66,10 @@ if (!LOOPBACK_BIND && AUTH_MODE !== "enforce") {
|
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
const store = await createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA });
|
|
69
|
+
const seenNonces = new Map();
|
|
65
70
|
const authRuntime = createAuthRuntime({
|
|
66
71
|
state: store.state, markDirty: store.markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS,
|
|
67
|
-
LOOPBACK_BIND, ENROLL_MODE,
|
|
72
|
+
LOOPBACK_BIND, ENROLL_MODE, seenNonces,
|
|
68
73
|
});
|
|
69
74
|
const notifier = createNotifier({ state: store.state, markDirty: store.markDirty, canRead: authRuntime.canRead });
|
|
70
75
|
const events = createEventRuntime({ state: store.state, markDirty: store.markDirty, AUTH_MODE, ONLINE_MS, canon: authRuntime.canon, notifier });
|
|
@@ -124,7 +129,7 @@ try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch
|
|
|
124
129
|
const context = {
|
|
125
130
|
...store, ...authRuntime, ...events, ...reaper, duty, overseer, derivePhases,
|
|
126
131
|
hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
|
|
127
|
-
REAP_GRACE_MS, CONTRACT_WINDOW_MS,
|
|
132
|
+
REAP_GRACE_MS, CONTRACT_WINDOW_MS, PUBLIC_URL,
|
|
128
133
|
};
|
|
129
134
|
const sessionChat = createSessionRuntime({
|
|
130
135
|
state: store.state, markDirty: store.markDirty,
|
|
@@ -132,33 +137,50 @@ const sessionChat = createSessionRuntime({
|
|
|
132
137
|
});
|
|
133
138
|
const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
|
|
134
139
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const P = u.pathname;
|
|
139
|
-
try {
|
|
140
|
-
const b0 = req.method === "POST" ? await authRuntime.body(req) : {};
|
|
141
|
-
if (authRuntime.handleEnrollment({ req, res, P, u, b0 })) return;
|
|
142
|
-
const auth = authRuntime.PUBLIC_ENDPOINTS.has(P)
|
|
143
|
-
? { ok: true, mode: AUTH_MODE, trusted: false }
|
|
144
|
-
: await authRuntime.authenticate(req, authRuntime.authPath(u));
|
|
145
|
-
if (authRuntime.handleInvite({ req, res, P, auth, b0 })) return;
|
|
146
|
-
if (!auth.ok) return authRuntime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
|
|
147
|
-
const authorization = authRuntime.authorize(auth, req.method, P, authRuntime.projectFromRequest(P, q, b0));
|
|
148
|
-
if (!authorization.ok) return authRuntime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
|
|
149
|
-
if (req.method === "GET" && P === "/_op") {
|
|
150
|
-
const o = opOutcomes.get(String(q.id || "").slice(0, 80));
|
|
151
|
-
return authRuntime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
|
|
152
|
-
}
|
|
153
|
-
const op = req.method === "POST" ? opIdOf(b0) : null;
|
|
154
|
-
const seen = op ? opOutcomes.get(op) : null;
|
|
155
|
-
if (seen) return authRuntime.json(res, seen.code, seen.body);
|
|
156
|
-
if (op) recordOpOutcome(res, op);
|
|
157
|
-
for (const route of routes) if (await route({ req, res, q, P, auth, ctx: context })) return;
|
|
158
|
-
authRuntime.json(res, 404, { error: "not found" });
|
|
159
|
-
} catch (error) {
|
|
160
|
-
authRuntime.json(res, 500, { error: String(error?.message || error) });
|
|
161
|
-
}
|
|
140
|
+
const publicAuth = createAuthRuntime({
|
|
141
|
+
state: store.state, markDirty: store.markDirty, AUTH_MODE: "enforce", SUPERSEDE_GRACE_MS,
|
|
142
|
+
LOOPBACK_BIND: true, ENROLL_MODE: "invite", seenNonces,
|
|
162
143
|
});
|
|
144
|
+
const publicContext = { ...context, ...publicAuth, AUTH_MODE: "enforce" };
|
|
163
145
|
|
|
146
|
+
function requestHandler(runtime, ctx, gate = null) {
|
|
147
|
+
return async (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const u = new URL(req.url, "http://x");
|
|
150
|
+
const q = Object.fromEntries(u.searchParams);
|
|
151
|
+
const P = u.pathname;
|
|
152
|
+
if (gate && !gate.admit(req, res, P)) return;
|
|
153
|
+
const b0 = req.method === "POST" ? await runtime.body(req) : {};
|
|
154
|
+
if (runtime.handleEnrollment({ req, res, P, u, b0, inviteOnly: !!gate })) return;
|
|
155
|
+
const auth = runtime.PUBLIC_ENDPOINTS.has(P)
|
|
156
|
+
? { ok: true, mode: ctx.AUTH_MODE, trusted: false }
|
|
157
|
+
: await runtime.authenticate(req, runtime.authPath(u));
|
|
158
|
+
if (runtime.handleInvite({ req, res, P, auth, b0 })) return;
|
|
159
|
+
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));
|
|
161
|
+
if (!authorization.ok) return runtime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
|
|
162
|
+
if (gate && P === "/stream" && !gate.openStream(req, res)) return;
|
|
163
|
+
if (req.method === "GET" && P === "/_op") {
|
|
164
|
+
const o = opOutcomes.get(String(q.id || "").slice(0, 80));
|
|
165
|
+
return runtime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
|
|
166
|
+
}
|
|
167
|
+
const op = req.method === "POST" ? opIdOf(b0) : null;
|
|
168
|
+
const seen = op ? opOutcomes.get(op) : null;
|
|
169
|
+
if (seen) return runtime.json(res, seen.code, seen.body);
|
|
170
|
+
if (op) recordOpOutcome(res, op);
|
|
171
|
+
for (const route of routes) if (await route({ req, res, q, P, auth, ctx })) return;
|
|
172
|
+
runtime.json(res, 404, { error: "not found" });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
runtime.json(res, 500, { error: String(error?.message || error) });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = http.createServer(requestHandler(authRuntime, context));
|
|
164
180
|
server.listen(PORT, HOST, () => console.error(`[trantor] hub on http://${HOST}:${PORT} (auth: ${AUTH_MODE}; data: ${DATA})`));
|
|
181
|
+
|
|
182
|
+
if (process.env.RELAY_PUBLIC_PORT) {
|
|
183
|
+
const publicPort = Number(process.env.RELAY_PUBLIC_PORT);
|
|
184
|
+
const publicServer = http.createServer(requestHandler(publicAuth, publicContext, createPublicGate()));
|
|
185
|
+
publicServer.listen(publicPort, "127.0.0.1", () => console.error(`[trantor] public hub on http://127.0.0.1:${publicServer.address().port} (auth: enforce)`));
|
|
186
|
+
}
|