trantor 0.17.57 → 0.17.59
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew-runner.mjs +7 -1
- package/hooks/inbox-deliver.mjs +20 -8
- package/hooks/lib/api.mjs +0 -0
- package/hooks/sessionstart.mjs +6 -0
- package/hooks/stop-inbox.mjs +9 -3
- package/hub.mjs +55 -4
- package/lib/identity.mjs +79 -0
- package/lib/signed-fetch.mjs +6 -2
- package/lib/store-pg.mjs +2 -0
- package/mcp.mjs +6 -2
- package/package.json +2 -2
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "trantor",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
|
|
16
|
-
"version": "0.17.
|
|
16
|
+
"version": "0.17.59",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Sasha Bogojevic"
|
|
19
19
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.59",
|
|
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/crew-runner.mjs
CHANGED
|
@@ -66,7 +66,13 @@ async function api(path, body) {
|
|
|
66
66
|
// that shows up as a seat that quietly records nothing rather than one that errors.
|
|
67
67
|
const url = HUB + path;
|
|
68
68
|
const sig = signedHeaders(identity, url, opts);
|
|
69
|
-
|
|
69
|
+
// HARD DEADLINE on every call (2026-08-01, crebral-health kimi seat): a long-poll whose socket
|
|
70
|
+
// dies silently (idle NAT/tailscale reset, no RST delivered) otherwise hangs fetch FOREVER —
|
|
71
|
+
// the runner sat "parked" with zero connections and zero retries while its crew was rebuilt
|
|
72
|
+
// around it. Deadline = the poll's own wait window + slack, so a healthy long-poll never trips
|
|
73
|
+
// it and a dead one surfaces as a catchable error that the main loop retries in 5s.
|
|
74
|
+
const waitS = Number((path.match(/[?&]wait=(\d+)/) || [])[1] || 0);
|
|
75
|
+
const r = await fetch(url, { ...opts, headers: { ...opts.headers, ...sig }, signal: AbortSignal.timeout((waitS + 30) * 1000) });
|
|
70
76
|
return r.json();
|
|
71
77
|
}
|
|
72
78
|
|
package/hooks/inbox-deliver.mjs
CHANGED
|
@@ -33,10 +33,10 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
|
|
|
33
33
|
// additionalContext payload (the model still gets the readable message).
|
|
34
34
|
function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
|
|
35
35
|
|
|
36
|
-
async function getInbox(session, since) {
|
|
37
|
-
const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
36
|
+
async function getInbox(session, since, instance) {
|
|
37
|
+
const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
|
|
38
38
|
if (!ok || !json) throw new Error("hub unreachable");
|
|
39
|
-
return json; // { messages: [...], cursor }
|
|
39
|
+
return json; // { messages: [...], cursor, superseded? }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
|
|
@@ -58,7 +58,12 @@ function emit(ctx) {
|
|
|
58
58
|
try { JSON.parse(out); return out; } catch { return "{}"; }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async function main() {
|
|
61
|
+
async function main(stdinRaw) {
|
|
62
|
+
// The harness session_id is this session's INSTANCE id (docs/INSTANCE-KEYS-CONTRACT.md): it keys
|
|
63
|
+
// the endorsed subkey that signs our reads AND the local cursor, so a baton twin (same durable
|
|
64
|
+
// name, different session_id) has its own ledger and can't eat this session's messages.
|
|
65
|
+
let instanceId = "";
|
|
66
|
+
try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
|
|
62
67
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
63
68
|
// Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
|
|
64
69
|
// isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
|
|
@@ -70,7 +75,7 @@ async function main() {
|
|
|
70
75
|
const session = process.env.RELAY_SESSION
|
|
71
76
|
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
72
77
|
|
|
73
|
-
const safe = session.replace(/[^A-Za-z0-9_
|
|
78
|
+
const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
|
|
74
79
|
const dir = join(homedir(), ".agent-bus");
|
|
75
80
|
const pollStamp = join(dir, `inbox-poll-${safe}.stamp`);
|
|
76
81
|
const cursorFile = join(dir, `inbox-cursor-${safe}.id`);
|
|
@@ -89,7 +94,7 @@ async function main() {
|
|
|
89
94
|
// so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
|
|
90
95
|
if (!existsSync(cursorFile)) {
|
|
91
96
|
try {
|
|
92
|
-
const { cursor } = await getInbox(session, 0);
|
|
97
|
+
const { cursor } = await getInbox(session, 0, instanceId);
|
|
93
98
|
writeFileSync(cursorFile, String(cursor || 0));
|
|
94
99
|
} catch {}
|
|
95
100
|
return "{}";
|
|
@@ -98,13 +103,19 @@ async function main() {
|
|
|
98
103
|
let cursor = 0;
|
|
99
104
|
try { cursor = Number(readFileSync(cursorFile, "utf8")) || 0; } catch {}
|
|
100
105
|
|
|
101
|
-
let messages = [], next = cursor;
|
|
106
|
+
let messages = [], next = cursor, superseded = false;
|
|
102
107
|
try {
|
|
103
|
-
const res = await getInbox(session, cursor);
|
|
108
|
+
const res = await getInbox(session, cursor, instanceId);
|
|
104
109
|
messages = Array.isArray(res.messages) ? res.messages : [];
|
|
105
110
|
next = res.cursor || cursor;
|
|
111
|
+
superseded = res.superseded === true;
|
|
106
112
|
} catch { return "{}"; } // hub down / timeout — never block the tool flow
|
|
107
113
|
|
|
114
|
+
// Stand-down note (never a block): a newer instance of this durable identity claimed the baton.
|
|
115
|
+
if (superseded && !messages.length) {
|
|
116
|
+
return emit(`<trantor-inbox count="0">\n⚠️ A newer instance of this session has claimed the baton (instance supersession). Stand down: finish your current thought, do not consume bus messages, and let the new session carry the work.\n</trantor-inbox>\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
if (!messages.length) return "{}";
|
|
109
120
|
|
|
110
121
|
// Advance the cursor immediately so we don't re-inject these on the next tool call.
|
|
@@ -119,6 +130,7 @@ async function main() {
|
|
|
119
130
|
|
|
120
131
|
const ctx =
|
|
121
132
|
`<trantor-inbox count="${messages.length}">\n` +
|
|
133
|
+
(superseded ? `⚠️ A newer instance of this session has claimed the baton — stand down after handling anything addressed directly to you; the new session carries the work.\n` : "") +
|
|
122
134
|
`📬 ${messages.length} new bus message(s) arrived while you were working (you did not poll for these — Trantor surfaced them automatically):\n` +
|
|
123
135
|
lines.join("\n") + `\n` +
|
|
124
136
|
`If a peer is asking you something or waiting on you, reply now with the relay_send tool (to their session id). ` +
|
package/hooks/lib/api.mjs
CHANGED
|
Binary file
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -242,6 +242,12 @@ try {
|
|
|
242
242
|
});
|
|
243
243
|
if (handoff) {
|
|
244
244
|
process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
|
|
245
|
+
// Baton claimed → supersede every OTHER instance of this durable identity (instance-keys
|
|
246
|
+
// contract). The dying twin's next /inbox or /poll answer tells its model to stand down —
|
|
247
|
+
// the hub-enforced end of the twin message race. Best-effort; compact shows don't claim.
|
|
248
|
+
if (!isCompact && stdinObj.session_id) {
|
|
249
|
+
await jpost(`${url}/instance/supersede`, { name: session, exceptInstanceId: String(stdinObj.session_id) }, session).catch(() => {});
|
|
250
|
+
}
|
|
245
251
|
additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
|
|
246
252
|
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n`;
|
|
247
253
|
// Verification gates FIRST — these are structured "must verify before shipping" claims the prior
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -63,7 +63,10 @@ async function main() {
|
|
|
63
63
|
|
|
64
64
|
// Share inbox-deliver.mjs's cursor: ONE local delivery ledger, so a message injected mid-turn is never
|
|
65
65
|
// re-surfaced here, and vice versa.
|
|
66
|
-
|
|
66
|
+
// Same instance id + per-instance cursor as inbox-deliver (docs/INSTANCE-KEYS-CONTRACT.md):
|
|
67
|
+
// T1 and T2 share one ledger within a session; a baton twin gets its own.
|
|
68
|
+
const instanceId = String(input.session_id || "");
|
|
69
|
+
const safe = (session + (instanceId ? `@${instanceId.slice(0, 8)}` : "")).replace(/[^A-Za-z0-9_.@-]/g, "_");
|
|
67
70
|
const cursorFile = join(homedir(), ".agent-bus", `inbox-cursor-${safe}.id`);
|
|
68
71
|
// No cursor yet means inbox-deliver has never run for this session; it initialises to "now" on its
|
|
69
72
|
// first tool call. Blocking on the whole backlog of old messages would be a terrible first impression.
|
|
@@ -75,8 +78,11 @@ async function main() {
|
|
|
75
78
|
let messages = [];
|
|
76
79
|
try {
|
|
77
80
|
// PEEK: look without claiming delivery. We may yet decide to let the stop through.
|
|
78
|
-
const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
81
|
+
const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
|
|
79
82
|
if (!peek.ok) return allow();
|
|
83
|
+
// Superseded twin (instance-keys contract): a newer instance claimed the baton — this session
|
|
84
|
+
// stands down. Blocking ITS stop over messages the new instance will handle would trap it.
|
|
85
|
+
if (peek.json?.superseded === true) return allow();
|
|
80
86
|
messages = peek.json?.messages || [];
|
|
81
87
|
} catch { return allow(); } // hub down — never trap the session
|
|
82
88
|
|
|
@@ -86,7 +92,7 @@ async function main() {
|
|
|
86
92
|
// Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
|
|
87
93
|
let next = cursor;
|
|
88
94
|
try {
|
|
89
|
-
const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session });
|
|
95
|
+
const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
|
|
90
96
|
if (claim.ok) next = claim.json?.cursor || cursor;
|
|
91
97
|
} catch {}
|
|
92
98
|
try { writeFileSync(cursorFile, String(next)); } catch {}
|
package/hub.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSy
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
12
|
-
import { verifyRequest, publicView } from "./lib/identity.mjs";
|
|
12
|
+
import { verifyRequest, verifyEndorsement, publicView } from "./lib/identity.mjs";
|
|
13
13
|
import { DEFAULT_ORG } from "./lib/store-contract.mjs";
|
|
14
14
|
import { assertNoSecrets } from "./lib/scrub.mjs";
|
|
15
15
|
|
|
@@ -86,7 +86,7 @@ function scanTelemetry() {
|
|
|
86
86
|
// TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
|
|
87
87
|
// filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
|
|
88
88
|
function emptyState() {
|
|
89
|
-
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {} };
|
|
89
|
+
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {} };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function normalizeState(loaded = {}) {
|
|
@@ -108,6 +108,7 @@ function normalizeState(loaded = {}) {
|
|
|
108
108
|
s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
|
|
109
109
|
s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
|
|
110
110
|
s.inviteTokens = loaded.inviteTokens && typeof loaded.inviteTokens === "object" ? loaded.inviteTokens : {};
|
|
111
|
+
s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
|
|
111
112
|
s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
|
|
112
113
|
s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
|
|
113
114
|
for (const [session, v] of Object.entries(loaded.peers || {})) {
|
|
@@ -532,6 +533,30 @@ async function authenticate(req, path) {
|
|
|
532
533
|
if (seenNonces.has(nonceKey)) return soft("replay");
|
|
533
534
|
seenNonces.set(nonceKey, verified.ts);
|
|
534
535
|
if (seenNonces.size > 10000) seenNonces.delete(seenNonces.keys().next().value);
|
|
536
|
+
// Instance-subkey path (docs/INSTANCE-KEYS-CONTRACT.md): when the three endorsement headers ride
|
|
537
|
+
// along, x-trantor-pubkey was the INSTANCE key (whose signature we just verified). Verify that the
|
|
538
|
+
// claimed DURABLE key endorsed it, then authenticate AS the durable identity — the instance mints
|
|
539
|
+
// no authority of its own; it is the durable identity, time-boxed to one session.
|
|
540
|
+
const h = (k) => req.headers[k] ?? "";
|
|
541
|
+
const durableHdr = h("x-trantor-durable"), instId = h("x-trantor-inst");
|
|
542
|
+
if (durableHdr && instId) {
|
|
543
|
+
const endorsed = verifyEndorsement({
|
|
544
|
+
durablePubkey: durableHdr, instancePubkey: verified.pubkey, instanceId: instId,
|
|
545
|
+
createdAt: state.instances?.[verified.pubkey]?.createdAt || Number(h("x-trantor-inst-ts")) || 0,
|
|
546
|
+
endorsement: h("x-trantor-endorse"),
|
|
547
|
+
});
|
|
548
|
+
if (!endorsed) return soft("bad endorsement");
|
|
549
|
+
const identity = findIdentity(durableHdr);
|
|
550
|
+
if (!identity) return soft("unknown identity");
|
|
551
|
+
if (!state.instances || typeof state.instances !== "object") state.instances = {};
|
|
552
|
+
const rec = state.instances[verified.pubkey] ||
|
|
553
|
+
{ durable: durableHdr, instanceId: instId, name: identity.name || "", firstSeen: now(),
|
|
554
|
+
createdAt: Number(h("x-trantor-inst-ts")) || now(), superseded: false };
|
|
555
|
+
rec.lastSeen = now();
|
|
556
|
+
state.instances[verified.pubkey] = rec; dirty = true;
|
|
557
|
+
return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: durableHdr, identity,
|
|
558
|
+
instanceId: instId, instancePubkey: verified.pubkey, superseded: !!rec.superseded };
|
|
559
|
+
}
|
|
535
560
|
const identity = findIdentity(verified.pubkey);
|
|
536
561
|
if (!identity) return soft("unknown identity");
|
|
537
562
|
return { ok: true, mode: AUTH_MODE, trusted: true, pubkey: verified.pubkey, identity };
|
|
@@ -971,6 +996,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
971
996
|
.filter(c => c.project === proj || linked.has(c.project)); } catch {}
|
|
972
997
|
return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
|
|
973
998
|
}
|
|
999
|
+
// Supersession (docs/INSTANCE-KEYS-CONTRACT.md): EXPLICIT, never automatic — the baton-claim
|
|
1000
|
+
// path calls this when a fresh session consumes a handoff. Marks every OTHER instance of the
|
|
1001
|
+
// named durable identity superseded; their /inbox + /poll answers then carry superseded:true so
|
|
1002
|
+
// their own hooks tell the model to stand down. Informational, never a hard block. Accepted
|
|
1003
|
+
// only from an endorsed instance of the SAME durable identity, or the owner.
|
|
1004
|
+
if (req.method === "POST" && P === "/instance/supersede") {
|
|
1005
|
+
const b = await body(req);
|
|
1006
|
+
const name = String(b.name || "").slice(0, 200);
|
|
1007
|
+
const except = String(b.exceptInstanceId || "").slice(0, 200);
|
|
1008
|
+
if (!name) return json(res, 400, { error: "name required" });
|
|
1009
|
+
if (AUTH_MODE !== "off") {
|
|
1010
|
+
const sameIdentity = auth?.identity && String(auth.identity.name || "") === name;
|
|
1011
|
+
const isOwner = auth?.identity?.kind === "human" || scopeAllows(auth?.identity, "", "owner");
|
|
1012
|
+
if (!sameIdentity && !isOwner && AUTH_MODE === "enforce") return json(res, 403, { error: "forbidden" });
|
|
1013
|
+
}
|
|
1014
|
+
let flipped = 0;
|
|
1015
|
+
for (const rec of Object.values(state.instances || {})) {
|
|
1016
|
+
if (rec.name !== name || rec.superseded) continue;
|
|
1017
|
+
if (except && rec.instanceId === except) continue;
|
|
1018
|
+
rec.superseded = now(); flipped++;
|
|
1019
|
+
}
|
|
1020
|
+
if (flipped) dirty = true;
|
|
1021
|
+
return json(res, 200, { ok: true, superseded: flipped });
|
|
1022
|
+
}
|
|
974
1023
|
if (req.method === "POST" && P === "/overseer/narrate") {
|
|
975
1024
|
const b = await body(req);
|
|
976
1025
|
const ev = state.events.find(e => e.id === Number(b.eventId) && e.type === "overseer.warn");
|
|
@@ -1798,7 +1847,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1798
1847
|
// through). Advancing the ledger on a peek would tell the deferred waker the message had been
|
|
1799
1848
|
// delivered when nobody ever saw it — a silent hole exactly where this feature is supposed to help.
|
|
1800
1849
|
if (q.peek !== "1") markDelivered(q.session, cursor);
|
|
1801
|
-
|
|
1850
|
+
// superseded (instance-keys contract): a baton twin that lost the claim learns it HERE, via
|
|
1851
|
+
// its own read — its hooks turn this into a stand-down note for the model. Never a block.
|
|
1852
|
+
return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor });
|
|
1802
1853
|
}
|
|
1803
1854
|
if (req.method === "GET" && P === "/poll") {
|
|
1804
1855
|
if (!canUseInboxSession(auth, q.session)) return json(res, 403, { error: "forbidden" });
|
|
@@ -1807,7 +1858,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1807
1858
|
const deadline = now() + waitMs;
|
|
1808
1859
|
const tick = () => {
|
|
1809
1860
|
const msgs = state.messages.filter(m => m.id > since && deliverable(m, q.session) && inboxReadable(auth, m, q.session));
|
|
1810
|
-
if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, { messages: msgs, cursor }); }
|
|
1861
|
+
if (msgs.length || now() >= deadline) { touch(q.session, undefined, undefined, undefined, auth); const cursor = msgs.length ? msgs[msgs.length - 1].id : since; markDelivered(q.session, cursor); return json(res, 200, auth?.superseded ? { messages: msgs, cursor, superseded: true } : { messages: msgs, cursor }); }
|
|
1811
1862
|
setTimeout(tick, 300);
|
|
1812
1863
|
};
|
|
1813
1864
|
return tick();
|
package/lib/identity.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
|
|
23
23
|
export const SCHEME = "trantor-v1";
|
|
24
|
+
export const INST_SCHEME = "trantor-inst-v1";
|
|
24
25
|
export const SKEW_MS = 120_000; // reject a signature older/newer than this
|
|
25
26
|
export const HDR = {
|
|
26
27
|
pubkey: "x-trantor-pubkey",
|
|
@@ -28,6 +29,15 @@ export const HDR = {
|
|
|
28
29
|
ts: "x-trantor-ts",
|
|
29
30
|
nonce: "x-trantor-nonce",
|
|
30
31
|
};
|
|
32
|
+
// Instance-subkey headers (docs/INSTANCE-KEYS-CONTRACT.md). When present, x-trantor-pubkey above
|
|
33
|
+
// carries the INSTANCE pubkey and the request is attributed to the DURABLE identity below, provided
|
|
34
|
+
// the endorsement verifies. Absent → plain v1, unchanged.
|
|
35
|
+
export const HDR_INST = {
|
|
36
|
+
durable: "x-trantor-durable",
|
|
37
|
+
inst: "x-trantor-inst",
|
|
38
|
+
endorse: "x-trantor-endorse",
|
|
39
|
+
instTs: "x-trantor-inst-ts",
|
|
40
|
+
};
|
|
31
41
|
|
|
32
42
|
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
33
43
|
const keysDir = () => join(busDir(), "keys");
|
|
@@ -134,6 +144,75 @@ export function signRequest(identity, { method, path, body }) {
|
|
|
134
144
|
};
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
// --- session-instance subkeys (docs/INSTANCE-KEYS-CONTRACT.md) ---------------------------------
|
|
148
|
+
// A per-session-instance keypair, ENDORSED by the durable identity: the durable key signs
|
|
149
|
+
// endorsementString(...), attesting "this instance pubkey speaks as me until it dies". The durable
|
|
150
|
+
// key keeps enrollment/grants/attribution; the instance key signs traffic and dies with the
|
|
151
|
+
// session. Fixes the handoff-twin identity collision (two lineages, two subkeys, one durable name)
|
|
152
|
+
// and gives per-restart credential freshness (the teams login-session model).
|
|
153
|
+
export function endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }) {
|
|
154
|
+
// Same discipline as canonicalString: newline-joined, fixed arity, no field may contain \n.
|
|
155
|
+
return [INST_SCHEME, String(durablePubkey), String(instancePubkey), String(instanceId), String(createdAt)].join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const instanceKeyPath = (name, instanceId) =>
|
|
159
|
+
join(keysDir(), "instances", `${safe(name)}@${safe(instanceId)}.json`);
|
|
160
|
+
|
|
161
|
+
// Mint-or-load an endorsed instance identity. Atomic against racing hooks exactly like
|
|
162
|
+
// loadOrCreate: two processes of one session (hooks vs MCP would use DIFFERENT instanceIds, but
|
|
163
|
+
// T1/T2 hooks share one) must converge on a single keypair for a given (name, instanceId).
|
|
164
|
+
export function loadOrCreateInstance(durableIdentity, instanceId) {
|
|
165
|
+
if (!durableIdentity?.privkey || !instanceId) return null;
|
|
166
|
+
const f = instanceKeyPath(durableIdentity.name, instanceId);
|
|
167
|
+
try {
|
|
168
|
+
if (existsSync(f)) {
|
|
169
|
+
const inst = JSON.parse(readFileSync(f, "utf8"));
|
|
170
|
+
if (inst?.pubkey && inst?.privkey && inst?.endorsement) return inst;
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(join(keysDir(), "instances"), { recursive: true, mode: 0o700 });
|
|
175
|
+
const { pubkey, privkey } = generate();
|
|
176
|
+
const createdAt = Date.now();
|
|
177
|
+
const msg = Buffer.from(endorsementString({
|
|
178
|
+
durablePubkey: durableIdentity.pubkey, instancePubkey: pubkey, instanceId, createdAt,
|
|
179
|
+
}), "utf8");
|
|
180
|
+
const endorsement = cryptoSign(null, msg, privKeyObject(durableIdentity)).toString("base64");
|
|
181
|
+
const inst = {
|
|
182
|
+
name: durableIdentity.name, instanceId, pubkey, privkey, createdAt, endorsement,
|
|
183
|
+
durablePubkey: durableIdentity.pubkey,
|
|
184
|
+
};
|
|
185
|
+
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
186
|
+
writeFileSync(tmp, JSON.stringify(inst), { mode: 0o600 });
|
|
187
|
+
if (existsSync(f)) { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return inst; } }
|
|
188
|
+
renameSync(tmp, f);
|
|
189
|
+
chmodSync(f, 0o600);
|
|
190
|
+
return inst;
|
|
191
|
+
} catch { return null; } // unwritable — caller falls back to durable
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The extra wire headers an instance identity contributes (alongside the v1 set signed with ITS key).
|
|
195
|
+
export function instanceHeaders(inst) {
|
|
196
|
+
if (!inst?.durablePubkey || !inst?.endorsement) return {};
|
|
197
|
+
return {
|
|
198
|
+
[HDR_INST.durable]: inst.durablePubkey,
|
|
199
|
+
[HDR_INST.inst]: inst.instanceId,
|
|
200
|
+
[HDR_INST.endorse]: inst.endorsement,
|
|
201
|
+
[HDR_INST.instTs]: String(inst.createdAt),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pure endorsement check: did `durablePubkey` really endorse `instancePubkey` for this instanceId?
|
|
206
|
+
// Enrollment/grants/supersession stay with the hub, which owns that state.
|
|
207
|
+
export function verifyEndorsement({ durablePubkey, instancePubkey, instanceId, createdAt, endorsement }) {
|
|
208
|
+
if (!durablePubkey || !instancePubkey || !instanceId || !createdAt || !endorsement) return false;
|
|
209
|
+
if (!/^[0-9a-f]{64}$/i.test(durablePubkey) || !/^[0-9a-f]{64}$/i.test(instancePubkey)) return false;
|
|
210
|
+
try {
|
|
211
|
+
const msg = Buffer.from(endorsementString({ durablePubkey, instancePubkey, instanceId, createdAt }), "utf8");
|
|
212
|
+
return cryptoVerify(null, msg, pubFromHex(durablePubkey), Buffer.from(endorsement, "base64"));
|
|
213
|
+
} catch { return false; }
|
|
214
|
+
}
|
|
215
|
+
|
|
137
216
|
// Pure verification: signature + freshness only. Replay defence (nonce memory) and authorization
|
|
138
217
|
// (is this pubkey known? may it touch this project?) belong to the hub, which owns that state.
|
|
139
218
|
// Returns { ok, pubkey, ts, nonce, reason }.
|
package/lib/signed-fetch.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// we send the request UNSIGNED rather than failing. Under RELAY_AUTH=warn the hub accepts it and
|
|
7
7
|
// flags it; under enforce the hub rejects it and the caller sees a 401 — which is the correct place
|
|
8
8
|
// for that decision, because only the hub knows the policy.
|
|
9
|
-
import { signRequest, loadOrCreate } from "./identity.mjs";
|
|
9
|
+
import { signRequest, loadOrCreate, instanceHeaders } from "./identity.mjs";
|
|
10
10
|
|
|
11
11
|
// Sign over path + query only. The origin is not in the canonical string: the same request proxied
|
|
12
12
|
// through a different host must still verify, and the hub knows its own address.
|
|
@@ -19,11 +19,15 @@ function pathOf(url) {
|
|
|
19
19
|
export function signedHeaders(identity, url, opts = {}) {
|
|
20
20
|
if (!identity?.privkey) return {};
|
|
21
21
|
try {
|
|
22
|
-
|
|
22
|
+
const v1 = signRequest(identity, {
|
|
23
23
|
method: (opts.method || "GET").toUpperCase(),
|
|
24
24
|
path: pathOf(url),
|
|
25
25
|
body: opts.body,
|
|
26
26
|
});
|
|
27
|
+
// An INSTANCE identity (docs/INSTANCE-KEYS-CONTRACT.md) carries its endorsement; the extra
|
|
28
|
+
// headers ride along and the hub attributes the request to the durable identity. A plain
|
|
29
|
+
// durable identity contributes nothing here — v1 wire format unchanged.
|
|
30
|
+
return { ...v1, ...instanceHeaders(identity) };
|
|
27
31
|
} catch { return {}; } // never let signing break a caller
|
|
28
32
|
}
|
|
29
33
|
|
package/lib/store-pg.mjs
CHANGED
|
@@ -124,6 +124,7 @@ function kvFromState(state) {
|
|
|
124
124
|
verifyGateSeq: Number(state.verifyGateSeq || 0),
|
|
125
125
|
cardEventsBackfilled: !!state.cardEventsBackfilled,
|
|
126
126
|
inviteTokens: state.inviteTokens || {},
|
|
127
|
+
instances: state.instances || {},
|
|
127
128
|
},
|
|
128
129
|
subagentCostReset: !!state.subagentCostReset,
|
|
129
130
|
seq: Number(state.seq || 0),
|
|
@@ -669,6 +670,7 @@ export class PgStore {
|
|
|
669
670
|
handoffLog: Array.isArray(kv.handoffLog) ? kv.handoffLog : [],
|
|
670
671
|
identities,
|
|
671
672
|
inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
|
|
673
|
+
instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
|
|
672
674
|
focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
|
|
673
675
|
orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
|
|
674
676
|
};
|
package/mcp.mjs
CHANGED
|
@@ -50,10 +50,14 @@ async function seedCursor() {
|
|
|
50
50
|
// bus exists for. Signed reads are scope-filtered by the hub to this identity's grants — for a
|
|
51
51
|
// session reading its own project + DMs that is the intended behavior. The client fail-opens on a
|
|
52
52
|
// down hub (returns {ok:false}); we surface that as a thrown Error so individual tools .catch it.
|
|
53
|
+
// Instance id (docs/INSTANCE-KEYS-CONTRACT.md): the MCP server has no harness session_id, so it
|
|
54
|
+
// mints a random id at boot — its lifetime ≈ the session's. The endorsed subkey it keys signs all
|
|
55
|
+
// traffic; the durable identity keeps enrollment and attribution.
|
|
56
|
+
const INSTANCE_ID = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
53
57
|
async function api(method, path, payload) {
|
|
54
58
|
const r = method.toUpperCase() === "GET"
|
|
55
|
-
? await signedGet(path, { session: SESSION })
|
|
56
|
-
: await signedPost(path, payload, { session: SESSION });
|
|
59
|
+
? await signedGet(path, { session: SESSION, instance: INSTANCE_ID })
|
|
60
|
+
: await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID });
|
|
57
61
|
if (!r.ok) throw new Error(`hub ${r.status} on ${path}`);
|
|
58
62
|
return r.json;
|
|
59
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"pg": "^8.22.0"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
|
|
14
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-reaper.mjs && node test-events.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-inbox-delivery.mjs && node test-hub-routing.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && bash test-crew.sh"
|
|
15
15
|
},
|
|
16
16
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
17
17
|
"files": [
|