trantor 0.18.118 → 0.18.119
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/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.119",
|
|
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));
|
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.
|