agent-peers 0.1.0 → 0.3.0
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/CHANGELOG.md +41 -0
- package/CLAUDE-snippet.md +1 -1
- package/CONTRIBUTING.md +13 -7
- package/README.md +35 -41
- package/agent_peers/plugin.py +15 -11
- package/agent_peers/runtime.py +5 -2
- package/bin/agent-peers.mjs +37 -8
- package/docs/compatibility.md +22 -4
- package/docs/hermes.md +1 -1
- package/docs/reference.md +24 -6
- package/docs/release-verification.md +25 -2
- package/hermes-plugin/plugin.yaml +1 -1
- package/package.json +14 -5
- package/plugin.yaml +1 -1
- package/src/app-server.mjs +33 -9
- package/src/bind-retry.mjs +26 -0
- package/src/codex-peer.mjs +234 -82
- package/src/diagnostics.mjs +60 -11
- package/src/discovery.mjs +32 -3
- package/src/guidance.mjs +2 -3
- package/src/hermes-bridge.mjs +128 -32
- package/src/logging.mjs +293 -0
- package/src/wire.mjs +93 -10
package/src/diagnostics.mjs
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
1
|
-
import { lstatSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { isAbsolute, join } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
4
|
import { VERSION } from "./version.mjs";
|
|
6
5
|
import { claudeHome, peersHome } from "./wire.mjs";
|
|
6
|
+
import { loggingConfig } from "./logging.mjs";
|
|
7
|
+
import { daemonSocket } from "./app-server.mjs";
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
function daemonStat(path) {
|
|
12
|
+
const link = lstatSync(path);
|
|
13
|
+
if (!link.isSymbolicLink()) return link;
|
|
14
|
+
if (process.getuid && link.uid !== process.getuid()) throw new Error("daemon socket link must belong to this user");
|
|
15
|
+
// Codex publishes its short runtime socket through an owned symlink. Only this
|
|
16
|
+
// daemon endpoint permits indirection; peer registry/socket rules stay strict.
|
|
17
|
+
let target;
|
|
18
|
+
try { target = realpathSync(path); }
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === "ENOENT") throw Object.assign(new Error(readlinkSync(path)), { code: "STALE_LINK" });
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
for (const directory of [dirname(path), dirname(target)]) {
|
|
24
|
+
const stat = lstatSync(directory);
|
|
25
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022)) {
|
|
26
|
+
throw new Error("daemon socket link and target require owned directories without shared write access");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return lstatSync(target);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const START_DAEMON = "start it with `codex app-server daemon start`, or open a Codex session";
|
|
33
|
+
|
|
34
|
+
// One handshake and a loaded-thread read: a socket file can outlive its server, for example
|
|
35
|
+
// across a reboot. Nothing is logged, started or loaded.
|
|
36
|
+
async function daemonAnswers(path) {
|
|
37
|
+
const { AppServer } = await import("./app-server.mjs");
|
|
38
|
+
const app = new AppServer(undefined, { connectTimeoutMs: 2000, handshakeTimeoutMs: 2000, log: () => {} });
|
|
39
|
+
try {
|
|
40
|
+
const result = await app.request("thread/loaded/list", {}, 3000);
|
|
41
|
+
const count = Array.isArray(result?.data) ? `${result.data.length}${result.nextCursor ? "+" : ""}` : "unknown";
|
|
42
|
+
return { status: "ok", message: `${path} (answering; ${count} loaded threads)` };
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const reason = error?.code === "ECONNREFUSED" ? "connection refused" : String(error?.code || error?.message || "no answer");
|
|
45
|
+
const configured = process.env.AGENT_PEERS_CODEX_APP_SERVER ? "the configured app-server" : "Codex's shared daemon";
|
|
46
|
+
return { status: "warning", message: `${path} is not answering (${reason}); ${configured} is not running` +
|
|
47
|
+
(process.env.AGENT_PEERS_CODEX_APP_SERVER ? "" : `: ${START_DAEMON}`) };
|
|
48
|
+
} finally { app.close(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Inspect metadata and ask the app-server one read-only question: no configuration writes,
|
|
52
|
+
// daemon startup or model turns.
|
|
53
|
+
export async function diagnose() {
|
|
12
54
|
const checks = [];
|
|
13
55
|
const add = (name, status, message) => checks.push({ name, status, message });
|
|
14
56
|
add("node", Number(process.versions.node.split(".")[0]) >= 22 ? "ok" : "error", process.version);
|
|
@@ -20,23 +62,30 @@ export function diagnose() {
|
|
|
20
62
|
const paths = [
|
|
21
63
|
["claude registry", join(claudeHome(), "sessions"), "directory", false],
|
|
22
64
|
["peer registry", peersHome(), "directory", true],
|
|
23
|
-
["codex daemon",
|
|
24
|
-
join(process.env.CODEX_HOME || join(homedir(), ".codex"), "app-server-control", "app-server-control.sock"), "socket", false],
|
|
65
|
+
["codex daemon", daemonSocket(), "socket", false],
|
|
25
66
|
];
|
|
26
67
|
for (const [name, path, kind, privateMode] of paths) {
|
|
27
68
|
if (!isAbsolute(path)) { add(name, "error", `expected an absolute path: ${path}`); continue; }
|
|
28
69
|
try {
|
|
29
|
-
const stat = lstatSync(path);
|
|
70
|
+
const stat = kind === "socket" ? daemonStat(path) : lstatSync(path);
|
|
30
71
|
if (stat.isSymbolicLink() || (kind === "socket" ? !stat.isSocket() : !stat.isDirectory())) {
|
|
31
72
|
add(name, "error", `${path} must be a ${kind}, not a symlink or another file type`);
|
|
32
73
|
} else if ((process.getuid && stat.uid !== process.getuid()) || (stat.mode & (privateMode ? 0o077 : 0o022))) {
|
|
33
74
|
add(name, "error", `${path} must belong to this user and have safe permissions`);
|
|
75
|
+
} else if (kind === "socket") {
|
|
76
|
+
const live = await daemonAnswers(path);
|
|
77
|
+
add(name, live.status, live.message);
|
|
34
78
|
} else add(name, "ok", path);
|
|
35
79
|
} catch (error) {
|
|
36
|
-
if (error.code === "
|
|
80
|
+
if (error.code === "STALE_LINK") add(name, "warning", `${path} links to a missing socket (${error.message}); Codex's shared daemon is not running: ${START_DAEMON}`);
|
|
81
|
+
else if (error.code === "ENOENT") add(name, "warning", `${path} is absent; start the corresponding agent to use it`);
|
|
37
82
|
else add(name, "error", `${path}: ${error.message}`);
|
|
38
83
|
}
|
|
39
84
|
}
|
|
40
|
-
|
|
41
|
-
|
|
85
|
+
const logging = loggingConfig();
|
|
86
|
+
add("logging", logging.enabled && !logging.valid ? "error" : "ok", logging.enabled
|
|
87
|
+
? `enabled; ${logging.mode}: ${logging.location}${logging.valid ? "" : "; expected an absolute normalized path"}`
|
|
88
|
+
: `disabled; set AGENT_PEERS_DEBUG=1 to capture future events in ${logging.location}`);
|
|
89
|
+
add("compatibility", "warning", "An answering daemon is not a delivery check. Codex threads are reachable only on the app-server agent-peers is pointed at; Hermes replies require the documented route APIs. See the compatibility guide.");
|
|
90
|
+
return { version: VERSION, ok: !checks.some((c) => c.status === "error"), logging, checks };
|
|
42
91
|
}
|
package/src/discovery.mjs
CHANGED
|
@@ -40,18 +40,47 @@ export function codexDiscovery(peers, threads, self) {
|
|
|
40
40
|
return [...rows.values()];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Explain a route mismatch without sending anything or treating a nickname as a bridge
|
|
44
|
+
// address. Match against all discovered peers so an abbreviation cannot silently select
|
|
45
|
+
// one native agent when another peer has a matching name.
|
|
46
|
+
export function nativeRouteError(to, peers) {
|
|
47
|
+
if (typeof to !== "string" || !to || to.startsWith("uds:")) return null;
|
|
48
|
+
const exact = peers.filter((p) => p.name === to || p.name === `codex:${to}` || p.name === `claude:${to}` ||
|
|
49
|
+
p.native?.target === to || p.threadId === to);
|
|
50
|
+
const matches = exact.length ? exact : peers.filter((p) => p.name.includes(to));
|
|
51
|
+
if (matches.length !== 1) return null;
|
|
52
|
+
const p = matches[0];
|
|
53
|
+
if (p.native?.reachable !== true || !p.native.target || p.bridge) return null;
|
|
54
|
+
const subagent = p.native.target !== "/root";
|
|
55
|
+
let message = `${JSON.stringify(p.name)} is a native Codex ${subagent ? "subagent" : "agent"} without a bridge route; ` +
|
|
56
|
+
`send_peer cannot address it. Use native ${p.native.tool} ${JSON.stringify({ target: p.native.target })}.`;
|
|
57
|
+
if (p.status === "idle") {
|
|
58
|
+
message += subagent
|
|
59
|
+
? ` It is idle; use native followup_task ${JSON.stringify({ target: p.native.target })} to start a turn; send_message only queues a message.`
|
|
60
|
+
: " Native send_message does not start an idle turn for the root agent.";
|
|
61
|
+
}
|
|
62
|
+
if (subagent) message += " Direct app-server input, including codex_tui.send_message_to_thread, is unsupported for subagents.";
|
|
63
|
+
return message;
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
export function formatCodexPeer(p) {
|
|
44
67
|
const native = p.native.reachable === true
|
|
45
68
|
? `yes — ${p.native.tool} ${JSON.stringify({ target: p.native.target })}`
|
|
46
69
|
: `${p.native.reachable === false ? "no" : "unknown"} (${p.native.reason})`;
|
|
47
|
-
const
|
|
48
|
-
|
|
70
|
+
const subagent = p.native.reachable === true && p.native.target !== "/root";
|
|
71
|
+
const bridge = p.bridge ? `${p.bridge.tool} ${JSON.stringify({ to: p.bridge.to })}`
|
|
72
|
+
: subagent ? "none (use native messaging; send_peer and direct app-server input cannot target this subagent)"
|
|
73
|
+
: p.native.reachable === true ? "none (use native messaging)" : "none (check list_agents for a native target)";
|
|
74
|
+
const wake = subagent && p.status === "idle"
|
|
75
|
+
? `\n start idle turn: followup_task ${JSON.stringify({ target: p.native.target })} (send_message only queues a message)` : "";
|
|
76
|
+
return `${p.name} ${p.status ?? ""} cwd=${p.cwd}\n native: ${native}\n bridge: ${bridge}${wake}`;
|
|
49
77
|
}
|
|
50
78
|
|
|
51
79
|
// The CLI is also used outside Codex, where there is no caller thread to compare.
|
|
52
80
|
// Name the platform explicitly rather than implying Claude's SendMessage is a Codex tool.
|
|
53
81
|
export function formatSessionPeer(p) {
|
|
54
|
-
return `${p.name} ${p.status ?? ""} cwd=${p.cwd}\n` +
|
|
82
|
+
return `${p.name} ${p.status ?? ""}${p.reachable === false ? " stale" : ""} cwd=${p.cwd}\n` +
|
|
83
|
+
(p.reachable === false ? " stale: the socket refused a connection; the record is kept\n" : "") +
|
|
55
84
|
` Claude native: SendMessage ${JSON.stringify({ to: p.address })}\n` +
|
|
56
85
|
` Codex bridge: send_peer ${JSON.stringify({ to: p.address })}`;
|
|
57
86
|
}
|
package/src/guidance.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Codex's session guidance; the same preference appears in the Claude setup snippet.
|
|
2
2
|
export const coordinationGuidance =
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"Routine progress updates can usually wait; use your judgment about when a discussion or reply would help.";
|
|
3
|
+
"Keep coordination useful and brief. Send what's new; link existing detail rather than repeating it. " +
|
|
4
|
+
"Reply when it adds value.";
|
package/src/hermes-bridge.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { chmodSync, lstatSync, rmSync } from "node:fs";
|
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { AppServer } from "./app-server.mjs";
|
|
9
|
+
import { errorFields, logEvent, logIdentity } from "./logging.mjs";
|
|
9
10
|
import {
|
|
10
11
|
MAX_LINE, MAX_SOCKET_PATH, address, allPeers, messageLine, parseEnvelope, peerSocket, post, readLines,
|
|
11
12
|
} from "./wire.mjs";
|
|
@@ -14,6 +15,11 @@ const BUCKET = 30, REFILL_PER_S = 0.5, DEDUP_MS = 30_000, IDLE_MS = 600_000;
|
|
|
14
15
|
const QUEUE_MAX = 50, TOTAL_QUEUE_MAX = 200, SOURCES_MAX = 256, SESSIONS_MAX = 256;
|
|
15
16
|
const IPC_QUEUE_MAX = 128, IPC_WRITE_TIMEOUT_MS = 5000;
|
|
16
17
|
const logger = (message) => console.error(`[hermes-bridge ${process.pid}] ${message}`);
|
|
18
|
+
const event = (name, fields) => logEvent("hermes-bridge", name, fields);
|
|
19
|
+
const safeError = (error) => {
|
|
20
|
+
const { reason, errorCode } = errorFields(error);
|
|
21
|
+
return errorCode && errorCode !== "unknown" ? `${reason} (${errorCode})` : reason;
|
|
22
|
+
};
|
|
17
23
|
|
|
18
24
|
function requiredString(value, field, max = MAX_LINE) {
|
|
19
25
|
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
@@ -53,7 +59,39 @@ export class HermesBridge {
|
|
|
53
59
|
this.#log = log;
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
#introduced = new Set();
|
|
63
|
+
|
|
64
|
+
#firstContact(key) {
|
|
65
|
+
if (this.#introduced.has(key)) return false;
|
|
66
|
+
if (this.#introduced.size >= 4096) this.#introduced.clear();
|
|
67
|
+
this.#introduced.add(key);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
async request(method, params = {}) {
|
|
72
|
+
const operationId = randomUUID(), started = performance.now();
|
|
73
|
+
const fields = {
|
|
74
|
+
operationId, method: ["list_peers", "send_peer", "close_session"].includes(method) ? method : "unknown",
|
|
75
|
+
...(typeof params?.session === "string" ? { sessionId: logIdentity(params.session) } : {}),
|
|
76
|
+
...(typeof params?.to === "string" ? { targetId: logIdentity(params.to) } : {}),
|
|
77
|
+
...(typeof params?.message === "string" ? { bytes: Buffer.byteLength(params.message) } : {}),
|
|
78
|
+
};
|
|
79
|
+
event("tool.request", fields);
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.#request(method, params, operationId);
|
|
82
|
+
event("tool.result", {
|
|
83
|
+
...fields, durationMs: performance.now() - started,
|
|
84
|
+
outcome: result.note || result.status && result.status !== "delivered" ? "warning" : "success",
|
|
85
|
+
...(Array.isArray(result.peers) ? { bridgeCount: result.peers.length } : {}),
|
|
86
|
+
});
|
|
87
|
+
return result;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
event("tool.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async #request(method, params, operationId) {
|
|
57
95
|
if (this.#closed) throw new Error("Hermes bridge is closed");
|
|
58
96
|
if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error("params must be an object");
|
|
59
97
|
if (method === "list_peers") return this.#listPeers();
|
|
@@ -63,7 +101,7 @@ export class HermesBridge {
|
|
|
63
101
|
const previous = this.#locks.get(session) ?? Promise.resolve();
|
|
64
102
|
const operation = previous.catch(() => {}).then(async () => {
|
|
65
103
|
if (this.#closed) throw new Error("Hermes bridge is closed");
|
|
66
|
-
if (method === "send_peer") return this.#send(session, params);
|
|
104
|
+
if (method === "send_peer") return this.#send(session, params, operationId);
|
|
67
105
|
const routes = [...this.#routes.values()].filter((route) => route.session === session);
|
|
68
106
|
await Promise.all(routes.map((route) => this.#retire(route)));
|
|
69
107
|
return { closed: routes.length > 0 };
|
|
@@ -137,12 +175,18 @@ export class HermesBridge {
|
|
|
137
175
|
buckets: new Map(), recent: new Map(), pending: new Map(), queued: 0, chain: Promise.resolve(),
|
|
138
176
|
};
|
|
139
177
|
route.server = createServer((conn) => {
|
|
140
|
-
if (route.closed || route.connections.size >= QUEUE_MAX)
|
|
178
|
+
if (route.closed || route.connections.size >= QUEUE_MAX) {
|
|
179
|
+
event("receive.dropped", { sessionId: logIdentity(session), reason: route.closed ? "closed" : "queue_full" });
|
|
180
|
+
return conn.destroy();
|
|
181
|
+
}
|
|
141
182
|
route.connections.add(conn);
|
|
142
183
|
conn.once("close", () => route.connections.delete(conn));
|
|
143
184
|
readLines(conn, (line) => this.#receive(route, line));
|
|
144
185
|
});
|
|
145
|
-
route.server.on("error", (error) =>
|
|
186
|
+
route.server.on("error", (error) => {
|
|
187
|
+
event("inbox.error", { sessionId: logIdentity(session), ...errorFields(error) });
|
|
188
|
+
this.#log(`inbox error: ${safeError(error)}`);
|
|
189
|
+
});
|
|
146
190
|
try {
|
|
147
191
|
await new Promise((resolve, reject) => {
|
|
148
192
|
route.server.once("error", reject);
|
|
@@ -152,6 +196,7 @@ export class HermesBridge {
|
|
|
152
196
|
route.owner = lstatSync(socket);
|
|
153
197
|
chmodSync(socket, 0o600);
|
|
154
198
|
this.#routes.set(key, route);
|
|
199
|
+
event("inbox.bound", { sessionId: logIdentity(session), targetId: logIdentity(route.address) });
|
|
155
200
|
} catch (error) {
|
|
156
201
|
route.closed = true;
|
|
157
202
|
for (const conn of route.connections) conn.destroy();
|
|
@@ -163,7 +208,30 @@ export class HermesBridge {
|
|
|
163
208
|
return route;
|
|
164
209
|
}
|
|
165
210
|
|
|
166
|
-
async #send(session, params) {
|
|
211
|
+
async #send(session, params, operationId) {
|
|
212
|
+
const started = performance.now(), messageId = randomUUID();
|
|
213
|
+
const fields = {
|
|
214
|
+
operationId, messageId: logIdentity(messageId), sessionId: logIdentity(session),
|
|
215
|
+
...(typeof params.to === "string" ? { targetId: logIdentity(params.to) } : {}),
|
|
216
|
+
...(typeof params.message === "string" ? { bytes: Buffer.byteLength(params.message) } : {}),
|
|
217
|
+
};
|
|
218
|
+
event("send.started", fields);
|
|
219
|
+
try {
|
|
220
|
+
const result = await this.#post(session, params, messageId, fields);
|
|
221
|
+
const status = result.status;
|
|
222
|
+
event("send.result", {
|
|
223
|
+
...fields, durationMs: performance.now() - started,
|
|
224
|
+
outcome: status === "held" ? "held" : status === "delivered" ? "receipt_delivered" : status ? "rejected" : "inbox_written",
|
|
225
|
+
receipt: Boolean(status),
|
|
226
|
+
});
|
|
227
|
+
return result;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
event("send.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async #post(session, params, messageId, fields) {
|
|
167
235
|
const name = requiredString(params.name, "name", 80);
|
|
168
236
|
if (!/^hermes:[A-Za-z0-9:_-]+$/.test(name)) throw new Error("name must identify a Hermes profile as hermes:<profile>");
|
|
169
237
|
const to = requiredString(params.to, "to", 512);
|
|
@@ -173,11 +241,12 @@ export class HermesBridge {
|
|
|
173
241
|
if (!targetSocket) throw new Error("the peer registry contains an invalid inbox address; call list_peers again");
|
|
174
242
|
const route = await this.#bind(session, name, dirname(targetSocket));
|
|
175
243
|
if (target.address === route.address) throw new Error("that address is this conversation's own inbox");
|
|
176
|
-
// Claude labels every external envelope as another Claude session. Correct that frame
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
244
|
+
// Claude labels every external envelope as another Claude session. Correct that frame once
|
|
245
|
+
// per conversation and recipient: the recipient keeps it in context.
|
|
246
|
+
const body = target.kind === "claude" && this.#firstContact(`${session}\0${target.address}`)
|
|
247
|
+
? `${message}\n\n(Hermes profile; notify_when_idle unavailable.)`
|
|
248
|
+
: message;
|
|
249
|
+
const line = { ...messageLine({ from: route.address, fromName: name, body }), msg_id: messageId };
|
|
181
250
|
let finish, timer;
|
|
182
251
|
const receipt = new Promise((resolve) => {
|
|
183
252
|
finish = resolve;
|
|
@@ -185,12 +254,14 @@ export class HermesBridge {
|
|
|
185
254
|
timer = setTimeout(() => resolve(undefined), 1500);
|
|
186
255
|
});
|
|
187
256
|
try {
|
|
257
|
+
const started = performance.now();
|
|
188
258
|
await post(targetSocket, line);
|
|
259
|
+
event("message.send", { ...fields, outcome: "inbox_written", durationMs: performance.now() - started });
|
|
189
260
|
const status = await receipt;
|
|
190
261
|
const text = status === "held"
|
|
191
262
|
? `Delivered to ${target.name}'s inbox; its session is holding it for its user's approval.`
|
|
192
263
|
: status && status !== "delivered" ? `Not delivered to ${target.name}: ${status}.`
|
|
193
|
-
: `Delivered to ${target.name}'s inbox
|
|
264
|
+
: `Delivered to ${target.name}'s inbox.`;
|
|
194
265
|
return { text, address: route.address, message_id: line.msg_id, ...(status ? { status } : {}) };
|
|
195
266
|
} finally {
|
|
196
267
|
clearTimeout(timer);
|
|
@@ -203,42 +274,60 @@ export class HermesBridge {
|
|
|
203
274
|
const now = Date.now();
|
|
204
275
|
for (const [key, bucket] of route.buckets) if (now - bucket.at > IDLE_MS) route.buckets.delete(key);
|
|
205
276
|
for (const [key, time] of route.recent) if (now - time > DEDUP_MS) route.recent.delete(key);
|
|
206
|
-
if (!route.buckets.has(from) && route.buckets.size >= SOURCES_MAX) return
|
|
277
|
+
if (!route.buckets.has(from) && route.buckets.size >= SOURCES_MAX) return "rate_limit";
|
|
207
278
|
const bucket = route.buckets.get(from) ?? { tokens: BUCKET, at: now };
|
|
208
279
|
bucket.tokens = Math.min(BUCKET, bucket.tokens + (now - bucket.at) / 1000 * REFILL_PER_S);
|
|
209
280
|
bucket.at = now;
|
|
210
281
|
route.buckets.set(from, bucket);
|
|
211
282
|
const bodyKey = createHash("sha256").update(from).update("\0").update(body).digest("hex");
|
|
212
283
|
const idKey = `${from}\0${id}`;
|
|
213
|
-
if (
|
|
214
|
-
|
|
284
|
+
if (route.recent.has(bodyKey) || route.recent.has(idKey)) return "duplicate";
|
|
285
|
+
if (bucket.tokens < 1) return "rate_limit";
|
|
286
|
+
if (route.queued >= QUEUE_MAX || this.#queued >= TOTAL_QUEUE_MAX) return "queue_full";
|
|
215
287
|
bucket.tokens--;
|
|
216
288
|
route.recent.set(bodyKey, now);
|
|
217
289
|
route.recent.set(idKey, now);
|
|
218
|
-
return
|
|
290
|
+
return undefined;
|
|
219
291
|
}
|
|
220
292
|
|
|
221
293
|
#receive(route, line) {
|
|
222
|
-
|
|
294
|
+
const sessionId = logIdentity(route.session);
|
|
295
|
+
const drop = (reason, fields = {}) => event("receive.dropped", { sessionId, reason, ...fields });
|
|
296
|
+
if (route.closed) return drop("closed");
|
|
297
|
+
if (!line || typeof line !== "object") return drop("malformed");
|
|
223
298
|
if (line.type === "control" && line.action === "peer_message_status") {
|
|
224
|
-
if (typeof line.status
|
|
299
|
+
if (typeof line.status !== "string" || !line.status.length || line.status.length > 128 ||
|
|
300
|
+
typeof line.orig_msg_id !== "string") return drop("malformed");
|
|
301
|
+
route.pending.get(line.orig_msg_id)?.(line.status);
|
|
225
302
|
return;
|
|
226
303
|
}
|
|
227
|
-
if (line.type !== "user" || typeof line.message?.content !== "string") return;
|
|
304
|
+
if (line.type !== "user" || typeof line.message?.content !== "string") return drop("malformed");
|
|
228
305
|
const env = parseEnvelope(line.message.content);
|
|
229
|
-
if (!env || !peerSocket(env.from) || env.from === route.address || (line.from && line.from !== env.from)) return;
|
|
306
|
+
if (!env || !peerSocket(env.from) || env.from === route.address || (line.from && line.from !== env.from)) return drop("malformed");
|
|
230
307
|
const id = typeof line.msg_id === "string" && line.msg_id.length <= 256 && line.msg_id ? line.msg_id : randomUUID();
|
|
231
|
-
|
|
308
|
+
const fields = { sessionId, messageId: logIdentity(id), targetId: logIdentity(env.from), bytes: Buffer.byteLength(env.body) };
|
|
309
|
+
const notification = { method: "peer_message", params: {
|
|
310
|
+
session: route.session, message_id: id, from: env.from,
|
|
311
|
+
from_name: /^[A-Za-z0-9:_ .-]{1,80}$/.test(env.fromName ?? "") ? env.fromName : "an unnamed peer",
|
|
312
|
+
message: env.body,
|
|
313
|
+
} };
|
|
314
|
+
// The host reads at most MAX_LINE per line and restarts the bridge on a longer one; a frame
|
|
315
|
+
// that fits the inbox can exceed that once re-encoded with session and message IDs.
|
|
316
|
+
if (Buffer.byteLength(JSON.stringify(notification)) + 1 > MAX_LINE) return drop("message_too_large", fields);
|
|
317
|
+
const reason = this.#admit(route, env.from, env.body, id);
|
|
318
|
+
if (reason) return drop(reason, fields);
|
|
232
319
|
route.queued++;
|
|
233
320
|
this.#queued++;
|
|
321
|
+
event("receive.queued", { ...fields, queueDepth: route.queued });
|
|
322
|
+
const started = performance.now();
|
|
234
323
|
route.chain = route.chain.then(async () => {
|
|
235
|
-
if (route.closed) return;
|
|
236
|
-
await this.#emit(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
})
|
|
324
|
+
if (route.closed) return drop("closed", fields);
|
|
325
|
+
await this.#emit(notification);
|
|
326
|
+
event("delivery.result", { ...fields, outcome: "emitted_to_host", durationMs: performance.now() - started });
|
|
327
|
+
}).catch((error) => {
|
|
328
|
+
event("delivery.result", { ...fields, outcome: "error", durationMs: performance.now() - started, ...errorFields(error) });
|
|
329
|
+
this.#log(`incoming message delivery failed: ${safeError(error)}`);
|
|
330
|
+
})
|
|
242
331
|
.finally(() => { route.queued--; this.#queued--; });
|
|
243
332
|
}
|
|
244
333
|
|
|
@@ -253,7 +342,7 @@ export class HermesBridge {
|
|
|
253
342
|
const current = lstatSync(route.socket);
|
|
254
343
|
owned = current.dev === route.owner.dev && current.ino === route.owner.ino;
|
|
255
344
|
} catch (error) {
|
|
256
|
-
if (error.code !== "ENOENT") this.#log(`cannot inspect inbox ownership: ${error
|
|
345
|
+
if (error.code !== "ENOENT") this.#log(`cannot inspect inbox ownership: ${safeError(error)}`);
|
|
257
346
|
}
|
|
258
347
|
if (owned) {
|
|
259
348
|
// Node closes and unlinks a Unix listener by pathname; do not close it if somebody
|
|
@@ -270,6 +359,7 @@ export class HermesBridge {
|
|
|
270
359
|
}
|
|
271
360
|
route.buckets.clear();
|
|
272
361
|
route.recent.clear();
|
|
362
|
+
event("inbox.closed", { sessionId: logIdentity(route.session), targetId: logIdentity(route.address), queueDepth: route.queued });
|
|
273
363
|
}
|
|
274
364
|
|
|
275
365
|
close() {
|
|
@@ -292,6 +382,7 @@ export class HermesBridge {
|
|
|
292
382
|
export function runHermesBridge() {
|
|
293
383
|
const write = (message) => new Promise((resolve, reject) => {
|
|
294
384
|
const timer = setTimeout(() => {
|
|
385
|
+
event("bridge.error", { reason: "timeout" });
|
|
295
386
|
reject(new Error("bridge output timed out"));
|
|
296
387
|
void stop(1);
|
|
297
388
|
}, IPC_WRITE_TIMEOUT_MS);
|
|
@@ -312,7 +403,8 @@ export function runHermesBridge() {
|
|
|
312
403
|
try {
|
|
313
404
|
await bridge.close();
|
|
314
405
|
} catch (error) {
|
|
315
|
-
|
|
406
|
+
event("bridge.error", errorFields(error));
|
|
407
|
+
logger(`bridge shutdown failed: ${safeError(error)}`);
|
|
316
408
|
code = 1;
|
|
317
409
|
}
|
|
318
410
|
process.exit(code);
|
|
@@ -326,6 +418,7 @@ export function runHermesBridge() {
|
|
|
326
418
|
const raw = buffer.slice(0, newline);
|
|
327
419
|
buffer = buffer.slice(newline + 1);
|
|
328
420
|
if (Buffer.byteLength(raw) + 1 > MAX_LINE || queued >= IPC_QUEUE_MAX) {
|
|
421
|
+
event("bridge.error", { reason: queued >= IPC_QUEUE_MAX ? "queue_full" : "message_too_large", queueDepth: queued });
|
|
329
422
|
logger("bridge input limit exceeded");
|
|
330
423
|
void stop(1);
|
|
331
424
|
return;
|
|
@@ -352,25 +445,28 @@ export function runHermesBridge() {
|
|
|
352
445
|
const result = await bridge.request(request.method, request.params);
|
|
353
446
|
await write({ id, result });
|
|
354
447
|
} catch (error) {
|
|
448
|
+
if (id === null) event("bridge.error", { reason: "malformed" });
|
|
355
449
|
await write({ id, error: { message: error.message } });
|
|
356
450
|
}
|
|
357
|
-
}).catch((error) => { logger(error
|
|
451
|
+
}).catch((error) => { event("bridge.error", errorFields(error)); logger(safeError(error)); void stop(1); }).finally(() => queued--);
|
|
358
452
|
}
|
|
359
453
|
if (Buffer.byteLength(buffer) >= MAX_LINE) {
|
|
454
|
+
event("bridge.error", { reason: "message_too_large" });
|
|
360
455
|
logger("bridge input line too large");
|
|
361
456
|
void stop(1);
|
|
362
457
|
}
|
|
363
458
|
});
|
|
364
459
|
process.stdin.on("end", () => {
|
|
365
460
|
if (buffer.trim()) {
|
|
461
|
+
event("bridge.error", { reason: "malformed" });
|
|
366
462
|
logger("incomplete bridge input at EOF");
|
|
367
463
|
void stop(1);
|
|
368
464
|
} else void chain.finally(() => stop());
|
|
369
465
|
});
|
|
370
|
-
process.stdin.on("error", () => { void stop(1); });
|
|
371
|
-
process.stdout.on("error", () => { void stop(1); });
|
|
466
|
+
process.stdin.on("error", (error) => { event("bridge.error", errorFields(error)); void stop(1); });
|
|
467
|
+
process.stdout.on("error", (error) => { event("bridge.error", errorFields(error)); void stop(1); });
|
|
372
468
|
process.once("SIGINT", () => { void stop(); });
|
|
373
469
|
process.once("SIGTERM", () => { void stop(); });
|
|
374
|
-
process.on("uncaughtException", (error) => { logger(error
|
|
470
|
+
process.on("uncaughtException", (error) => { event("bridge.error", errorFields(error)); logger(safeError(error)); void stop(1); });
|
|
375
471
|
return bridge;
|
|
376
472
|
}
|