trantor 0.18.118 → 0.18.120
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/cli.mjs +4 -4
- package/bin/pair.mjs +14 -21
- package/hooks/lib/handoff-claims.mjs +9 -1
- package/hooks/lib/handoff.mjs +34 -9
- package/hooks/stop-inbox.mjs +3 -2
- package/hub/routes/session.mjs +42 -8
- package/lib/identity.mjs +66 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.120",
|
|
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/cli.mjs
CHANGED
|
@@ -169,15 +169,15 @@ switch (cmd) {
|
|
|
169
169
|
break;
|
|
170
170
|
}
|
|
171
171
|
case "invite": {
|
|
172
|
-
const {
|
|
172
|
+
const { resolveOwnerIdentity, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
|
|
173
173
|
const nameIdx = args.indexOf("--name"), scopeIdx = args.indexOf("--scope");
|
|
174
174
|
const name = nameIdx >= 0 ? args[nameIdx + 1] : "";
|
|
175
175
|
const scopeRaw = scopeIdx >= 0 ? (args[scopeIdx + 1] || "") : "";
|
|
176
176
|
if (!name || !scopeRaw) { console.error("usage: trantor invite --name <name> --scope <project>:<role>"); process.exit(1); }
|
|
177
177
|
const [project, role = "write"] = scopeRaw.split(":");
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const id =
|
|
178
|
+
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
179
|
+
const hub = resolveHubInfo().url;
|
|
180
|
+
const id = await resolveOwnerIdentity(hub);
|
|
181
181
|
const payload = { scopes: [{ project, role }], ttlSec: 86400 };
|
|
182
182
|
const body = JSON.stringify(payload);
|
|
183
183
|
const sig = signRequest(id, { method: "POST", path: "/invite", body });
|
package/bin/pair.mjs
CHANGED
|
@@ -21,15 +21,23 @@ if (!role || role.startsWith("--")) {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const {
|
|
24
|
+
const { resolveOwnerIdentity, phoneHubAddress, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
|
|
25
25
|
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
26
26
|
|
|
27
27
|
const hub = resolveHubInfo().url.replace(/\/+$/, "");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
let id, used;
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(`${hub}/health`, { signal: AbortSignal.timeout(8000) });
|
|
31
|
+
if (!response.ok) throw new Error(`hub health returned HTTP ${response.status}`);
|
|
32
|
+
const health = await response.json();
|
|
33
|
+
used = phoneHubAddress(hub, String(health.publicUrl || ""), forceTailnet);
|
|
34
|
+
id = await resolveOwnerIdentity(hub);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = `Pairing failed: ${error.message}. Hub: ${hub}`;
|
|
37
|
+
if (asJson) console.log(JSON.stringify({ error: message }));
|
|
38
|
+
else console.error(message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
33
41
|
|
|
34
42
|
const payload = { scopes: [{ project: "*", role }], ttlSec: 600, kind: "human" };
|
|
35
43
|
const body = JSON.stringify(payload);
|
|
@@ -64,21 +72,6 @@ if (!r.ok) {
|
|
|
64
72
|
process.exit(1);
|
|
65
73
|
}
|
|
66
74
|
|
|
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
75
|
const link = `trantor://enroll?hub=${encodeURIComponent(used.hub)}&token=${j.token}`;
|
|
83
76
|
const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
|
|
84
77
|
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
|
|
@@ -85,7 +85,7 @@ export function loadPendingHandoff(project, { claim = true, freshSession = null,
|
|
|
85
85
|
if (!claim) return record;
|
|
86
86
|
const next = claimHandoffFile(path, freshSession, now);
|
|
87
87
|
if (!next) return null;
|
|
88
|
-
const readFirst = readFirstPaths(next.summary || "");
|
|
88
|
+
const readFirst = readFirstPaths(next.summary || "", { projectDir: next.project || process.cwd() });
|
|
89
89
|
writeFileSync(recapStampPath(dir, freshSession.session_id), JSON.stringify({
|
|
90
90
|
handoffId: next.id, file, token: next.claim.token, ts: now,
|
|
91
91
|
mode: next.mode === "unattended" ? "unattended" : "attended", readFirst,
|
|
@@ -135,3 +135,11 @@ export function completeHandoffRecap({ sessionId, transcriptPath, dir = handoffD
|
|
|
135
135
|
else traceRecapAttempt(stampPath, { ts: now, sessionId, outcome: "no-record-update" });
|
|
136
136
|
return { recapped: !!next, missed };
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
// Persist before emitting the reminder so subsequent turns cannot repeat it (#13061).
|
|
140
|
+
export function markHandoffReadReminder({ sessionId, dir = handoffDir() }) {
|
|
141
|
+
try {
|
|
142
|
+
return !!updateRecord(recapStampPath(dir, sessionId), stamp =>
|
|
143
|
+
stamp.readReminderShown ? null : { ...stamp, readReminderShown: true });
|
|
144
|
+
} catch { return false; }
|
|
145
|
+
}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// spawns a fresh same-agent session. PreCompact cannot stop compaction, so continuing with a full
|
|
4
4
|
// window means a NEW session; both paths share a per-session guard so nothing is written or spawned twice.
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
|
|
6
|
-
import { join, basename, dirname, sep } from "node:path";
|
|
6
|
+
import { join, basename, dirname, sep, resolve } from "node:path";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { execSync, spawn } from "node:child_process";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { asString, asRecord } from "../../lib/decode.mjs";
|
|
10
11
|
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
11
12
|
import { signedPost, loadIdentity } from "./api.mjs";
|
|
12
13
|
// #7037: signedHeaders is SYNCHRONOUS, which is why this path can sign at all — see hubCallSync.
|
|
@@ -692,7 +693,7 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
692
693
|
// paths disarms the #8162 gate — every check downstream passes vacuously, and the summarizer
|
|
693
694
|
// produced exactly that before #8232 asked it for the section. The memory index is knowable
|
|
694
695
|
// without the model; PRD and TDD stay the summarizer's to find (only it knows they exist).
|
|
695
|
-
if (!readFirstPaths(narrative).length) {
|
|
696
|
+
if (!readFirstPaths(narrative, { projectDir }).length) {
|
|
696
697
|
const mem = memoryIndexPath(projectDir);
|
|
697
698
|
if (mem) narrative += `\n\n## READ FIRST\n${mem}`;
|
|
698
699
|
}
|
|
@@ -869,7 +870,7 @@ export function memoryIndexPath(projectDir) {
|
|
|
869
870
|
* can check it. Recognised: a `READ FIRST`/`READ-FIRST`/`Read first:` heading or line, and every
|
|
870
871
|
* path-looking token until the next blank line or heading — narrow on purpose: a path in passing
|
|
871
872
|
* is not a reading order. @returns {string[]} paths, deduped, capped */
|
|
872
|
-
export function readFirstPaths(summary, { max = 12 } = {}) {
|
|
873
|
+
export function readFirstPaths(summary, { max = 12, projectDir = process.cwd() } = {}) {
|
|
873
874
|
const text = String(summary || "");
|
|
874
875
|
const out = [];
|
|
875
876
|
const lines = text.split(/\r?\n/);
|
|
@@ -880,10 +881,13 @@ export function readFirstPaths(summary, { max = 12 } = {}) {
|
|
|
880
881
|
if (!collecting && !opens) continue;
|
|
881
882
|
if (collecting && (line === "" || /^#{1,6}\s/.test(line))) break;
|
|
882
883
|
collecting = true;
|
|
883
|
-
//
|
|
884
|
-
for (const m of line.matchAll(
|
|
885
|
-
const p = m[1].replace(
|
|
886
|
-
if (p
|
|
884
|
+
// Preserve bracketed routes and globs as whole tokens; never turn a suffix into a path.
|
|
885
|
+
for (const m of line.matchAll(/`([^`]+)`|"([^"]+)"|'([^']+)'|[^\s`"'<>]+/g)) {
|
|
886
|
+
const p = (m[1] || m[2] || m[3] || m[0]).replace(/^\(|[),;:]$/g, "");
|
|
887
|
+
if (!/\.(?:md|mjs|js|ts|tsx|json|sql|rs|py|sh|txt)$/.test(p) || /[*?]/.test(p)) continue;
|
|
888
|
+
const full = p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(projectDir, p);
|
|
889
|
+
try { if (!statSync(full).isFile()) continue; } catch { continue; }
|
|
890
|
+
if (!out.includes(p)) out.push(p);
|
|
887
891
|
if (out.length >= max) return out;
|
|
888
892
|
}
|
|
889
893
|
}
|
|
@@ -901,8 +905,29 @@ export function pathsReadIn(transcriptPath, paths) {
|
|
|
901
905
|
// Only the tool CALLS count. A path quoted in the injected handoff summary, or in the model's own
|
|
902
906
|
// prose, must never read as "opened" — that is precisely the proxy this card exists to kill.
|
|
903
907
|
const opened = new Set();
|
|
904
|
-
for (const
|
|
905
|
-
|
|
908
|
+
for (const line of body.split("\n")) {
|
|
909
|
+
let row;
|
|
910
|
+
try { row = asRecord(JSON.parse(line)); } catch { continue; }
|
|
911
|
+
if (row?.type !== "assistant" || row.isSidechain) continue;
|
|
912
|
+
for (const call of Array.isArray(row.message?.content) ? row.message.content : []) {
|
|
913
|
+
if (call.type !== "tool_use") continue;
|
|
914
|
+
if (["Read", "Grep", "Glob", "NotebookRead"].includes(call.name)) {
|
|
915
|
+
for (const key of ["file_path", "path", "pattern"]) {
|
|
916
|
+
const path = asString(asRecord(call.input)?.[key]);
|
|
917
|
+
if (path !== null) opened.add(path);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const command = asString(asRecord(call.input)?.command);
|
|
921
|
+
if (call.name !== "Bash" || command === null) continue;
|
|
922
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|[^\s;&|]+|[;\n&|]+/g) || [];
|
|
923
|
+
let reader = false, commandStart = true;
|
|
924
|
+
for (const token of tokens) {
|
|
925
|
+
if (/^[;\n&|]+$/.test(token)) { reader = false; commandStart = true; continue; }
|
|
926
|
+
const word = token.replace(/^["']|["']$/g, "");
|
|
927
|
+
if (commandStart) { commandStart = false; reader = /^(?:\/[^ ]*\/)?(?:cat|sed|head)$/.test(word); continue; }
|
|
928
|
+
if (reader && !word.startsWith("-")) opened.add(word);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
906
931
|
}
|
|
907
932
|
const hit = (p) => {
|
|
908
933
|
const base = p.split("/").pop();
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigne
|
|
|
13
13
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
14
14
|
import { readArm, clearArm, markHandedOff, subagentsActive } from "./lib/handoff.mjs";
|
|
15
15
|
|
|
16
|
-
import { completeHandoffRecap } from "./lib/handoff-claims.mjs";
|
|
16
|
+
import { completeHandoffRecap, markHandoffReadReminder } from "./lib/handoff-claims.mjs";
|
|
17
17
|
|
|
18
18
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
|
|
@@ -129,7 +129,8 @@ async function main() {
|
|
|
129
129
|
|
|
130
130
|
if (process.env.RELAY_STOP_INBOX === "0") return allow();
|
|
131
131
|
const recap = completeHandoffRecap({ sessionId: String(input.session_id || ""), transcriptPath: input.transcript_path || "" });
|
|
132
|
-
if (recap?.missed.length && !input.stop_hook_active
|
|
132
|
+
if (recap?.missed.length && !input.stop_hook_active
|
|
133
|
+
&& markHandoffReadReminder({ sessionId: String(input.session_id || "") })) {
|
|
133
134
|
process.stdout.write(JSON.stringify({ decision: "block", reason: `Read these handoff files before recapping: ${recap.missed.join(", ")}` }));
|
|
134
135
|
return;
|
|
135
136
|
}
|
package/hub/routes/session.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const CLAIMED_EXPIRED_REASON = "The Mac claimed this prompt but never delivered
|
|
|
20
20
|
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
|
|
21
21
|
const ANSWER_PICKS_MAX = 10;
|
|
22
22
|
const ANSWER_OTHER_MAX = 2000;
|
|
23
|
+
const ANSWER_ENTRIES_MAX = 4; // CC's question limit per ask (#12912)
|
|
23
24
|
// #12161: photo attachments ride POST /session/prompt as base64 JPEGs. The phone downscales to
|
|
24
25
|
// the byte cap; the hub stores the bytes on disk next to its state (never in bus.json) and hands
|
|
25
26
|
// metadata to the claiming desktop, which downloads them through GET /session/attachment.
|
|
@@ -243,14 +244,33 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
243
244
|
if (!project) return json(res, 400, { error: "project required" });
|
|
244
245
|
if (!session) return json(res, 400, { error: "session required" });
|
|
245
246
|
if (!toolId) return json(res, 400, { error: "toolId required" });
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// #12912: a multi-question ask carries answers[] (1..4 entries, one per question in
|
|
248
|
+
// order); the old single-question { picks, other } body normalizes to one entry.
|
|
249
|
+
const normEntry = (e) => {
|
|
250
|
+
const picks = (Array.isArray(e?.picks) ? e.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
|
|
251
|
+
const other = String(e?.other || "").slice(0, ANSWER_OTHER_MAX);
|
|
252
|
+
if (!picks.length && !other.trim()) return null;
|
|
253
|
+
const entry = { picks };
|
|
254
|
+
if (other.trim()) entry.other = other;
|
|
255
|
+
return entry;
|
|
256
|
+
};
|
|
257
|
+
let answers;
|
|
258
|
+
if (Array.isArray(b.answers)) {
|
|
259
|
+
if (!b.answers.length || b.answers.length > ANSWER_ENTRIES_MAX) {
|
|
260
|
+
return json(res, 400, { error: `answers needs 1..${ANSWER_ENTRIES_MAX} entries` });
|
|
261
|
+
}
|
|
262
|
+
answers = b.answers.map(normEntry);
|
|
263
|
+
if (answers.some(a => !a)) return json(res, 400, { error: "each answer needs picks or a non-blank other" });
|
|
264
|
+
} else {
|
|
265
|
+
const entry = normEntry(b);
|
|
266
|
+
if (!entry) return json(res, 400, { error: "picks or other required" });
|
|
267
|
+
answers = [entry];
|
|
268
|
+
}
|
|
249
269
|
expireQueued();
|
|
250
270
|
if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
|
|
251
271
|
state.sessionPromptSeq += 1;
|
|
252
|
-
const text =
|
|
253
|
-
const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId,
|
|
272
|
+
const text = answers.map(a => a.other ? a.other : `picks ${a.picks.join(", ")}`).join("; ");
|
|
273
|
+
const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, answers,
|
|
254
274
|
text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
255
275
|
state.sessionPrompts.push(p);
|
|
256
276
|
trimPrompts();
|
|
@@ -379,15 +399,29 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
|
|
|
379
399
|
}
|
|
380
400
|
|
|
381
401
|
// The public view of a queue row: plain prompts are kind "prompt"; answers carry their
|
|
382
|
-
// session, toolId
|
|
402
|
+
// session, toolId and normalized answers (plus legacy picks/other for a one-entry answer); crew commands carry action, seats, from and to —
|
|
383
403
|
// so the claiming desktop has everything it needs. Attachments ride as metadata only;
|
|
384
404
|
// the bytes go to the claimer through GET /session/attachment.
|
|
385
405
|
const promptView = (p) => {
|
|
386
406
|
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
407
|
if (p.attachments?.length) view.attachments = p.attachments.map(({ name, size }) => ({ name, size }));
|
|
388
408
|
if (p.kind === "answer") {
|
|
389
|
-
view.session = p.session; view.toolId = p.toolId;
|
|
390
|
-
|
|
409
|
+
view.session = p.session; view.toolId = p.toolId;
|
|
410
|
+
// #12912: the view always carries the normalized answers; the legacy top-level picks and
|
|
411
|
+
// other ride along only for a one-entry answer so a desktop that predates it keeps working.
|
|
412
|
+
// A row persisted before answers[] shipped has picks/other but no answers — derive the one
|
|
413
|
+
// entry from them so new clients read old rows too.
|
|
414
|
+
let answers = Array.isArray(p.answers) && p.answers.length ? p.answers : null;
|
|
415
|
+
if (!answers) {
|
|
416
|
+
const entry = { picks: Array.isArray(p.picks) ? p.picks : [] };
|
|
417
|
+
if (String(p.other || "").trim()) entry.other = p.other;
|
|
418
|
+
answers = [entry];
|
|
419
|
+
}
|
|
420
|
+
view.answers = answers;
|
|
421
|
+
if (answers.length === 1) {
|
|
422
|
+
view.picks = answers[0].picks;
|
|
423
|
+
if (answers[0].other?.trim()) view.other = answers[0].other; // absent, never "", when nothing was typed
|
|
424
|
+
}
|
|
391
425
|
}
|
|
392
426
|
if (p.kind === "crew") {
|
|
393
427
|
view.action = p.action; view.seats = p.seats;
|
package/lib/identity.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSy
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
+
import { isIP } from "node:net";
|
|
8
9
|
import { isTestIdentity } from "./test-identity-names.mjs";
|
|
9
10
|
import { asFunction, asString } from "./decode.mjs";
|
|
10
11
|
|
|
@@ -207,6 +208,71 @@ export function publicView(identity) {
|
|
|
207
208
|
return { name: identity.name, kind: identity.kind, pubkey: identity.pubkey, createdAt: identity.createdAt };
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
function loopbackHost(host) {
|
|
212
|
+
return ["localhost", "localhost.", "[::1]"].includes(host) || (isIP(host) === 4 && host.startsWith("127."));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function loopbackHub(base) {
|
|
216
|
+
const url = new URL(base);
|
|
217
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") return false;
|
|
218
|
+
return loopbackHost(url.hostname);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function phoneHubAddress(base, publicUrl = "", forceTailnet = false) {
|
|
222
|
+
const hub = String(!forceTailnet && publicUrl || base).trim().replace(/\/+$/, "");
|
|
223
|
+
const url = new URL(hub);
|
|
224
|
+
if (!/^https?:$/.test(url.protocol) || loopbackHost(url.hostname) || ["0.0.0.0", "[::]"].includes(url.hostname)) {
|
|
225
|
+
throw new Error(`Your phone cannot reach ${hub}: this address only works on this Mac. Use Tailscale on the Mac and iPhone and configure the hub with the Mac's Tailscale address (then use trantor pair --tailnet), or configure a public hub URL.`);
|
|
226
|
+
}
|
|
227
|
+
return { hub, public: !forceTailnet && !!publicUrl };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The hub's owner-only read verifies a candidate without minting an invite or changing scopes.
|
|
231
|
+
export async function resolveOwnerIdentity(base) {
|
|
232
|
+
let config = {};
|
|
233
|
+
try { config = JSON.parse(readFileSync(join(busDir(), "config.json"), "utf8")); } catch {}
|
|
234
|
+
const explicit = process.env.TRANTOR_IDENTITY || process.env.RELAY_OWNER_IDENTITY || config.ownerIdentity;
|
|
235
|
+
const names = explicit ? [explicit] : listKeyNames().flatMap(file => {
|
|
236
|
+
try {
|
|
237
|
+
const record = JSON.parse(readFileSync(join(keysDir(), `${file}.json`), "utf8"));
|
|
238
|
+
return record.kind === "human" && record.name ? [record.name] : [];
|
|
239
|
+
} catch { return []; }
|
|
240
|
+
});
|
|
241
|
+
const request = async (id, path, payload) => {
|
|
242
|
+
const method = payload ? "POST" : "GET";
|
|
243
|
+
const body = payload ? JSON.stringify(payload) : undefined;
|
|
244
|
+
return fetch(`${base.replace(/\/+$/, "")}${path}`, {
|
|
245
|
+
method, body, redirect: "error", headers: { "content-type": "application/json", ...signRequest(id, { method, path, body }) },
|
|
246
|
+
signal: AbortSignal.timeout(8000),
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
let unavailable = "";
|
|
250
|
+
for (const name of names) {
|
|
251
|
+
const id = load(name);
|
|
252
|
+
if (!id) {
|
|
253
|
+
unavailable = `The key for "${name}" is unavailable on this Mac. Unlock the identity Keychain or restore this identity's key.`;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const response = await request(id, "/keys/usage");
|
|
257
|
+
if (response.ok) return id;
|
|
258
|
+
if (response.status === 401 && loopbackHub(base)) {
|
|
259
|
+
const error = await response.json();
|
|
260
|
+
if (error.error === "unknown identity") {
|
|
261
|
+
const enrolled = await request(id, "/enroll", { name: id.name, kind: "human" });
|
|
262
|
+
if (enrolled.ok && (await request(id, "/keys/usage")).ok) return id;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!names.length && loopbackHub(base)) {
|
|
267
|
+
const id = loadOrCreate("human", "human");
|
|
268
|
+
if (!id) throw new Error("The local human identity exists, but its key is unavailable. Unlock the identity Keychain or restore its key.");
|
|
269
|
+
const enrolled = await request(id, "/enroll", { name: id.name, kind: "human" });
|
|
270
|
+
if (enrolled.ok && (await request(id, "/keys/usage")).ok) return id;
|
|
271
|
+
throw new Error("The local hub refused owner enrollment. Ask its owner for an invite or configure the existing owner identity.");
|
|
272
|
+
}
|
|
273
|
+
throw new Error(unavailable || `No local owner identity is authorized on ${base}. Enroll this Mac with an owner invite, or select its existing owner key with TRANTOR_IDENTITY.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
210
276
|
// --- migrate + status (`trantor keys …`) ---------------------------------------------------------
|
|
211
277
|
// One <safe-name>.json per durable identity; the instances/ subdir holds per-session subkeys that
|
|
212
278
|
// die with the session and stay files by design.
|