trantor 0.18.109 → 0.18.111
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/README.md +8 -1
- package/bin/cli.mjs +2 -0
- package/bin/crew/codex-relay.mjs +8 -0
- package/bin/crew-runner.mjs +5 -3
- package/bin/keys.mjs +10 -3
- package/bin/pair.mjs +87 -0
- package/hub/auth.mjs +15 -5
- package/hub/events.mjs +19 -32
- package/hub/notifier.mjs +171 -0
- package/hub/routes/admin.mjs +1 -1
- package/hub/routes/cards.mjs +3 -2
- package/hub/routes/devices.mjs +55 -0
- package/hub/routes/messages.mjs +8 -2
- package/hub/routes/session.mjs +187 -0
- package/hub/store.mjs +6 -1
- package/hub.mjs +10 -2
- package/lib/identity.mjs +25 -17
- package/lib/key-prune.mjs +30 -2
- package/lib/store-contract.mjs +13 -0
- package/lib/store-pg.mjs +37 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.111",
|
|
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/README.md
CHANGED
|
@@ -417,9 +417,16 @@ rate, not work rate.
|
|
|
417
417
|
```
|
|
418
418
|
trantor setup | doctor | connect | profile | provider | models
|
|
419
419
|
| new <name> | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
|
|
420
|
-
| adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks
|
|
420
|
+
| adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks | pair
|
|
421
421
|
```
|
|
422
422
|
|
|
423
|
+
- **`trantor pair`** pairs the iPhone and iPad companion app (`ios/`, an operator console for
|
|
424
|
+
fleet decisions). It mints a single-use, ten-minute invite with the `approver` role (decides
|
|
425
|
+
holds, proposals and gates; never deletes projects or mints invites) and prints a
|
|
426
|
+
`trantor://enroll` link. `--sim` opens it in the booted simulator, `--json` prints it for
|
|
427
|
+
scripts, and `--role owner` widens the scope. The desktop app's Settings shows the same invite
|
|
428
|
+
as a QR code. It signs as the operator's owner identity (`TRANTOR_IDENTITY`, default `sasha@mac`).
|
|
429
|
+
|
|
423
430
|
- **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
|
|
424
431
|
(`TRANTOR_DEV_ROOT` or `~/development`), git on main (or `--from <git-url>`, or `--adopt` an
|
|
425
432
|
existing folder), installs the auto-card hook, posts the brief to the hub, and opens the first
|
package/bin/cli.mjs
CHANGED
|
@@ -187,6 +187,7 @@ switch (cmd) {
|
|
|
187
187
|
else console.error(`Invite failed: ${j.error || r.statusText}`);
|
|
188
188
|
break;
|
|
189
189
|
}
|
|
190
|
+
case "pair": run("bin/pair.mjs"); break;
|
|
190
191
|
case "enroll": {
|
|
191
192
|
const token = args[0];
|
|
192
193
|
if (!token) { console.error("usage: trantor enroll <token>"); process.exit(1); }
|
|
@@ -256,6 +257,7 @@ switch (cmd) {
|
|
|
256
257
|
trantor down tear the crew down (kills processes, closes windows, no dialogs)
|
|
257
258
|
trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
|
|
258
259
|
trantor ui open the live dashboard (board + flow views)
|
|
260
|
+
trantor pair pair a phone: mint a trantor://enroll link for the companion app — [--role owner] [--sim] [--json]
|
|
259
261
|
trantor app the DESKTOP app: status | install | update — pulls the latest DMG from GitHub Releases
|
|
260
262
|
trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
|
|
261
263
|
trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Codex's MCP env table overrides ambient runner variables and may pin another seat.
|
|
2
|
+
export function codexRelayFlags({ persona, session, project, hub }) {
|
|
3
|
+
const env = { RELAY_AGENT: persona, RELAY_SESSION: session, RELAY_PROJECT: project, RELAY_URL: hub };
|
|
4
|
+
return Object.entries(env).map(([key, value]) => {
|
|
5
|
+
const setting = `mcp_servers.relay.env.${key}=${JSON.stringify(value)}`;
|
|
6
|
+
return ` -c '${setting.replaceAll("'", "'\"'\"'")}'`;
|
|
7
|
+
}).join("");
|
|
8
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import { costLine } from "../lib/state/cost.mjs";
|
|
|
48
48
|
import { resolveStateFlags, stateFeatureOn } from "../lib/state/flags.mjs";
|
|
49
49
|
|
|
50
50
|
import { awaitSwapRelease } from "./crew/stage.mjs";
|
|
51
|
+
import { codexRelayFlags } from "./crew/codex-relay.mjs";
|
|
51
52
|
|
|
52
53
|
const releaseState = process.env.CREW_RELEASE_STATE ? JSON.parse(process.env.CREW_RELEASE_STATE) : null;
|
|
53
54
|
delete process.env.CREW_RELEASE_STATE;
|
|
@@ -243,8 +244,8 @@ let MODEL = process.env.CREW_MODEL || "";
|
|
|
243
244
|
// ZAI-coding-plan models into deepseek/…). `scrooge route` returns qualified ids.
|
|
244
245
|
if (MODEL && !MODEL.includes("/") && AGENT === "deepseek") MODEL = `deepseek/${MODEL}`;
|
|
245
246
|
const CLI = {
|
|
246
|
-
codex: { first: `codex exec{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
247
|
-
next: `codex exec resume --last{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
247
|
+
codex: { first: `codex exec{M}{R} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
248
|
+
next: `codex exec resume --last{M}{R} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
248
249
|
gemini: { first: `gemini --yolo{M} -p "$(cat {P})"`,
|
|
249
250
|
next: `gemini --yolo{M} -r latest -p "$(cat {P})"`, mflag: " -m " },
|
|
250
251
|
// kimi-code (successor to kimi-cli) has no --print (-p alone is non-interactive), REJECTS
|
|
@@ -863,7 +864,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
863
864
|
const turnEffort = effortForTurn({ agent: DRIVER, model: MODEL, launchEffort: EFFORT, cardDifficulty });
|
|
864
865
|
const mfrag = (MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "") + turnEffort.flag;
|
|
865
866
|
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
|
|
866
|
-
.replaceAll("{S}", STATE_SCHEMA_FILE)
|
|
867
|
+
.replaceAll("{S}", STATE_SCHEMA_FILE)
|
|
868
|
+
.replaceAll("{R}", () => codexRelayFlags({ persona: PERSONA, session: SESSION, project: PROJ, hub: HUB }));
|
|
867
869
|
// PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
|
|
868
870
|
// first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
|
|
869
871
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
package/bin/keys.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { load, migrateKeysToKeychain, keyLocationReport, IDENTITY_SERVICE } from "../lib/identity.mjs";
|
|
10
10
|
import { sfetch } from "../lib/signed-fetch.mjs";
|
|
11
|
-
import { pruneTestKeys } from "../lib/key-prune.mjs";
|
|
11
|
+
import { pruneTestKeys, pruneStaleTmp } from "../lib/key-prune.mjs";
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
const verb = args[0] || "status";
|
|
@@ -65,6 +65,10 @@ async function prune() {
|
|
|
65
65
|
const idleDays = at < 0 ? 7 : Number(args[at + 1]);
|
|
66
66
|
if (!Number.isFinite(idleDays) || idleDays <= 0) throw new Error("--idle-days must be a positive number");
|
|
67
67
|
if (args.includes("--apply") && args.includes("--dry-run")) throw new Error("choose --apply or --dry-run");
|
|
68
|
+
const apply = args.includes("--apply");
|
|
69
|
+
// The tmp sweep is local (a stranded atomic-write temp is not a key and needs no hub) and runs
|
|
70
|
+
// first, so it works even when the hub is unreachable; --apply gates it like the rest of prune.
|
|
71
|
+
const tmp = pruneStaleTmp({ apply });
|
|
68
72
|
const bus = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
69
73
|
let config = {};
|
|
70
74
|
try { config = JSON.parse(readFileSync(join(bus, "config.json"), "utf8")); }
|
|
@@ -79,8 +83,11 @@ async function prune() {
|
|
|
79
83
|
snapshots.push(await response.json());
|
|
80
84
|
} catch (error) { throw new Error(`cannot check hub ${hub}: ${error.message}; no keys pruned`); }
|
|
81
85
|
}
|
|
82
|
-
const result = pruneTestKeys({ snapshots, idleDays, apply
|
|
83
|
-
if (JSON_MODE) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
86
|
+
const result = pruneTestKeys({ snapshots, idleDays, apply });
|
|
87
|
+
if (JSON_MODE) { console.log(JSON.stringify({ ...result, tmp }, null, 2)); return; }
|
|
88
|
+
const tmpDid = tmp.dryRun ? "dry run — would remove" : "removed";
|
|
89
|
+
console.log(`${tmpDid} ${tmp.removed.length} stale key-write tmp file(s) older than 1h${tmp.dryRun && tmp.removed.length ? "; use --apply to remove them" : ""}`);
|
|
90
|
+
for (const f of tmp.removed) console.log(` ${f}`);
|
|
84
91
|
console.log(`${result.dryRun ? "dry run — would remove" : "removed"} ${result.removed.length} test key file(s), idle over ${idleDays} days`);
|
|
85
92
|
for (const name of result.removed) console.log(` ${name}`);
|
|
86
93
|
console.log(`kept ${result.kept.length} key file(s)${result.dryRun ? "; use --apply to remove the listed files" : ""}`);
|
package/bin/pair.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor pair — mint a phone-pairing invite and print it as a trantor://enroll link.
|
|
3
|
+
// Same owner signing as `trantor invite`, but scoped for the companion app: every project,
|
|
4
|
+
// the approver role, single use, ten minutes. The phone scans the QR (desktop Settings) or
|
|
5
|
+
// opens the link (this command, --sim) and enrolls itself.
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const USAGE = "usage: trantor pair [--role owner] [--sim] [--json]";
|
|
13
|
+
if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
14
|
+
const asJson = args.includes("--json");
|
|
15
|
+
const openSim = args.includes("--sim");
|
|
16
|
+
const roleIdx = args.indexOf("--role");
|
|
17
|
+
const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
|
|
18
|
+
if (!role || role.startsWith("--")) {
|
|
19
|
+
console.error(USAGE);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { load, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
|
|
24
|
+
const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
|
|
25
|
+
|
|
26
|
+
const hub = resolveHubInfo().url.replace(/\/+$/, "");
|
|
27
|
+
// The operator's owner key, the same one the desktop app signs with (hub.rs owner_identity).
|
|
28
|
+
// Loaded, never minted: a fresh key is unknown to the hub and every invite would 401.
|
|
29
|
+
const owner = process.env.TRANTOR_IDENTITY || "sasha@mac";
|
|
30
|
+
const id = load(owner);
|
|
31
|
+
if (!id) { console.error(`error: no key for the owner identity "${owner}". Set TRANTOR_IDENTITY, or run \`trantor keys status\``); process.exit(1); }
|
|
32
|
+
|
|
33
|
+
const payload = { scopes: [{ project: "*", role }], ttlSec: 600, kind: "human" };
|
|
34
|
+
const body = JSON.stringify(payload);
|
|
35
|
+
const sig = signRequest(id, { method: "POST", path: "/invite", body });
|
|
36
|
+
let r;
|
|
37
|
+
try {
|
|
38
|
+
r = await fetch(`${hub}/invite`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`error: hub unreachable at ${hub} — ${e.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const j = await r.json().catch(() => ({}));
|
|
44
|
+
|
|
45
|
+
// The hub may predate the approver role (#12113): an explicit 400 names it, an older hub is
|
|
46
|
+
// quieter — cleanScope folds unknown roles down to read and echoes the folded scope. Either
|
|
47
|
+
// way a link minted here would pair the phone into near-nothing, so refuse it instead.
|
|
48
|
+
const echoed = Array.isArray(j.scopes) ? j.scopes[0]?.role : undefined;
|
|
49
|
+
const roleRefused = !r.ok
|
|
50
|
+
? /role|approver/i.test(String(j.error || "")) || r.status === 400
|
|
51
|
+
: echoed !== role;
|
|
52
|
+
if (roleRefused) {
|
|
53
|
+
const why = r.ok
|
|
54
|
+
? `this hub does not know the "${role}" role yet — it answered with "${echoed}", which would pair the phone near-read-only`
|
|
55
|
+
: `hub refused the invite: ${j.error || r.statusText}`;
|
|
56
|
+
if (asJson) console.log(JSON.stringify({ error: why, hint: "retry with --role owner" }));
|
|
57
|
+
else console.error(`Pairing refused: ${why}\nRetry with --role owner.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
if (!r.ok) {
|
|
61
|
+
if (asJson) console.log(JSON.stringify({ error: j.error || r.statusText }));
|
|
62
|
+
else console.error(`Pairing failed: ${j.error || r.statusText}`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const link = `trantor://enroll?hub=${encodeURIComponent(hub)}&token=${j.token}`;
|
|
67
|
+
const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
|
|
68
|
+
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
|
|
69
|
+
|
|
70
|
+
if (asJson) {
|
|
71
|
+
console.log(JSON.stringify({ link, token: j.token, hub, expiresAt }));
|
|
72
|
+
} else {
|
|
73
|
+
console.log(`Pair link (single use):\n${link}\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (openSim) {
|
|
77
|
+
if (process.platform !== "darwin") {
|
|
78
|
+
console.error("--sim needs macOS (xcrun simctl)");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const sim = spawnSync("xcrun", ["simctl", "openurl", "booted", link], { stdio: "pipe" });
|
|
82
|
+
if (sim.status !== 0) {
|
|
83
|
+
console.error(`simctl could not open the link: ${String(sim.stderr).trim() || "no booted simulator?"}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
if (!asJson) console.log("Opened in the booted simulator.");
|
|
87
|
+
}
|
package/hub/auth.mjs
CHANGED
|
@@ -53,15 +53,16 @@ function cmpSemver(a, b) {
|
|
|
53
53
|
}
|
|
54
54
|
const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
|
|
55
55
|
const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
|
|
56
|
-
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/
|
|
57
|
-
const
|
|
58
|
-
const
|
|
56
|
+
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
|
|
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"]);
|
|
59
|
+
const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
|
|
59
60
|
const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
|
|
60
61
|
const authPath = (u) => `${u.pathname}${u.search || ""}`;
|
|
61
62
|
function cleanScope(s) {
|
|
62
63
|
if (!s || typeof s !== "object") return null;
|
|
63
64
|
const project = canon(String(s.project || s.proj || "*").slice(0, 80)) || "*";
|
|
64
|
-
const role = ["read", "write", "owner"].includes(s.role) ? s.role : "read";
|
|
65
|
+
const role = ["read", "write", "approver", "owner"].includes(s.role) ? s.role : "read";
|
|
65
66
|
return { project, role };
|
|
66
67
|
}
|
|
67
68
|
function defaultScopesFor(identity, b) {
|
|
@@ -69,7 +70,7 @@ function defaultScopesFor(identity, b) {
|
|
|
69
70
|
if (explicit.length) return explicit.slice(0, 20);
|
|
70
71
|
const name = String(b.name || identity.name || "");
|
|
71
72
|
const project = canon(String(b.project || (name.includes(":") ? name.split(":").pop() : "*")).slice(0, 80)) || "*";
|
|
72
|
-
const role = ["read", "write", "owner"].includes(b.role) ? b.role : (String(b.kind || identity.kind || "") === "agent" ? "write" : "owner");
|
|
73
|
+
const role = ["read", "write", "approver", "owner"].includes(b.role) ? b.role : (String(b.kind || identity.kind || "") === "agent" ? "write" : "owner");
|
|
73
74
|
return [{ project, role }];
|
|
74
75
|
}
|
|
75
76
|
function findIdentity(pubkey) {
|
|
@@ -115,6 +116,13 @@ function projectFromRequest(P, q, b) {
|
|
|
115
116
|
const p = state.proposals.find(x => x.id === Number(b?.id ?? q?.id));
|
|
116
117
|
return p?.project || "";
|
|
117
118
|
}
|
|
119
|
+
// claim/ack and the single-prompt GET carry only an id — authorization runs against the
|
|
120
|
+
// PROMPT's project, or a project-scoped key could claim any project's queued prompt (#12129).
|
|
121
|
+
if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack") {
|
|
122
|
+
const pr = (state.sessionPrompts || []).find(x => x.id === Number(b?.id ?? q?.id));
|
|
123
|
+
if (pr) return pr.project || "";
|
|
124
|
+
if (P !== "/session/prompt") return "";
|
|
125
|
+
}
|
|
118
126
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
119
127
|
}
|
|
120
128
|
// Cross-project actions require a declared link or the operator identity, even with wildcard scopes (#6228).
|
|
@@ -247,6 +255,8 @@ function authorize(auth, method, P, project) {
|
|
|
247
255
|
if (AUTH_MODE === "off" || PUBLIC_ENDPOINTS.has(P)) return { ok: true };
|
|
248
256
|
if (auth?.warning && AUTH_MODE === "warn") return { ok: true };
|
|
249
257
|
if (!auth?.identity) return { ok: false, code: 401, error: "signature required" };
|
|
258
|
+
// Device management is self-keyed, not project-scoped; the route enforces the signer boundary.
|
|
259
|
+
if (DEVICE_ENDPOINTS.has(P)) return { ok: true };
|
|
250
260
|
const need = OWNER_ENDPOINTS.has(P) ? "owner" : (method === "POST" ? "write" : (READ_ENDPOINTS.has(P) ? "read" : "read"));
|
|
251
261
|
if (isDutyIdentity(auth) && (need === "read" || P === "/send" || P === "/duty/failure")) return { ok: true };
|
|
252
262
|
return scopeAllows(auth.identity, project, need) ? { ok: true } : { ok: false, code: 403, error: "forbidden" };
|
package/hub/events.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: Event payloads preserve the established card wire contract, including numeric cost fields from legacy durable rows. */
|
|
2
2
|
import { publicView } from "../lib/identity.mjs";
|
|
3
3
|
|
|
4
|
-
export function createEventRuntime({ state, markDirty, AUTH_MODE, ONLINE_MS, canon }) {
|
|
4
|
+
export function createEventRuntime({ state, markDirty, AUTH_MODE, ONLINE_MS, canon, notifier = null }) {
|
|
5
5
|
const streams = [];
|
|
6
6
|
// live file claims: "project file session" -> { project, file, session, ts }. Ephemeral like
|
|
7
7
|
// presence — see the /claim handler for the design.
|
|
@@ -26,12 +26,9 @@ function applyPeerAuth(p, auth) {
|
|
|
26
26
|
function touch(session, status, project, hookVersion, auth) {
|
|
27
27
|
if (!session || session === "all") return; // "all" is a wildcard, not a real peer
|
|
28
28
|
const p = state.peers[session] || { lastSeen: 0, status: "", project: "" };
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// process into arbitrary keystrokes in a session that may be running with permissions bypassed. A
|
|
33
|
-
// message must reach an agent through a channel the agent's own harness controls (see hooks/
|
|
34
|
-
// inbox-deliver.mjs and hooks/stop-inbox.mjs), never by driving a process we do not own.
|
|
29
|
+
// Peers carry no terminal address: /send is unauthenticated and `from` is self-asserted, so one
|
|
30
|
+
// would turn any local process into arbitrary keystrokes. Messages reach agents only through
|
|
31
|
+
// channels their own harness controls (hooks/inbox-deliver.mjs and hooks/stop-inbox.mjs).
|
|
35
32
|
delete p.tty; delete p.windowId; delete p.host;
|
|
36
33
|
const wasOnline = p._on === true; // explicit flag, so the log gets ONE event per transition
|
|
37
34
|
p.lastSeen = now();
|
|
@@ -70,11 +67,9 @@ function healthOf(status) {
|
|
|
70
67
|
return "ok";
|
|
71
68
|
}
|
|
72
69
|
function deliverable(m, session) { return (m.to === session || m.to === "all") && m.from !== session; }
|
|
73
|
-
// The delivery ledger
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// That is what lets the deferred waker give in-session delivery first refusal and only type into
|
|
77
|
-
// somebody's terminal when nothing else got there first. Monotonic — a later read never lowers it.
|
|
70
|
+
// The delivery ledger records the highest message id handed to a session across /inbox and /poll,
|
|
71
|
+
// whether read by relay_inbox or a PostToolUse hook. The deferred waker gives in-session delivery
|
|
72
|
+
// first refusal; the high-water mark is monotonic.
|
|
78
73
|
function markDelivered(session, upTo) {
|
|
79
74
|
const p = state.peers[session]; const n = Number(upTo || 0);
|
|
80
75
|
if (!p || !n) return;
|
|
@@ -82,7 +77,7 @@ function markDelivered(session, upTo) {
|
|
|
82
77
|
}
|
|
83
78
|
function pushToStreams(msg) {
|
|
84
79
|
for (const s of streams) {
|
|
85
|
-
if (!deliverable(msg, s.session) || s.res.writableEnded || s.res.destroyed) continue;
|
|
80
|
+
if (!deliverable(msg, s.session) || (s.readsMessage && !s.readsMessage(msg)) || s.res.writableEnded || s.res.destroyed) continue;
|
|
86
81
|
try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {}
|
|
87
82
|
}
|
|
88
83
|
}
|
|
@@ -107,18 +102,13 @@ function inboxResponse(auth, messages, cursor, rewound = false) {
|
|
|
107
102
|
// message and nothing else — can never see it. Backwards-safe by construction.
|
|
108
103
|
function pushEventToStreams(ev) {
|
|
109
104
|
for (const s of streams) {
|
|
110
|
-
if (!s.events || s.res.writableEnded || s.res.destroyed) continue;
|
|
105
|
+
if (!s.events || (s.readsEvent && !s.readsEvent(ev)) || s.res.writableEnded || s.res.destroyed) continue;
|
|
111
106
|
try { s.res.write(`event: ev\ndata: ${JSON.stringify(ev)}\n\n`); } catch {}
|
|
112
107
|
}
|
|
113
108
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// so /history, /card, /project/merge and the TIMELINE view are byte-for-byte unaffected.
|
|
118
|
-
// • Everything else uses a DOTTED type ("message", "presence.online", "focus", "handoff.written",
|
|
119
|
-
// "lesson", "verify.gate.opened", …) and is filtered OUT of /history — it surfaces via /events.
|
|
120
|
-
// Card events carry `source` + `costUsd` so the FEED can render a git commit or a sub-agent's
|
|
121
|
-
// spend distinctly from an ordinary card move without a second lookup.
|
|
109
|
+
// The unified log keeps card events in their legacy flat shape for /history, /card and TIMELINE.
|
|
110
|
+
// Dotted non-card types surface only through /events. Card events also carry source and costUsd
|
|
111
|
+
// so the FEED can distinguish commits and sub-agent spend without another lookup.
|
|
122
112
|
const EVENT_CAP = Number(process.env.RELAY_EVENT_CAP || 20000);
|
|
123
113
|
const CARD_TYPES = new Set(["created", "moved", "updated"]);
|
|
124
114
|
const isCardEvent = e => CARD_TYPES.has(e?.type);
|
|
@@ -134,6 +124,13 @@ function appendEvent(type, project, by, extra = {}) {
|
|
|
134
124
|
state.events.push(ev);
|
|
135
125
|
if (state.events.length > EVENT_CAP) state.events.splice(0, state.events.length - EVENT_CAP);
|
|
136
126
|
pushEventToStreams(ev);
|
|
127
|
+
try {
|
|
128
|
+
if (notifier) Promise.resolve(notifier.notify(ev)).catch(error => {
|
|
129
|
+
process.stderr.write(`[trantor] push failed: ${error?.message || error}\n`);
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
process.stderr.write(`[trantor] push failed: ${error?.message || error}\n`);
|
|
133
|
+
}
|
|
137
134
|
markDirty();
|
|
138
135
|
return ev;
|
|
139
136
|
}
|
|
@@ -159,13 +156,3 @@ function appendCardEvent(type, task, by, from = null, to = null, extra = {}) {
|
|
|
159
156
|
appendCardEvent, EVENT_CAP, CARD_TYPES, isCardEvent,
|
|
160
157
|
};
|
|
161
158
|
}
|
|
162
|
-
|
|
163
|
-
// --- FLOW v2: derive a project's PHASES (the orchestrator-rooted flowchart spine) ---
|
|
164
|
-
// Real data is deps-sparse (most cards carry no deps), so we DON'T derive phases from the
|
|
165
|
-
// dependency graph. Instead: a card's phase = its title-prefix family (P5a/P5b → "P5",
|
|
166
|
-
// CBv2-1/CBfix → "CB", FA-comp1 → "FA", …) when present; otherwise it's clustered with its
|
|
167
|
-
// time-neighbours into a "Setup N" round (gap > 8h opens a new round). Phases are ordered by
|
|
168
|
-
// first-seen. The orchestrator (host session, "machine:project") vs crew ("brand:project")
|
|
169
|
-
// split gives each phase its fan-out actors; the plan/integrate spine nodes are synthetic
|
|
170
|
-
// because real per-phase orchestrator cards are rare. `sparse` flags a board that's mostly
|
|
171
|
-
// un-prefixed (the UI shows an "inferred phases" notice — never a silent blob).
|
package/hub/notifier.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import http2 from "node:http2";
|
|
2
|
+
import { createPrivateKey, sign } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const APNS_HOSTS = {
|
|
6
|
+
sandbox: "api.sandbox.push.apple.com",
|
|
7
|
+
production: "api.push.apple.com",
|
|
8
|
+
};
|
|
9
|
+
const JWT_REUSE_MS = 50 * 60 * 1000;
|
|
10
|
+
const text = (value, max = 300) => String(value || "").slice(0, max);
|
|
11
|
+
|
|
12
|
+
function apnsTransport({ host, path, headers, body }) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const session = http2.connect(`https://${host}`);
|
|
15
|
+
let settled = false;
|
|
16
|
+
const fail = error => {
|
|
17
|
+
if (settled) return;
|
|
18
|
+
settled = true;
|
|
19
|
+
session.destroy();
|
|
20
|
+
reject(error);
|
|
21
|
+
};
|
|
22
|
+
session.on("error", fail);
|
|
23
|
+
const req = session.request({ ...headers, ":method": "POST", ":path": path });
|
|
24
|
+
let status = 0;
|
|
25
|
+
let responseBody = "";
|
|
26
|
+
req.setEncoding("utf8");
|
|
27
|
+
req.on("response", headers => { status = Number(headers[":status"] || 0); });
|
|
28
|
+
req.on("data", chunk => { responseBody += chunk; });
|
|
29
|
+
req.on("error", fail);
|
|
30
|
+
req.setTimeout(10000, () => fail(new Error("APNs request timed out")));
|
|
31
|
+
req.on("end", () => {
|
|
32
|
+
if (settled) return;
|
|
33
|
+
settled = true;
|
|
34
|
+
session.close();
|
|
35
|
+
resolve({ status, body: responseBody });
|
|
36
|
+
});
|
|
37
|
+
req.end(body);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clearFor(event) {
|
|
42
|
+
// Whatever ends the item clears the phone's alert: decided, resolved, expired or withdrawn.
|
|
43
|
+
const match = /^(hold|verify\.gate|proposal)\.(?:decided|resolved|closed|expired|withdrawn)$/.exec(event.type || "");
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const kind = match[1] === "verify.gate" ? "gate" : match[1];
|
|
46
|
+
const id = Number(kind === "hold" ? event.holdId : kind === "gate" ? event.gateId : event.proposalId);
|
|
47
|
+
if (!id) return null;
|
|
48
|
+
return { kind, id, project: text(event.project, 80), silent: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function alertFor(event, state) {
|
|
52
|
+
const base = { project: text(event.project, 80), silent: false, direct: false, category: "" };
|
|
53
|
+
if (event.type === "hold.opened" && Number(event.holdId)) {
|
|
54
|
+
return { ...base, kind: "hold", id: Number(event.holdId), category: "HOLD",
|
|
55
|
+
title: `File hold #${Number(event.holdId)}`, body: text(event.file || event.reason) };
|
|
56
|
+
}
|
|
57
|
+
if (event.type === "verify.gate.opened" && Number(event.gateId)) {
|
|
58
|
+
return { ...base, kind: "gate", id: Number(event.gateId), category: "GATE",
|
|
59
|
+
title: `Verify gate #${Number(event.gateId)}`, body: text(event.claim) };
|
|
60
|
+
}
|
|
61
|
+
if (event.type === "proposal.filed" && Number(event.proposalId)) {
|
|
62
|
+
return { ...base, kind: "proposal", id: Number(event.proposalId), category: "PROPOSAL",
|
|
63
|
+
title: `Proposal #${Number(event.proposalId)}`, body: text(event.scope) };
|
|
64
|
+
}
|
|
65
|
+
if (event.type === "message" && Number(event.msgId)) {
|
|
66
|
+
return { ...base, kind: "message", id: Number(event.msgId), direct: true,
|
|
67
|
+
title: `Message from ${text(event.by, 120)}`, body: text(event.text) };
|
|
68
|
+
}
|
|
69
|
+
if (event.type === "presence.offline" && state.peers?.[event.by]?.kind === "agent" && Number(event.id)) {
|
|
70
|
+
return { ...base, kind: "presence", id: Number(event.id),
|
|
71
|
+
title: `${text(event.by, 120)} offline`, body: text(event.project ? `Crew seat offline in ${event.project}` : "Crew seat offline") };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createNotifier({ state, markDirty, canRead, transport = apnsTransport,
|
|
77
|
+
env = process.env, now = Date.now, log = line => process.stderr.write(`[trantor] ${line}\n`) }) {
|
|
78
|
+
const keyId = String(env.APNS_KEY_ID || "");
|
|
79
|
+
const teamId = String(env.APNS_TEAM_ID || "");
|
|
80
|
+
let keyText = String(env.APNS_PRIVATE_KEY || env.APNS_KEY || "").replace(/\\n/g, "\n");
|
|
81
|
+
if (!keyText && env.APNS_KEY_PATH) {
|
|
82
|
+
try { keyText = readFileSync(String(env.APNS_KEY_PATH), "utf8"); }
|
|
83
|
+
catch { log("push disabled: APNS_KEY_PATH unreadable"); }
|
|
84
|
+
}
|
|
85
|
+
const defaultBundleId = String(env.APNS_BUNDLE_ID || "ai.trantor.companion");
|
|
86
|
+
let privateKey = null;
|
|
87
|
+
if (keyId && teamId && keyText) {
|
|
88
|
+
try { privateKey = createPrivateKey(keyText); }
|
|
89
|
+
catch { log("push disabled: invalid APNs key"); }
|
|
90
|
+
} else {
|
|
91
|
+
log("push disabled: no APNs key");
|
|
92
|
+
}
|
|
93
|
+
let cachedJwt = "";
|
|
94
|
+
let cachedJwtAt = 0;
|
|
95
|
+
|
|
96
|
+
function jwt() {
|
|
97
|
+
const time = now();
|
|
98
|
+
if (cachedJwt && time >= cachedJwtAt && time - cachedJwtAt < JWT_REUSE_MS) return cachedJwt;
|
|
99
|
+
const header = Buffer.from(JSON.stringify({ alg: "ES256", kid: keyId })).toString("base64url");
|
|
100
|
+
const claims = Buffer.from(JSON.stringify({ iss: teamId, iat: Math.floor(time / 1000) })).toString("base64url");
|
|
101
|
+
const signature = sign("sha256", Buffer.from(`${header}.${claims}`), { key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
|
|
102
|
+
cachedJwt = `${header}.${claims}.${signature}`;
|
|
103
|
+
cachedJwtAt = time;
|
|
104
|
+
return cachedJwt;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dropDevice(device) {
|
|
108
|
+
const index = state.devices.findIndex(d => d.pubkey === device.pubkey && d.apns_token === device.apns_token);
|
|
109
|
+
if (index < 0) return;
|
|
110
|
+
state.devices.splice(index, 1);
|
|
111
|
+
markDirty();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function send(device, delivery) {
|
|
115
|
+
const aps = delivery.silent
|
|
116
|
+
? { "content-available": 1 }
|
|
117
|
+
: { alert: { title: delivery.title, body: delivery.body }, sound: "default" };
|
|
118
|
+
if (delivery.category) aps.category = delivery.category;
|
|
119
|
+
const payload = JSON.stringify({ aps, kind: delivery.kind, id: delivery.id, project: delivery.project });
|
|
120
|
+
// kind in the id: hold #5 and gate #5 are different alerts
|
|
121
|
+
const collapseId = `${delivery.kind}-${delivery.id}`;
|
|
122
|
+
try {
|
|
123
|
+
const result = await transport({
|
|
124
|
+
host: APNS_HOSTS[device.env] || APNS_HOSTS.production,
|
|
125
|
+
path: `/3/device/${device.apns_token}`,
|
|
126
|
+
headers: {
|
|
127
|
+
authorization: `bearer ${jwt()}`,
|
|
128
|
+
"apns-topic": device.bundle_id || defaultBundleId,
|
|
129
|
+
"apns-push-type": delivery.silent ? "background" : "alert",
|
|
130
|
+
"apns-priority": delivery.silent ? "5" : "10",
|
|
131
|
+
"apns-collapse-id": collapseId,
|
|
132
|
+
},
|
|
133
|
+
body: payload,
|
|
134
|
+
});
|
|
135
|
+
let reason = "";
|
|
136
|
+
try { reason = JSON.parse(result.body || "{}").reason || ""; } catch {}
|
|
137
|
+
if (result.status === 410 || reason === "BadDeviceToken") {
|
|
138
|
+
dropDevice(device);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (result.status !== 200) {
|
|
142
|
+
log(`push failed (${Number(result.status || 0)}): ${reason || "APNs error"}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const current = state.devices.find(d => d.pubkey === device.pubkey && d.apns_token === device.apns_token);
|
|
146
|
+
if (current) {
|
|
147
|
+
current.last_ok = now();
|
|
148
|
+
markDirty();
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
log(`push failed: ${text(error?.message || error)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function notify(event) {
|
|
156
|
+
if (!privateKey) return;
|
|
157
|
+
const delivery = clearFor(event) || alertFor(event, state);
|
|
158
|
+
if (!delivery) return;
|
|
159
|
+
const deliveries = [];
|
|
160
|
+
for (const device of [...(state.devices || [])]) {
|
|
161
|
+
const identity = state.identities?.[device.pubkey];
|
|
162
|
+
if (!identity || identity.revoked || !canRead({ identity }, delivery.project)) continue;
|
|
163
|
+
if ((device.muted_projects || []).includes(delivery.project)) continue;
|
|
164
|
+
if (delivery.direct && String(identity.name || "") !== String(event.toSession || "")) continue;
|
|
165
|
+
deliveries.push(send(device, delivery));
|
|
166
|
+
}
|
|
167
|
+
await Promise.all(deliveries);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { notify };
|
|
171
|
+
}
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -305,7 +305,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
305
305
|
const b = await body(req);
|
|
306
306
|
const hold = overseer.listHolds().find(h => h.id === Number(b.id));
|
|
307
307
|
if (!hold) return json(res, 404, { error: "hold expired or not found" });
|
|
308
|
-
if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "
|
|
308
|
+
if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "approver")) return json(res, 403, { error: "operator permission required" });
|
|
309
309
|
if (b.status !== "go" && b.status !== "nogo") return json(res, 400, { error: "status must be go or nogo" });
|
|
310
310
|
const decided = overseer.decideHold(hold.id, b.status, auth?.identity?.name || "operator");
|
|
311
311
|
return decided ? json(res, 200, { ok: true, hold: decided }) : json(res, 409, { error: "hold already decided" });
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
38
38
|
appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
|
|
39
39
|
derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
|
|
40
40
|
prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
|
|
41
|
-
ONLINE_MS, PEER_TTL_MS, AUTH_MODE,
|
|
41
|
+
ONLINE_MS, PEER_TTL_MS, AUTH_MODE, scopeAllows,
|
|
42
42
|
} = ctx;
|
|
43
43
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
44
44
|
const b = await body(req);
|
|
@@ -647,7 +647,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
647
647
|
// --- agent-proposed permissions (governance): the autonomy ladder made two-directional ---
|
|
648
648
|
// An agent needing more rope FILES A PROPOSAL here instead of assuming or working around.
|
|
649
649
|
// Enforced HERE: unbounded proposal = 400; queue caps at 3 pending/session; near-duplicate of
|
|
650
|
-
// a DENIED proposal = refused. Deciding is the HUMAN's alone — /proposal/decide
|
|
650
|
+
// a DENIED proposal = refused. Deciding is the HUMAN's alone — /proposal/decide requires approver or owner.
|
|
651
651
|
if (req.method === "POST" && P === "/propose") {
|
|
652
652
|
const b = await body(req);
|
|
653
653
|
const session = String(b.session || b.by || "").slice(0, 120);
|
|
@@ -689,6 +689,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
689
689
|
const b = await body(req);
|
|
690
690
|
const pr = state.proposals.find(p => p.id === Number(b.id));
|
|
691
691
|
if (!pr) return json(res, 404, { error: "no such proposal" });
|
|
692
|
+
if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, pr.project, "approver")) return json(res, 403, { error: "operator permission required" });
|
|
692
693
|
// A grant that GATES tool behavior needs an off-switch: the operator may REVOKE an
|
|
693
694
|
// approved proposal. Revocation is not a denial — it leaves no denial memory, so the
|
|
694
695
|
// agent may re-propose a refined bound later.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export async function routeDevices({ req, res, q, P, auth, ctx }) {
|
|
2
|
+
if (!((req.method === "POST" && (P === "/device/register" || P === "/device/unregister")) ||
|
|
3
|
+
(req.method === "GET" && P === "/devices"))) return false;
|
|
4
|
+
const { state, body, json, markDirty, canon, AUTH_MODE } = ctx;
|
|
5
|
+
if (AUTH_MODE === "off" || !auth?.identity || !auth?.pubkey) {
|
|
6
|
+
return json(res, 401, { error: "signed identity required" });
|
|
7
|
+
}
|
|
8
|
+
const pubkey = auth.pubkey;
|
|
9
|
+
state.devices ||= [];
|
|
10
|
+
|
|
11
|
+
if (req.method === "GET") {
|
|
12
|
+
if (q.pubkey && q.pubkey !== pubkey) return json(res, 403, { error: "cannot read another signer's devices" });
|
|
13
|
+
return json(res, 200, { devices: state.devices.filter(d => d.pubkey === pubkey) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const b = await body(req);
|
|
17
|
+
if (b.pubkey && b.pubkey !== pubkey) return json(res, 403, { error: "cannot manage another signer's devices" });
|
|
18
|
+
const token = String(b.apnsToken || b.apns_token || "").trim().toLowerCase();
|
|
19
|
+
if (!/^[a-f0-9]{32,128}$/.test(token)) return json(res, 400, { error: "a hexadecimal apnsToken is required" });
|
|
20
|
+
|
|
21
|
+
if (P === "/device/unregister") {
|
|
22
|
+
const index = state.devices.findIndex(d => d.apns_token === token && d.pubkey === pubkey);
|
|
23
|
+
if (index < 0) return json(res, 404, { error: "device not found" });
|
|
24
|
+
const [device] = state.devices.splice(index, 1);
|
|
25
|
+
markDirty();
|
|
26
|
+
return json(res, 200, { ok: true, device });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const requestedEnv = String(b.env || "production").toLowerCase();
|
|
30
|
+
const env = requestedEnv === "development" ? "sandbox" : requestedEnv;
|
|
31
|
+
if (env !== "sandbox" && env !== "production") return json(res, 400, { error: "env must be sandbox or production" });
|
|
32
|
+
// A token is one physical device. Re-pairing makes a new key for the same phone, so the newest
|
|
33
|
+
// registration wins and the old identity's row goes; otherwise a re-paired phone loses push.
|
|
34
|
+
const before = state.devices.length;
|
|
35
|
+
state.devices = state.devices.filter(d => d.apns_token !== token || d.pubkey === pubkey);
|
|
36
|
+
if (state.devices.length !== before) markDirty();
|
|
37
|
+
const existing = state.devices.find(d => d.apns_token === token && d.pubkey === pubkey);
|
|
38
|
+
const muted = Array.isArray(b.muted_projects) ? b.muted_projects : b.mutedProjects;
|
|
39
|
+
const device = {
|
|
40
|
+
pubkey,
|
|
41
|
+
apns_token: token,
|
|
42
|
+
env,
|
|
43
|
+
bundle_id: String(b.bundleId || b.bundle_id || process.env.APNS_BUNDLE_ID || "ai.trantor.companion").slice(0, 200),
|
|
44
|
+
name: String(b.name || auth.identity.name || "").slice(0, 120),
|
|
45
|
+
created: existing?.created || Date.now(),
|
|
46
|
+
last_ok: existing?.last_ok || 0,
|
|
47
|
+
muted_projects: Array.isArray(muted)
|
|
48
|
+
? [...new Set(muted.map(p => canon(String(p).slice(0, 80))).filter(Boolean))].slice(0, 100)
|
|
49
|
+
: (existing?.muted_projects || []),
|
|
50
|
+
};
|
|
51
|
+
if (existing) Object.assign(existing, device);
|
|
52
|
+
else state.devices.push(device);
|
|
53
|
+
markDirty();
|
|
54
|
+
return json(res, 200, { ok: true, device: existing || device });
|
|
55
|
+
}
|
package/hub/routes/messages.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
7
7
|
const {
|
|
8
8
|
state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
|
|
9
9
|
appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
|
|
10
|
-
deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
|
|
10
|
+
deliverable, inboxReadable, inboxResponse, filterReadable, canRead, streams, UI,
|
|
11
11
|
AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
|
|
12
12
|
CONTRACT_WINDOW_MS,
|
|
13
13
|
} = ctx;
|
|
@@ -143,7 +143,13 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
|
|
|
143
143
|
touch(session, q.status, undefined, undefined, auth);
|
|
144
144
|
// events=1 opts this stream into the unified log as NAMED "ev" frames (see pushEventToStreams).
|
|
145
145
|
// Existing consumers omit it and keep receiving bus messages on the default channel only.
|
|
146
|
-
|
|
146
|
+
// #12112: a stream carries only what its signer may read, by the same checks /inbox and /events
|
|
147
|
+
// apply, so a key scoped to one project never sees another project's messages or events live.
|
|
148
|
+
const entry = {
|
|
149
|
+
session, res, events: q.events === "1",
|
|
150
|
+
readsMessage: m => inboxReadable(auth, m, session),
|
|
151
|
+
readsEvent: ev => canRead(auth, ev.project || ""),
|
|
152
|
+
};
|
|
147
153
|
streams.push(entry);
|
|
148
154
|
const ka = setInterval(() => {
|
|
149
155
|
if (res.writableEnded || res.destroyed) return;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// #12129: the hub is the meeting point between the desktop relay and the phone. The chat mirror
|
|
2
|
+
// is MEMORY-ONLY (a restart answers have:0, the desktop resends its window); the prompt queue
|
|
3
|
+
// lives in state so a restart never strands a queued prompt. Live frames ride pushEventToStreams
|
|
4
|
+
// (scope-filtered, never logged); only chat.prompt.queued/.delivered/.failed are audit-appended.
|
|
5
|
+
import { setInterval } from "node:timers";
|
|
6
|
+
|
|
7
|
+
const CHAT_CAP = 400; // turns kept per project, highest idx wins
|
|
8
|
+
const CHAT_PAGE = 200; // max turns per GET
|
|
9
|
+
const PROMPT_CAP = 200;
|
|
10
|
+
const PROMPT_TEXT_MAX = 8000;
|
|
11
|
+
const EVENT_TEXT_MAX = 500;
|
|
12
|
+
const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
|
|
13
|
+
const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
|
|
14
|
+
const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
|
|
15
|
+
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent"]);
|
|
16
|
+
|
|
17
|
+
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams }) {
|
|
18
|
+
const now = () => Date.now();
|
|
19
|
+
const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
|
|
20
|
+
state.sessionPrompts ||= [];
|
|
21
|
+
state.sessionPromptSeq = Number(state.sessionPromptSeq || 0);
|
|
22
|
+
|
|
23
|
+
const findPrompt = (id) => state.sessionPrompts.find(p => p.id === Number(id));
|
|
24
|
+
|
|
25
|
+
// A prompt still queued past the TTL means no desktop relay claimed it. Expiry is lazy (on
|
|
26
|
+
// read) plus a sweep that is a no-op when nothing is queued, so an idle hub does no work.
|
|
27
|
+
function expireQueued() {
|
|
28
|
+
let changed = false;
|
|
29
|
+
for (const p of state.sessionPrompts) {
|
|
30
|
+
if (p.status !== "queued" || now() - p.ts <= PROMPT_TTL_MS) continue;
|
|
31
|
+
p.status = "expired"; p.outcome = "expired"; p.error = EXPIRED_REASON;
|
|
32
|
+
appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: EXPIRED_REASON });
|
|
33
|
+
changed = true;
|
|
34
|
+
}
|
|
35
|
+
if (changed) markDirty();
|
|
36
|
+
return changed;
|
|
37
|
+
}
|
|
38
|
+
const sweep = setInterval(() => {
|
|
39
|
+
if (state.sessionPrompts.some(p => p.status === "queued")) expireQueued();
|
|
40
|
+
}, PROMPT_SWEEP_MS);
|
|
41
|
+
sweep.unref?.();
|
|
42
|
+
|
|
43
|
+
function mergeChat(b, auth) {
|
|
44
|
+
const project = String(b.project || "").slice(0, 80);
|
|
45
|
+
const session = String(b.session || "").slice(0, 200);
|
|
46
|
+
if (!project) return { code: 400, body: { error: "project required" } };
|
|
47
|
+
if (!session) return { code: 400, body: { error: "session required" } };
|
|
48
|
+
let m = mirrors.get(project);
|
|
49
|
+
// A different session resets the mirror (handoff/compaction): the desktop then sends its
|
|
50
|
+
// whole window, and stale turns from the previous session must not survive the switch.
|
|
51
|
+
if (!m || m.session !== session) {
|
|
52
|
+
m = { session, total: 0, turns: new Map(), have: 0, meta: {}, updatedAt: 0, host: "" };
|
|
53
|
+
mirrors.set(project, m);
|
|
54
|
+
}
|
|
55
|
+
// The last turn grows while the assistant writes, so the desktop resends from the lowest
|
|
56
|
+
// changed idx and the hub REPLACES by idx — re-sends are idempotent by construction.
|
|
57
|
+
for (const t of Array.isArray(b.turns) ? b.turns : []) {
|
|
58
|
+
const idx = Number(t?.idx);
|
|
59
|
+
if (!Number.isInteger(idx) || idx < 0) continue;
|
|
60
|
+
const clean = {
|
|
61
|
+
idx,
|
|
62
|
+
role: String(t.role || "").slice(0, 40),
|
|
63
|
+
blocks: Array.isArray(t.blocks) ? t.blocks : [],
|
|
64
|
+
};
|
|
65
|
+
if (t.queued) clean.queued = true;
|
|
66
|
+
m.turns.set(idx, clean);
|
|
67
|
+
if (idx + 1 > m.have) m.have = idx + 1;
|
|
68
|
+
}
|
|
69
|
+
if (m.turns.size > CHAT_CAP) {
|
|
70
|
+
const drop = [...m.turns.keys()].sort((a, b) => a - b).slice(0, m.turns.size - CHAT_CAP);
|
|
71
|
+
for (const k of drop) m.turns.delete(k);
|
|
72
|
+
}
|
|
73
|
+
m.total = Math.max(0, Number(b.total) || 0);
|
|
74
|
+
// Property access on a primitive is safe (undefined); only null/undefined would throw.
|
|
75
|
+
const meta = b.meta || {};
|
|
76
|
+
m.meta = {
|
|
77
|
+
working: !!meta.working,
|
|
78
|
+
model: String(meta.model || "").slice(0, 120),
|
|
79
|
+
context: meta.context ?? null,
|
|
80
|
+
};
|
|
81
|
+
m.updatedAt = now();
|
|
82
|
+
m.host = String(b.host || auth?.identity?.name || "").slice(0, 120);
|
|
83
|
+
pushEventToStreams({ ts: m.updatedAt, type: "chat.updated", project, total: m.total, working: m.meta.working });
|
|
84
|
+
return { code: 200, body: { ok: true, have: m.have } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function route({ req, res, q, P, auth, ctx }) {
|
|
88
|
+
const isChat = P === "/session/chat";
|
|
89
|
+
const isPrompts = P === "/session/prompts";
|
|
90
|
+
const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
|
|
91
|
+
if (!isChat && !isPrompts && !isPrompt) return false;
|
|
92
|
+
const { body, json, canon } = ctx;
|
|
93
|
+
|
|
94
|
+
if (req.method === "POST" && isChat) {
|
|
95
|
+
const b = await body(req);
|
|
96
|
+
// the same canon the GET reads with, or a publish and a read could name two mirrors
|
|
97
|
+
b.project = canon(String(b.project || "").slice(0, 80));
|
|
98
|
+
const r = mergeChat(b, auth);
|
|
99
|
+
return json(res, r.code, r.body);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (req.method === "GET" && isChat) {
|
|
103
|
+
const project = canon(String(q.project || "").slice(0, 80));
|
|
104
|
+
const m = mirrors.get(project);
|
|
105
|
+
if (!m) return json(res, 200, { session: "", total: 0, turns: [], meta: {}, updatedAt: 0, host: "" });
|
|
106
|
+
const afterRaw = Number(q.after);
|
|
107
|
+
const after = Number.isFinite(afterRaw) ? afterRaw : -1;
|
|
108
|
+
const turns = [...m.turns.values()].filter(t => t.idx > after).sort((a, b) => a.idx - b.idx).slice(0, CHAT_PAGE);
|
|
109
|
+
return json(res, 200, { session: m.session, total: m.total, turns, meta: m.meta, updatedAt: m.updatedAt, host: m.host });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (req.method === "POST" && P === "/session/prompt") {
|
|
113
|
+
const b = await body(req);
|
|
114
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
115
|
+
const text = String(b.text || "");
|
|
116
|
+
if (!project || !text.trim()) return json(res, 400, { error: "project and non-empty text required" });
|
|
117
|
+
if (text.length > PROMPT_TEXT_MAX) return json(res, 400, { error: `text must be <= ${PROMPT_TEXT_MAX} chars` });
|
|
118
|
+
expireQueued();
|
|
119
|
+
state.sessionPromptSeq += 1;
|
|
120
|
+
const p = { id: state.sessionPromptSeq, project, text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
121
|
+
state.sessionPrompts.push(p);
|
|
122
|
+
// Cap: evict oldest terminal rows first so live work is never the first casualty.
|
|
123
|
+
while (state.sessionPrompts.length > PROMPT_CAP) {
|
|
124
|
+
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
125
|
+
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
126
|
+
}
|
|
127
|
+
markDirty();
|
|
128
|
+
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
129
|
+
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
130
|
+
return json(res, 200, { id: p.id, status: "queued" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (req.method === "GET" && isPrompts) {
|
|
134
|
+
expireQueued();
|
|
135
|
+
const project = canon(String(q.project || "").slice(0, 80));
|
|
136
|
+
const status = String(q.status || "").slice(0, 40);
|
|
137
|
+
const prompts = state.sessionPrompts
|
|
138
|
+
.filter(p => (!project || p.project === project) && (!status || p.status === status))
|
|
139
|
+
.map(p => ({ id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status }));
|
|
140
|
+
return json(res, 200, { prompts });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (req.method === "POST" && P === "/session/prompt/claim") {
|
|
144
|
+
const b = await body(req);
|
|
145
|
+
expireQueued();
|
|
146
|
+
const p = findPrompt(b.id);
|
|
147
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
148
|
+
// The first claim wins; a second claim — even from the same host — gets 409.
|
|
149
|
+
if (p.status !== "queued") return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
150
|
+
p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
|
|
151
|
+
markDirty();
|
|
152
|
+
// The full text goes only to the claimer.
|
|
153
|
+
return json(res, 200, { ok: true, prompt: { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts } });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (req.method === "POST" && P === "/session/prompt/ack") {
|
|
157
|
+
const b = await body(req);
|
|
158
|
+
const p = findPrompt(b.id);
|
|
159
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
160
|
+
const outcome = String(b.outcome || "");
|
|
161
|
+
if (!ACK_OUTCOMES.has(outcome)) return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
|
|
162
|
+
if (p.status === "delivered" || p.status === "failed" || p.status === "expired") {
|
|
163
|
+
return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
164
|
+
}
|
|
165
|
+
const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
|
|
166
|
+
p.outcome = outcome; p.error = error; p.ackedAt = now();
|
|
167
|
+
p.status = outcome === "delivered" ? "delivered" : "failed";
|
|
168
|
+
markDirty();
|
|
169
|
+
const extra = { promptId: p.id, outcome };
|
|
170
|
+
if (error) extra.error = error;
|
|
171
|
+
appendEvent(outcome === "delivered" ? "chat.prompt.delivered" : "chat.prompt.failed", p.project,
|
|
172
|
+
String(auth?.identity?.name || ""), extra);
|
|
173
|
+
return json(res, 200, { ok: true, status: p.status });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (req.method === "GET" && P === "/session/prompt") {
|
|
177
|
+
expireQueued();
|
|
178
|
+
const p = findPrompt(q.id);
|
|
179
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
180
|
+
return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", error: p.error || "" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { route, expireQueued };
|
|
187
|
+
}
|
package/hub/store.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
|
|
|
6
6
|
|
|
7
7
|
export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
|
|
8
8
|
function emptyState() {
|
|
9
|
-
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
9
|
+
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, devices: [], inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0, sessionPrompts: [], sessionPromptSeq: 0 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const CARD_LOG_MAX = 40;
|
|
@@ -107,6 +107,7 @@ function normalizeState(loaded = {}) {
|
|
|
107
107
|
s.subagentCostReset = !!loaded.subagentCostReset;
|
|
108
108
|
s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
|
|
109
109
|
s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
|
|
110
|
+
s.devices = Array.isArray(loaded.devices) ? loaded.devices : [];
|
|
110
111
|
s.inviteTokens = loaded.inviteTokens && typeof loaded.inviteTokens === "object" ? loaded.inviteTokens : {};
|
|
111
112
|
s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
|
|
112
113
|
s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
|
|
@@ -114,6 +115,10 @@ function normalizeState(loaded = {}) {
|
|
|
114
115
|
s.overseerState = loaded.overseerState || {};
|
|
115
116
|
s.dutySession = String(loaded.dutySession || "");
|
|
116
117
|
s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
|
|
118
|
+
// #12129: the phone's prompt queue survives a hub restart (JSON store); the id high-water mark
|
|
119
|
+
// is rebuilt from the rows so a reloaded queue never mints a colliding id.
|
|
120
|
+
s.sessionPrompts = Array.isArray(loaded.sessionPrompts) ? loaded.sessionPrompts : [];
|
|
121
|
+
s.sessionPromptSeq = Number(loaded.sessionPromptSeq || Math.max(0, ...s.sessionPrompts.map(p => Number(p.id) || 0))) || 0;
|
|
117
122
|
// The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
|
|
118
123
|
// otherwise from the largest id in the array — never from the array TAIL, which is exactly the
|
|
119
124
|
// assumption that let a clobbered id mint colliding event ids and kill the log.
|
package/hub.mjs
CHANGED
|
@@ -17,6 +17,9 @@ import { routeAdmin } from "./hub/routes/admin.mjs";
|
|
|
17
17
|
import { routeCards } from "./hub/routes/cards.mjs";
|
|
18
18
|
import { routeInsights } from "./hub/routes/insights.mjs";
|
|
19
19
|
import { routeMessages } from "./hub/routes/messages.mjs";
|
|
20
|
+
import { routeDevices } from "./hub/routes/devices.mjs";
|
|
21
|
+
import { createSessionRuntime } from "./hub/routes/session.mjs";
|
|
22
|
+
import { createNotifier } from "./hub/notifier.mjs";
|
|
20
23
|
|
|
21
24
|
const PORT = Number(process.env.RELAY_PORT || 4477);
|
|
22
25
|
const HOST = process.env.RELAY_HOST || "127.0.0.1";
|
|
@@ -63,7 +66,8 @@ const authRuntime = createAuthRuntime({
|
|
|
63
66
|
state: store.state, markDirty: store.markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS,
|
|
64
67
|
LOOPBACK_BIND, ENROLL_MODE,
|
|
65
68
|
});
|
|
66
|
-
const
|
|
69
|
+
const notifier = createNotifier({ state: store.state, markDirty: store.markDirty, canRead: authRuntime.canRead });
|
|
70
|
+
const events = createEventRuntime({ state: store.state, markDirty: store.markDirty, AUTH_MODE, ONLINE_MS, canon: authRuntime.canon, notifier });
|
|
67
71
|
runStoreMigrations({ ...store, subFp: authRuntime.subFp });
|
|
68
72
|
if (process.argv.includes("--smoke")) {
|
|
69
73
|
// The smoke exists to refuse a restart that would boot into a broken store: a configured pg
|
|
@@ -122,7 +126,11 @@ const context = {
|
|
|
122
126
|
hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
|
|
123
127
|
REAP_GRACE_MS, CONTRACT_WINDOW_MS,
|
|
124
128
|
};
|
|
125
|
-
const
|
|
129
|
+
const sessionChat = createSessionRuntime({
|
|
130
|
+
state: store.state, markDirty: store.markDirty,
|
|
131
|
+
appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams,
|
|
132
|
+
});
|
|
133
|
+
const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
|
|
126
134
|
|
|
127
135
|
const server = http.createServer(async (req, res) => {
|
|
128
136
|
const u = new URL(req.url, "http://x");
|
package/lib/identity.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const HDR_INST = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
31
|
-
const keysDir = () => join(busDir(), "keys");
|
|
31
|
+
export const keysDir = () => join(busDir(), "keys");
|
|
32
32
|
// A name is cosmetic, but it becomes a filename, so it gets sanitised twice over: dot-runs collapse
|
|
33
33
|
// FIRST (so no `..` survives even in principle), then anything outside the safe charset goes. Relying
|
|
34
34
|
// on "we stripped the separators" alone would make traversal one regex edit away.
|
|
@@ -187,17 +187,17 @@ export function loadOrCreate(name, kind = "agent") {
|
|
|
187
187
|
const f = keyPath(name);
|
|
188
188
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
189
189
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(tmp, JSON.stringify(id), { mode: 0o600 });
|
|
192
|
+
if (existsSync(f)) return load(name) || id; // someone beat us between check and write
|
|
193
|
+
renameSync(tmp, f);
|
|
194
|
+
chmodSync(f, 0o600);
|
|
195
|
+
processCache.set(cacheKey(String(name)), id);
|
|
196
|
+
return id;
|
|
197
|
+
} finally {
|
|
198
|
+
try { unlinkSync(tmp); } catch {} // unconditional: no early exit leaves a tmp holding a private key
|
|
194
199
|
}
|
|
195
|
-
renameSync(tmp, f);
|
|
196
|
-
chmodSync(f, 0o600);
|
|
197
|
-
processCache.set(cacheKey(String(name)), id);
|
|
198
|
-
return id;
|
|
199
200
|
} catch {
|
|
200
|
-
try { unlinkSync(tmp); } catch {}
|
|
201
201
|
return load(name) || id; // lost the race, or unwritable — never throw
|
|
202
202
|
}
|
|
203
203
|
}
|
|
@@ -220,8 +220,12 @@ export function listKeyNames() {
|
|
|
220
220
|
function writeKeyStub(f, id) {
|
|
221
221
|
const stub = { name: id.name, kind: id.kind, pubkey: id.pubkey, createdAt: id.createdAt, keychain: IDENTITY_SERVICE };
|
|
222
222
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(tmp, JSON.stringify(stub), { mode: 0o600 });
|
|
225
|
+
renameSync(tmp, f);
|
|
226
|
+
} finally {
|
|
227
|
+
try { unlinkSync(tmp); } catch {} // a failed rename must not strand its tmp
|
|
228
|
+
}
|
|
225
229
|
try { chmodSync(f, 0o600); } catch {}
|
|
226
230
|
}
|
|
227
231
|
|
|
@@ -353,11 +357,15 @@ export function loadOrCreateInstance(durableIdentity, instanceId) {
|
|
|
353
357
|
durablePubkey: durableIdentity.pubkey,
|
|
354
358
|
};
|
|
355
359
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
try {
|
|
361
|
+
writeFileSync(tmp, JSON.stringify(inst), { mode: 0o600 });
|
|
362
|
+
if (existsSync(f)) { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return inst; } }
|
|
363
|
+
renameSync(tmp, f);
|
|
364
|
+
chmodSync(f, 0o600);
|
|
365
|
+
return inst;
|
|
366
|
+
} finally {
|
|
367
|
+
try { unlinkSync(tmp); } catch {} // the race loser's tmp still holds a private key — never strand it
|
|
368
|
+
}
|
|
361
369
|
} catch { return null; } // unwritable — caller falls back to durable
|
|
362
370
|
}
|
|
363
371
|
|
package/lib/key-prune.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { lstatSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { keyPath, listKeyNames, keysDir } from "./identity.mjs";
|
|
3
4
|
|
|
4
5
|
import { isTestIdentity } from "./test-identity-names.mjs";
|
|
5
6
|
export { isTestIdentity } from "./test-identity-names.mjs";
|
|
@@ -47,3 +48,30 @@ export function pruneTestKeys({ snapshots, idleDays = 7, apply = false, now = Da
|
|
|
47
48
|
}
|
|
48
49
|
return { dryRun: !apply, idleDays, removed, kept };
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
// An interrupted atomic key write strands `<name>.json.<pid>.<rand>.tmp` — and a tmp from a lost
|
|
53
|
+
// race holds a full PRIVATE key. Real keys always end in `.json`, so anything ending in `.tmp` in
|
|
54
|
+
// the keys tree is by construction an incomplete write; an hour past its mtime no live writer is
|
|
55
|
+
// still between write and rename, so it is safe to remove (--apply only, like the rest of prune).
|
|
56
|
+
export function pruneStaleTmp({ maxAgeMs = 3_600_000, apply = false, now = Date.now() } = {}) {
|
|
57
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) throw new Error("tmp age must be a positive number");
|
|
58
|
+
const cutoff = now - maxAgeMs;
|
|
59
|
+
const removed = [], kept = [];
|
|
60
|
+
for (const dir of [keysDir(), join(keysDir(), "instances")]) {
|
|
61
|
+
let entries = [];
|
|
62
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.name.endsWith(".tmp")) continue; // a real key file never matches
|
|
65
|
+
const file = join(dir, entry.name);
|
|
66
|
+
let stat;
|
|
67
|
+
try { stat = lstatSync(file); } catch { continue; } // vanished mid-sweep
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue; // never follow a link out of the keys tree
|
|
69
|
+
if (stat.mtimeMs >= cutoff) { kept.push(file); continue; }
|
|
70
|
+
if (apply) {
|
|
71
|
+
try { unlinkSync(file); } catch { kept.push(file); continue; }
|
|
72
|
+
}
|
|
73
|
+
removed.push(file);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { dryRun: !apply, maxAgeMs, removed, kept };
|
|
77
|
+
}
|
package/lib/store-contract.mjs
CHANGED
|
@@ -42,6 +42,19 @@ CREATE TABLE IF NOT EXISTS identities (
|
|
|
42
42
|
ALTER TABLE identities DROP CONSTRAINT IF EXISTS identities_kind_check;
|
|
43
43
|
ALTER TABLE identities ADD CONSTRAINT identities_kind_check CHECK (kind IN (${IDENTITY_KINDS_SQL}));
|
|
44
44
|
|
|
45
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
46
|
+
org_id TEXT NOT NULL,
|
|
47
|
+
pubkey TEXT NOT NULL,
|
|
48
|
+
apns_token TEXT NOT NULL,
|
|
49
|
+
env TEXT NOT NULL CHECK (env IN ('sandbox','production')),
|
|
50
|
+
bundle_id TEXT NOT NULL,
|
|
51
|
+
name TEXT NOT NULL DEFAULT '',
|
|
52
|
+
created BIGINT NOT NULL,
|
|
53
|
+
last_ok BIGINT NOT NULL DEFAULT 0,
|
|
54
|
+
muted_projects JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
55
|
+
PRIMARY KEY (org_id, apns_token)
|
|
56
|
+
);
|
|
57
|
+
|
|
45
58
|
|
|
46
59
|
-- THE LOG. Append-only, never updated, never deleted except by retention. Everything else derives.
|
|
47
60
|
CREATE TABLE IF NOT EXISTS events (
|
package/lib/store-pg.mjs
CHANGED
|
@@ -139,6 +139,19 @@ function identityFromRow(row) {
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function deviceFromRow(row) {
|
|
143
|
+
return {
|
|
144
|
+
pubkey: row.pubkey,
|
|
145
|
+
apns_token: row.apns_token,
|
|
146
|
+
env: row.env,
|
|
147
|
+
bundle_id: row.bundle_id,
|
|
148
|
+
name: row.name || "",
|
|
149
|
+
created: Number(row.created || 0),
|
|
150
|
+
last_ok: Number(row.last_ok || 0),
|
|
151
|
+
muted_projects: Array.isArray(row.muted_projects) ? row.muted_projects : [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
// The kv projection of hub state — shared by saveSnapshot and saveDelta so the two can never drift.
|
|
143
156
|
function kvFromState(state) {
|
|
144
157
|
return {
|
|
@@ -501,6 +514,14 @@ export class PgStore {
|
|
|
501
514
|
"DELETE FROM peers WHERE org_id=$1 AND NOT (session = ANY($2::text[]))",
|
|
502
515
|
[orgId, Object.keys(state.peers || {})],
|
|
503
516
|
);
|
|
517
|
+
await c.query("DELETE FROM devices WHERE org_id=$1", [orgId]);
|
|
518
|
+
for (const d of state.devices || []) {
|
|
519
|
+
await c.query(
|
|
520
|
+
`INSERT INTO devices(org_id, pubkey, apns_token, env, bundle_id, name, created, last_ok, muted_projects)
|
|
521
|
+
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)`,
|
|
522
|
+
[orgId, d.pubkey, d.apns_token, d.env, d.bundle_id, d.name || "", ms(d.created, 0), Number(d.last_ok || 0), JSON.stringify(d.muted_projects || [])],
|
|
523
|
+
);
|
|
524
|
+
}
|
|
504
525
|
for (const id of Object.values(state.identities || {})) {
|
|
505
526
|
await c.query(
|
|
506
527
|
`INSERT INTO identities(pubkey, org_id, name, kind, scopes, enrolled_by, created_at, revoked_at)
|
|
@@ -548,12 +569,13 @@ export class PgStore {
|
|
|
548
569
|
p => p.session,
|
|
549
570
|
);
|
|
550
571
|
const identities = diffById(Object.values(prev.identities || {}), Object.values(next.identities || {}), i => i.pubkey);
|
|
572
|
+
const devices = diffById(prev.devices, next.devices, d => d.apns_token);
|
|
551
573
|
const prevKv = kvFromState(prev), nextKv = kvFromState(next);
|
|
552
574
|
const kvChanged = KV_KEYS.filter(k => JSON.stringify(prevKv[k] ?? null) !== JSON.stringify(nextKv[k] ?? null));
|
|
553
575
|
|
|
554
576
|
const changed = tasks.upserts.length + tasks.deletes.length + events.upserts.length + events.deletes.length
|
|
555
577
|
+ messages.upserts.length + messages.deletes.length + peers.upserts.length + peers.deletes.length
|
|
556
|
-
+ identities.upserts.length + kvChanged.length;
|
|
578
|
+
+ identities.upserts.length + devices.upserts.length + devices.deletes.length + kvChanged.length;
|
|
557
579
|
if (!changed) return 0;
|
|
558
580
|
|
|
559
581
|
const c = await this.pool.connect();
|
|
@@ -618,6 +640,17 @@ export class PgStore {
|
|
|
618
640
|
);
|
|
619
641
|
}
|
|
620
642
|
if (peers.deletes.length) await c.query("DELETE FROM peers WHERE org_id=$1 AND session = ANY($2::text[])", [orgId, peers.deletes]);
|
|
643
|
+
for (const d of devices.upserts) {
|
|
644
|
+
await c.query(
|
|
645
|
+
`INSERT INTO devices(org_id, pubkey, apns_token, env, bundle_id, name, created, last_ok, muted_projects)
|
|
646
|
+
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)
|
|
647
|
+
ON CONFLICT(org_id, apns_token) DO UPDATE SET pubkey=EXCLUDED.pubkey, env=EXCLUDED.env,
|
|
648
|
+
bundle_id=EXCLUDED.bundle_id, name=EXCLUDED.name, created=EXCLUDED.created,
|
|
649
|
+
last_ok=EXCLUDED.last_ok, muted_projects=EXCLUDED.muted_projects`,
|
|
650
|
+
[orgId, d.pubkey, d.apns_token, d.env, d.bundle_id, d.name || "", ms(d.created, 0), Number(d.last_ok || 0), JSON.stringify(d.muted_projects || [])],
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (devices.deletes.length) await c.query("DELETE FROM devices WHERE org_id=$1 AND apns_token = ANY($2::text[])", [orgId, devices.deletes]);
|
|
621
654
|
for (const id of identities.upserts) {
|
|
622
655
|
await c.query(
|
|
623
656
|
`INSERT INTO identities(pubkey, org_id, name, kind, scopes, enrolled_by, created_at, revoked_at)
|
|
@@ -681,12 +714,13 @@ export class PgStore {
|
|
|
681
714
|
}
|
|
682
715
|
|
|
683
716
|
async loadSnapshot(orgId) {
|
|
684
|
-
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows, eventMaxRow] = await Promise.all([
|
|
717
|
+
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, devicesRows, kvRows, eventMaxRow] = await Promise.all([
|
|
685
718
|
this.pool.query("SELECT * FROM tasks WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
686
719
|
this.pool.query("SELECT * FROM peers WHERE org_id=$1 ORDER BY session ASC", [orgId]),
|
|
687
720
|
this.pool.query("SELECT * FROM events WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
688
721
|
this.pool.query("SELECT * FROM messages WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
689
722
|
this.pool.query("SELECT * FROM identities WHERE org_id=$1 ORDER BY created_at ASC", [orgId]),
|
|
723
|
+
this.pool.query("SELECT * FROM devices WHERE org_id=$1 ORDER BY created ASC", [orgId]),
|
|
690
724
|
this.pool.query("SELECT key, value FROM kv WHERE org_id=$1", [orgId]),
|
|
691
725
|
// The authoritative high-water mark for event ids. Deriving it from the loaded array is what
|
|
692
726
|
// broke: a clobbered tail reported a lower id than the table actually held. Ask the table.
|
|
@@ -730,6 +764,7 @@ export class PgStore {
|
|
|
730
764
|
subagentCostReset: !!kv.subagentCostReset,
|
|
731
765
|
handoffLog: Array.isArray(kv.handoffLog) ? kv.handoffLog : [],
|
|
732
766
|
identities,
|
|
767
|
+
devices: devicesRows.rows.map(deviceFromRow),
|
|
733
768
|
inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
|
|
734
769
|
instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
|
|
735
770
|
balancesByMachine: meta.balancesByMachine && typeof meta.balancesByMachine === "object" ? meta.balancesByMachine : {},
|