@aeondave/pi-persona 1.15.2
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/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exocom peer-to-peer plane — binds this instance's own socket/pipe, serves inbound frames
|
|
3
|
+
* (message/bye), sends point-to-point with large-message artifact spill (R3) and a
|
|
4
|
+
* single reconnect on a peer-restarting error (R4), and cleans up on stop.
|
|
5
|
+
*
|
|
6
|
+
* Reuses the broker's length-prefixed framing (`bus/broker/framing.ts`) verbatim, and its
|
|
7
|
+
* stale-socket reclaim probe (`bus/broker/host.ts`'s `isConnectable`/`attemptListen`) —
|
|
8
|
+
* those two are not exported there, so the small probe + bind-attempt helpers are lifted
|
|
9
|
+
* here rather than re-implemented differently.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash, createPublicKey, generateKeyPairSync, randomUUID, sign as cryptoSign, verify as cryptoVerify, type KeyObject } from "node:crypto";
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
closeSync,
|
|
16
|
+
constants as fsConstants,
|
|
17
|
+
existsSync,
|
|
18
|
+
fstatSync,
|
|
19
|
+
lstatSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
openSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
type Stats,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import nodeNet from "node:net";
|
|
29
|
+
import type net from "node:net";
|
|
30
|
+
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { createFrameReader, encodeFrame } from "../bus/broker/framing.ts";
|
|
33
|
+
import { frameSigningPayload, isExocomFrame, isSemanticFrame, nextHops, parseExocomArtifactDescriptor, truncateForInject, type AgentCard, type ExocomAck, type ExocomArtifactDescriptor, type ExocomBye, type ExocomFrame, type ExocomMessage, type ExocomNack, type ExocomSemanticFrame } from "./envelope.ts";
|
|
34
|
+
import { EXOCOM } from "./limits.ts";
|
|
35
|
+
import { exocomRoot } from "./paths.ts";
|
|
36
|
+
import { isAlive, normalizePeerName, prune, readAll, removeEntryIfMatches, writeEntry, type RegistryEntry, type RegistryOwnership } from "./registry.ts";
|
|
37
|
+
|
|
38
|
+
export interface ExocomIdentity {
|
|
39
|
+
session_id: string; name: string; persona: string; purpose: string; color: string;
|
|
40
|
+
model: string; endpoint: string; cwd: string;
|
|
41
|
+
workspace?: { id: string; code: string; label: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ExocomInboundResult =
|
|
45
|
+
| { accepted: true; duplicate?: boolean }
|
|
46
|
+
| { accepted: false; reason: string };
|
|
47
|
+
|
|
48
|
+
export interface ExocomPlaneDeps {
|
|
49
|
+
agentDir: string;
|
|
50
|
+
hash: string;
|
|
51
|
+
identity: ExocomIdentity;
|
|
52
|
+
getCard: () => AgentCard;
|
|
53
|
+
onInbound: (m: ExocomMessage, fromEntry: RegistryEntry | undefined) => ExocomInboundResult;
|
|
54
|
+
/** Semantic collaboration frames (claim/ask/answer/progress/release). Unset ⇒ ACK bytes only. */
|
|
55
|
+
onSemantic?: (frame: ExocomSemanticFrame, fromEntry: RegistryEntry) => ExocomInboundResult;
|
|
56
|
+
/** Fired when the live pool changes without our own action — a peer's `bye` (clean shutdown)
|
|
57
|
+
* removes it from the registry, so the widget should refresh at once rather than wait for the
|
|
58
|
+
* next heartbeat tick. Optional; unset ⇒ no-op. */
|
|
59
|
+
onPoolChange?: () => void;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/** Injected transport (tests use real sockets/pipes over a temp dir); defaults to `node:net`. */
|
|
62
|
+
net?: typeof import("node:net");
|
|
63
|
+
/** Read-only registry pool read (defaults to `readAll`). Injected so a test can drive the
|
|
64
|
+
* registry-unreadable path — the one failure the delivery path has to survive without
|
|
65
|
+
* turning a good message into a NACK, and one plain `fs` can't be made to produce. */
|
|
66
|
+
readPool?: (agentDir: string, hash: string) => RegistryEntry[];
|
|
67
|
+
/** Bounds `send`'s ack-wait (defaults to `EXOCOM.ACK_TIMEOUT_MS`); shrinkable in tests. */
|
|
68
|
+
ackTimeoutMs?: number;
|
|
69
|
+
/** Endpoint cleanup seam; production unlinks only POSIX socket paths. */
|
|
70
|
+
unlinkEndpoint?: () => void;
|
|
71
|
+
/** Test seam: runs after source validation and immediately before snapshot I/O. */
|
|
72
|
+
beforeArtifactRead?: (sourcePath: string) => void;
|
|
73
|
+
/** Test seam: production receiver snapshot names come from `randomUUID()`. */
|
|
74
|
+
artifactSnapshotId?: () => string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STALE_PROBE_TIMEOUT_MS = 1000;
|
|
78
|
+
const RECONNECT_DELAY_MS = 150;
|
|
79
|
+
const MAX_TRACKED_HOPS = 1024;
|
|
80
|
+
|
|
81
|
+
/** Ceiling on a spill payload, enforced against the bytes ON DISK (see `artifactClaimError`).
|
|
82
|
+
* `exocom_send` caps a message at 1,000,000 characters, which cannot exceed 4 MiB once UTF-8
|
|
83
|
+
* encoded, so no honest spill is ever refused by this — but nothing else in the system bounds
|
|
84
|
+
* what a peer can point the receiver's model at: `isExocomFrame` does not bound the message text,
|
|
85
|
+
* and the per-sender byte window deliberately charges only what crossed the wire. */
|
|
86
|
+
export const ARTIFACT_MAX_BYTES = 4 * 1_024 * 1_024;
|
|
87
|
+
|
|
88
|
+
/** Hop history belongs to a message as observed from one sender. msg_id is only
|
|
89
|
+
* sender-scoped by protocol, so using it alone lets two peers overwrite each
|
|
90
|
+
* other's chain depth. JSON's array encoding is unambiguous for the token fields
|
|
91
|
+
* and keeps the map key independent of any delimiter escaping. */
|
|
92
|
+
function inboundHopKey(fromSession: string, msgId: string): string {
|
|
93
|
+
return JSON.stringify([fromSession, msgId]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function delay(ms: number): Promise<void> {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const t = setTimeout(resolve, ms);
|
|
99
|
+
t.unref?.();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRestartingError(err: unknown): boolean {
|
|
104
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
105
|
+
return code === "ECONNREFUSED" || code === "ENOENT";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A peer accepted the connection but never acked within budget — distinct from a connect
|
|
109
|
+
* error (R4): the peer is registered and possibly just frozen, not necessarily gone, so
|
|
110
|
+
* `send` propagates this without pruning the registry (the heartbeat/stale prune is the
|
|
111
|
+
* right place to evict a genuinely dead entry, not a single slow ack). */
|
|
112
|
+
class AckTimeoutError extends Error {}
|
|
113
|
+
class PeerNackError extends Error {}
|
|
114
|
+
class PeerProtocolError extends Error {}
|
|
115
|
+
class PeerAuthenticationError extends Error {}
|
|
116
|
+
|
|
117
|
+
/** A peer refused the message. `reason` is the PEER's own prose, kept separate from our wrapper so
|
|
118
|
+
* the tool layer can put that half behind the peer fence (src/core/fence.ts) instead of letting it
|
|
119
|
+
* land in the supervisor's transcript as this agent's own words — `message` keeps the flat wording
|
|
120
|
+
* every other caller (and the operator-facing card) already reads. */
|
|
121
|
+
export class ExocomPeerRejection extends Error {
|
|
122
|
+
readonly reason: string;
|
|
123
|
+
constructor(target: string, reason: string) {
|
|
124
|
+
super(`exocom: peer "${target}" rejected message: ${reason}`);
|
|
125
|
+
this.reason = reason;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function verifyFrameOrigin(frame: ExocomFrame, entry: RegistryEntry): boolean {
|
|
130
|
+
if (!frame.signature || !entry.public_key) return false;
|
|
131
|
+
try {
|
|
132
|
+
const key = createPublicKey({ key: Buffer.from(entry.public_key, "base64"), format: "der", type: "spki" });
|
|
133
|
+
return cryptoVerify(null, Buffer.from(frameSigningPayload(frame), "utf8"), key, Buffer.from(frame.signature, "base64"));
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function preparePosixEndpoint(endpoint: string): void {
|
|
140
|
+
if (process.platform === "win32") return;
|
|
141
|
+
const dir = dirname(endpoint);
|
|
142
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
143
|
+
const stat = lstatSync(dir);
|
|
144
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`exocom: unsafe socket directory ${dir}`);
|
|
145
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`exocom: socket directory is not owned by this user: ${dir}`);
|
|
146
|
+
chmodSync(dir, 0o700);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function closeServer(server: net.Server): Promise<void> {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
try { server.close(() => resolve()); } catch { resolve(); }
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Probe: is something actually listening at `endpoint`? (lifted from `broker/host.ts`,
|
|
156
|
+
* which does not export it — same probe, not a different one.) */
|
|
157
|
+
function isConnectable(netImpl: typeof import("node:net"), endpoint: string): Promise<boolean> {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const socket = netImpl.connect(endpoint);
|
|
160
|
+
const finish = (ok: boolean): void => {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
socket.removeListener("connect", onConnect);
|
|
163
|
+
socket.removeListener("error", onError);
|
|
164
|
+
socket.destroy();
|
|
165
|
+
resolve(ok);
|
|
166
|
+
};
|
|
167
|
+
const onConnect = (): void => finish(true);
|
|
168
|
+
const onError = (): void => finish(false);
|
|
169
|
+
socket.once("connect", onConnect);
|
|
170
|
+
socket.once("error", onError);
|
|
171
|
+
const timer = setTimeout(() => finish(false), STALE_PROBE_TIMEOUT_MS);
|
|
172
|
+
timer.unref?.();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type ListenAttempt = { server: net.Server } | { code: string };
|
|
177
|
+
|
|
178
|
+
/** One bind attempt on a fresh server; connection handler wired before `listen()` so no
|
|
179
|
+
* connection can race it. On success: permanent error sink + `unref()`. */
|
|
180
|
+
function attemptListen(netImpl: typeof import("node:net"), endpoint: string, onConnection: (socket: net.Socket) => void): Promise<ListenAttempt> {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const server = netImpl.createServer();
|
|
183
|
+
server.on("connection", onConnection);
|
|
184
|
+
const onError = (err: NodeJS.ErrnoException): void => {
|
|
185
|
+
server.removeListener("listening", onListening);
|
|
186
|
+
try { server.close(); } catch { /* ignore */ }
|
|
187
|
+
resolve({ code: err.code ?? "EUNKNOWN" });
|
|
188
|
+
};
|
|
189
|
+
const onListening = (): void => {
|
|
190
|
+
server.removeListener("error", onError);
|
|
191
|
+
server.on("error", () => { /* permanent sink: a late error must never crash the plane */ });
|
|
192
|
+
server.unref();
|
|
193
|
+
resolve({ server });
|
|
194
|
+
};
|
|
195
|
+
server.once("error", onError);
|
|
196
|
+
server.once("listening", onListening);
|
|
197
|
+
try {
|
|
198
|
+
server.listen(endpoint);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
resolve({ code: (err as NodeJS.ErrnoException).code ?? "EUNKNOWN" });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Bind `endpoint`, reclaiming a stale POSIX socket file once (mirrors `broker/host.ts`'s
|
|
206
|
+
* bind pattern, minus its host-election concerns — exocom peers are all equal). Windows
|
|
207
|
+
* named pipes leave no stale-file state behind, so the reclaim step is POSIX-only. */
|
|
208
|
+
async function bindServer(netImpl: typeof import("node:net"), endpoint: string, onConnection: (socket: net.Socket) => void): Promise<net.Server> {
|
|
209
|
+
let attempt = await attemptListen(netImpl, endpoint, onConnection);
|
|
210
|
+
if (
|
|
211
|
+
!("server" in attempt) &&
|
|
212
|
+
attempt.code === "EADDRINUSE" &&
|
|
213
|
+
process.platform !== "win32" &&
|
|
214
|
+
existsSync(endpoint) &&
|
|
215
|
+
!(await isConnectable(netImpl, endpoint))
|
|
216
|
+
) {
|
|
217
|
+
try { unlinkSync(endpoint); } catch { /* another instance may have reaped it first */ }
|
|
218
|
+
attempt = await attemptListen(netImpl, endpoint, onConnection);
|
|
219
|
+
}
|
|
220
|
+
if (!("server" in attempt)) throw new Error(`exocom plane: failed to listen on ${endpoint} (${attempt.code})`);
|
|
221
|
+
return attempt.server;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Connect, write one frame, and resolve with the peer's ack (or reject on a signed nack).
|
|
225
|
+
* Bounded by an unref'd `ackTimeoutMs` (R4): a peer that accepts the connection and then
|
|
226
|
+
* never replies (frozen, wedged) must not hang the caller's turn forever. */
|
|
227
|
+
function sendFrame(netImpl: typeof import("node:net"), endpoint: string, frame: ExocomFrame & { msg_id: string }, ackTimeoutMs: number, expected: RegistryEntry): Promise<ExocomAck> {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const socket = netImpl.connect(endpoint);
|
|
230
|
+
let settled = false;
|
|
231
|
+
const finish = (fn: () => void): void => {
|
|
232
|
+
if (settled) return;
|
|
233
|
+
settled = true;
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
fn();
|
|
236
|
+
};
|
|
237
|
+
socket.once("connect", () => socket.write(encodeFrame(frame)));
|
|
238
|
+
socket.on("data", createFrameReader(
|
|
239
|
+
(raw) => finish(() => {
|
|
240
|
+
socket.destroy();
|
|
241
|
+
if (!isExocomFrame(raw)) { reject(new PeerProtocolError("exocom: malformed reply frame")); return; }
|
|
242
|
+
if (raw.kind === "nack" && raw.msg_id === frame.msg_id) {
|
|
243
|
+
if (raw.from_session !== expected.session_id || !verifyFrameOrigin(raw, expected)) reject(new PeerAuthenticationError("exocom: unauthenticated nack"));
|
|
244
|
+
else reject(new PeerNackError(raw.error));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (raw.kind !== "ack" || raw.msg_id !== frame.msg_id) { reject(new PeerProtocolError("exocom: unexpected reply frame")); return; }
|
|
248
|
+
if (raw.from_session !== expected.session_id || !verifyFrameOrigin(raw, expected)) { reject(new PeerAuthenticationError("exocom: unauthenticated ack")); return; }
|
|
249
|
+
resolve(raw);
|
|
250
|
+
}),
|
|
251
|
+
(e) => finish(() => { socket.destroy(); reject(e); }),
|
|
252
|
+
));
|
|
253
|
+
socket.once("error", (err) => finish(() => { socket.destroy(); reject(err); }));
|
|
254
|
+
socket.once("close", () => finish(() => reject(new Error("exocom: connection closed before reply"))));
|
|
255
|
+
const timer = setTimeout(() => finish(() => {
|
|
256
|
+
socket.destroy();
|
|
257
|
+
reject(new AckTimeoutError(`exocom: ack timeout from ${endpoint}`));
|
|
258
|
+
}), ackTimeoutMs);
|
|
259
|
+
timer.unref?.();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Best-effort, fire-and-forget send (used for `bye` — the receiver sends nothing back, so
|
|
264
|
+
* waiting for a reply frame would hang). Bounded by a short timeout either way. */
|
|
265
|
+
function sendNoReply(netImpl: typeof import("node:net"), endpoint: string, frame: ExocomFrame): Promise<void> {
|
|
266
|
+
return new Promise((resolve) => {
|
|
267
|
+
const socket = netImpl.connect(endpoint);
|
|
268
|
+
let settled = false;
|
|
269
|
+
const finish = (): void => {
|
|
270
|
+
if (settled) return;
|
|
271
|
+
settled = true;
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
socket.destroy();
|
|
274
|
+
resolve();
|
|
275
|
+
};
|
|
276
|
+
socket.once("connect", () => socket.write(encodeFrame(frame), () => socket.end()));
|
|
277
|
+
socket.once("close", finish);
|
|
278
|
+
socket.once("error", finish);
|
|
279
|
+
const timer = setTimeout(finish, STALE_PROBE_TIMEOUT_MS);
|
|
280
|
+
timer.unref?.();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `displayName` is the human label (may be renumbered as peers come and go); `target` is the
|
|
285
|
+
* human-readable routing token every caller should ROUTE with. Both are derived, never stored. */
|
|
286
|
+
export type DisplayPeer = RegistryEntry & { displayName: string; target: string };
|
|
287
|
+
|
|
288
|
+
/** A display name is intentionally human-sized, may be shared, and is renumbered from the live
|
|
289
|
+
* set on every read. This qualified token uses a 96-bit session hash, stays within the tool's
|
|
290
|
+
* 80-character budget even for maximum-length names, and is kept separate from human roster
|
|
291
|
+
* labels so a send always addresses the authenticated session it was aimed at. Its label is
|
|
292
|
+
* deliberately current (for people); its hash alone is the stable routing identity. */
|
|
293
|
+
function sessionRoutingHash(sessionId: string): string {
|
|
294
|
+
return createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function qualifiedTarget(entry: RegistryEntry): string {
|
|
298
|
+
return `${entry.name}@${sessionRoutingHash(entry.session_id)}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The tail `qualifiedTarget` emits, whatever the call-sign in front of it. A string of this shape
|
|
302
|
+
* is a ROUTE: it names one session and never degrades into a display-name lookup, because names
|
|
303
|
+
* are unreserved (`normalizePeerName` keeps `@`, so a peer can register another's token as its own
|
|
304
|
+
* label). The test stays deliberately un-anchored — a call-sign may itself contain `@`, and the
|
|
305
|
+
* token built from it must keep that protection too. A call-sign that legitimately LOOKS like a
|
|
306
|
+
* token is the cost of that rule; `namesakeTarget` is what keeps such a peer reachable. */
|
|
307
|
+
function isRoutingToken(target: string): boolean {
|
|
308
|
+
return /@[a-f0-9]{24}$/i.test(target);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The stable, session-derived part of a public target. The prefix is presentation only: a
|
|
312
|
+
* peer may choose a new call-sign after another instance has retained the old target. */
|
|
313
|
+
function routingSessionHash(target: string): string | undefined {
|
|
314
|
+
const match = /@([a-f0-9]{24})$/i.exec(target);
|
|
315
|
+
return match?.[1]?.toLowerCase();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The same file, spelled two ways. Peers share one registry by construction (that `agentDir`+`hash`
|
|
319
|
+
* pair IS the registry both sides read), but each reaches it through its own `PI_AGENT_DIR`, which
|
|
320
|
+
* may differ in case on Windows or carry relative segments — comparing the peer's path string
|
|
321
|
+
* byte-for-byte would NACK every large message between two such peers. Directory safety is checked
|
|
322
|
+
* separately with `lstat`; do not `realpath` here and collapse the accepted spelling contract. */
|
|
323
|
+
function samePath(a: string, b: string): boolean {
|
|
324
|
+
const canonical = (path: string): string => (process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path));
|
|
325
|
+
return canonical(a) === canonical(b);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Compare the kernel identity behind a pathname/descriptor. `dev` + `ino` are populated by
|
|
329
|
+
* Node on supported Windows filesystems as well as POSIX. If a filesystem reports neither, the
|
|
330
|
+
* identity cannot be proved: fail closed rather than treating forgeable timestamps as identity. */
|
|
331
|
+
function sameFileIdentity(a: Stats, b: Stats): boolean {
|
|
332
|
+
if (a.dev !== 0 || a.ino !== 0 || b.dev !== 0 || b.ino !== 0) return a.dev === b.dev && a.ino === b.ino;
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function unchangedOpenFile(before: Stats, after: Stats): boolean {
|
|
337
|
+
return sameFileIdentity(before, after)
|
|
338
|
+
&& before.size === after.size
|
|
339
|
+
&& before.nlink === after.nlink
|
|
340
|
+
&& before.mtimeMs === after.mtimeMs
|
|
341
|
+
&& before.ctimeMs === after.ctimeMs;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** `lstat` the artifacts directory itself: checking only a final spill file does not reveal that
|
|
345
|
+
* a parent component is a symlink/junction. Callers compare returned identities across their own
|
|
346
|
+
* operation where useful; an absent, linked, or non-directory path always fails closed. */
|
|
347
|
+
function safeArtifactDirectory(path: string): Stats | undefined {
|
|
348
|
+
try {
|
|
349
|
+
const stat = lstatSync(path);
|
|
350
|
+
return stat.isDirectory() && !stat.isSymbolicLink() ? stat : undefined;
|
|
351
|
+
} catch {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface InboundContext {
|
|
357
|
+
msg_id: string;
|
|
358
|
+
hops: number;
|
|
359
|
+
entry: RegistryEntry;
|
|
360
|
+
replyTarget: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Display-time-only name disambiguation. The registry FILE is keyed by session_id (registry.ts),
|
|
364
|
+
* so a name collision can never corrupt storage — but two peers named "orion" still need
|
|
365
|
+
* distinct labels for a human to read. Ties are broken by `session_id`, so every instance
|
|
366
|
+
* computes the SAME numbering for one live set; the numbering is NOT stable across time, since
|
|
367
|
+
* it is recomputed from the CURRENT live set and a group that loses a member gives up its
|
|
368
|
+
* suffixes. That is precisely why `target` (session-pinned) rides along with every label and is
|
|
369
|
+
* what routing uses. Never mutates the stored `.name`. */
|
|
370
|
+
function dedupeDisplayNames(peers: RegistryEntry[]): DisplayPeer[] {
|
|
371
|
+
const byName = new Map<string, RegistryEntry[]>();
|
|
372
|
+
for (const p of peers) {
|
|
373
|
+
const group = byName.get(p.name);
|
|
374
|
+
if (group) group.push(p); else byName.set(p.name, [p]);
|
|
375
|
+
}
|
|
376
|
+
const out: DisplayPeer[] = [];
|
|
377
|
+
for (const group of byName.values()) {
|
|
378
|
+
if (group.length === 1) {
|
|
379
|
+
out.push({ ...group[0]!, displayName: group[0]!.name, target: qualifiedTarget(group[0]!) });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const ordered = [...group].sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : 0));
|
|
383
|
+
ordered.forEach((p, i) => out.push({ ...p, displayName: i === 0 ? p.name : `${p.name}#${i + 1}`, target: qualifiedTarget(p) }));
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export class ExocomPlane {
|
|
389
|
+
private readonly deps: ExocomPlaneDeps;
|
|
390
|
+
private readonly netImpl: typeof import("node:net");
|
|
391
|
+
private readonly now: () => number;
|
|
392
|
+
private readonly sockets = new Set<net.Socket>();
|
|
393
|
+
private readonly inboundContext = new Map<string, InboundContext>(); // [from_session,msg_id] -> auth + hops + reply target
|
|
394
|
+
private readonly ackTimeoutMs: number;
|
|
395
|
+
private readonly privateKey: KeyObject;
|
|
396
|
+
private readonly publicKey: string;
|
|
397
|
+
private readonly artifacts = new Map<string, string>();
|
|
398
|
+
private server: net.Server | undefined;
|
|
399
|
+
private readonly unlinkEndpoint: () => void;
|
|
400
|
+
private endpointOwned = false;
|
|
401
|
+
/** Ownership of the registry entry successfully written by this plane. A plane
|
|
402
|
+
* may share a session_id with a replacement, so session_id alone is not enough
|
|
403
|
+
* to clean up safely. */
|
|
404
|
+
private registeredOwnership: RegistryOwnership | undefined;
|
|
405
|
+
// Viewer-centric per-peer counters, keyed by the PEER's session_id — NOT a global self-report
|
|
406
|
+
// (that read "inverted" on another instance's pool: a peer who sent TO us would show ITS OWN
|
|
407
|
+
// out-count, not what WE received from it). Local to this instance, never published in the
|
|
408
|
+
// registry (registry.ts no longer carries sent/received).
|
|
409
|
+
private readonly sentTo = new Map<string, number>();
|
|
410
|
+
private readonly receivedFrom = new Map<string, number>();
|
|
411
|
+
|
|
412
|
+
constructor(deps: ExocomPlaneDeps) {
|
|
413
|
+
this.deps = deps;
|
|
414
|
+
this.netImpl = deps.net ?? nodeNet;
|
|
415
|
+
this.now = deps.now ?? Date.now;
|
|
416
|
+
this.ackTimeoutMs = deps.ackTimeoutMs ?? EXOCOM.ACK_TIMEOUT_MS;
|
|
417
|
+
this.unlinkEndpoint = deps.unlinkEndpoint ?? (() => {
|
|
418
|
+
if (process.platform !== "win32") {
|
|
419
|
+
try { unlinkSync(deps.identity.endpoint); } catch { /* best-effort */ }
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
const pair = generateKeyPairSync("ed25519");
|
|
423
|
+
this.privateKey = pair.privateKey;
|
|
424
|
+
this.publicKey = pair.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private signFrame<T extends ExocomFrame>(frame: T): T {
|
|
428
|
+
return { ...frame, signature: cryptoSign(null, Buffer.from(frameSigningPayload(frame), "utf8"), this.privateKey).toString("base64") };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private ack(msgId: string): ExocomAck {
|
|
432
|
+
return this.signFrame({ kind: "ack", msg_id: msgId, from_session: this.deps.identity.session_id });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private nack(msgId: string, error: string): ExocomNack {
|
|
436
|
+
return this.signFrame({ kind: "nack", msg_id: msgId, error, from_session: this.deps.identity.session_id });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The identity name — display-only (see `RegistryEntry.name`'s doc comment). Nothing left to
|
|
440
|
+
* suffix: the registry file is keyed by session_id, so this never diverges from
|
|
441
|
+
* `deps.identity.name`, before or after `start()`. */
|
|
442
|
+
get name(): string {
|
|
443
|
+
return normalizePeerName(this.deps.identity.name);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Messages this instance has successfully `send()`'d TO the given peer (ack settled), keyed by
|
|
447
|
+
* the peer's session_id — 0 for a peer never sent to. The plane is the SINGLE source of these
|
|
448
|
+
* counters — the widget reads them, it never keeps its own. */
|
|
449
|
+
sentToPeer(sessionId: string): number {
|
|
450
|
+
return this.sentTo.get(sessionId) ?? 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Inbound `message` frames received FROM the given peer (bye/ack don't count — see
|
|
454
|
+
* `handleConnection`'s `onFrame`), keyed by the peer's session_id — 0 for a peer never heard
|
|
455
|
+
* from. */
|
|
456
|
+
receivedFromPeer(sessionId: string): number {
|
|
457
|
+
return this.receivedFrom.get(sessionId) ?? 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Sum across all peers — the status line's aggregate view. */
|
|
461
|
+
get totalSent(): number {
|
|
462
|
+
return [...this.sentTo.values()].reduce((a, b) => a + b, 0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Sum across all peers — the status line's aggregate view. */
|
|
466
|
+
get totalReceived(): number {
|
|
467
|
+
return [...this.receivedFrom.values()].reduce((a, b) => a + b, 0);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async start(): Promise<void> {
|
|
471
|
+
const { agentDir, hash, identity } = this.deps;
|
|
472
|
+
let bound = false;
|
|
473
|
+
let registeredOnThisStart = false;
|
|
474
|
+
try {
|
|
475
|
+
preparePosixEndpoint(identity.endpoint);
|
|
476
|
+
this.server = await bindServer(this.netImpl, identity.endpoint, (s) => this.handleConnection(s));
|
|
477
|
+
bound = true;
|
|
478
|
+
this.endpointOwned = true;
|
|
479
|
+
if (process.platform !== "win32") chmodSync(identity.endpoint, 0o600);
|
|
480
|
+
prune(agentDir, hash, { now: this.now(), staleMs: EXOCOM.STALE_AFTER_MS });
|
|
481
|
+
const entry = this.buildEntry();
|
|
482
|
+
writeEntry(agentDir, hash, entry);
|
|
483
|
+
this.registeredOwnership = {
|
|
484
|
+
session_id: entry.session_id,
|
|
485
|
+
endpoint: entry.endpoint,
|
|
486
|
+
...(entry.public_key ? { public_key: entry.public_key } : {}),
|
|
487
|
+
};
|
|
488
|
+
registeredOnThisStart = true;
|
|
489
|
+
this.cleanupArtifacts();
|
|
490
|
+
} catch (err) {
|
|
491
|
+
const server = this.server;
|
|
492
|
+
this.server = undefined;
|
|
493
|
+
for (const socket of this.sockets) {
|
|
494
|
+
try { socket.destroy(); } catch { /* best-effort rollback */ }
|
|
495
|
+
}
|
|
496
|
+
this.sockets.clear();
|
|
497
|
+
if (server) await closeServer(server);
|
|
498
|
+
if (bound) {
|
|
499
|
+
try { this.unlinkEndpoint(); } catch { /* best-effort */ }
|
|
500
|
+
this.endpointOwned = false;
|
|
501
|
+
}
|
|
502
|
+
if (registeredOnThisStart && this.registeredOwnership) {
|
|
503
|
+
removeEntryIfMatches(agentDir, hash, this.registeredOwnership);
|
|
504
|
+
this.registeredOwnership = undefined;
|
|
505
|
+
}
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** This instance's registry entry, keyed (by writeEntry) on `identity.session_id` — never on
|
|
511
|
+
* `name`, so there is nothing to claim/suffix. The heartbeat re-write (extension.ts) targets
|
|
512
|
+
* the SAME file on every call via `writeEntry`. */
|
|
513
|
+
private buildEntry(): RegistryEntry {
|
|
514
|
+
const { identity } = this.deps;
|
|
515
|
+
const card = this.deps.getCard();
|
|
516
|
+
return {
|
|
517
|
+
session_id: identity.session_id, name: this.name, persona: identity.persona, purpose: identity.purpose,
|
|
518
|
+
color: identity.color, model: identity.model, pid: process.pid, endpoint: identity.endpoint,
|
|
519
|
+
cwd: identity.cwd, context_pct: card.context_pct, inbox: card.inbox,
|
|
520
|
+
heartbeat_at: new Date(this.now()).toISOString(),
|
|
521
|
+
public_key: this.publicKey,
|
|
522
|
+
...(identity.workspace ? {
|
|
523
|
+
workspace_id: identity.workspace.id,
|
|
524
|
+
workspace_code: identity.workspace.code,
|
|
525
|
+
workspace_label: identity.workspace.label,
|
|
526
|
+
} : {}),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Re-register this instance with FRESH metadata (a `/persona` switch, a model change) — the
|
|
531
|
+
* ONLY supported way to rewrite our own entry. Routing it through the plane is what keeps the
|
|
532
|
+
* ed25519 key attached: the entry file can vanish while we are alive (a peer's transient send
|
|
533
|
+
* error evicts it, or a >STALE_AFTER_MS stall lets every peer prune it), and a re-creation
|
|
534
|
+
* without `public_key` would make every frame we sign unverifiable — permanently, since the
|
|
535
|
+
* key exists only in this process. `session_id` is ours by construction, never the caller's. */
|
|
536
|
+
heartbeat(entry: Omit<RegistryEntry, "public_key">): void {
|
|
537
|
+
const { agentDir, hash, identity } = this.deps;
|
|
538
|
+
const owned = { ...entry, session_id: identity.session_id, public_key: this.publicKey };
|
|
539
|
+
writeEntry(agentDir, hash, owned);
|
|
540
|
+
this.registeredOwnership = { session_id: owned.session_id, endpoint: owned.endpoint, public_key: owned.public_key };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Pruned live peers, excluding self, with a display-deduped `displayName` ("orion"/"orion#2")
|
|
544
|
+
* computed over the FULL live set (self included) before self is filtered out — so every
|
|
545
|
+
* instance computes the SAME numbering for a given peer, regardless of who's asking. */
|
|
546
|
+
listPeers(): DisplayPeer[] {
|
|
547
|
+
const { agentDir, hash, identity } = this.deps;
|
|
548
|
+
const live = prune(agentDir, hash, { now: this.now(), staleMs: EXOCOM.STALE_AFTER_MS });
|
|
549
|
+
return dedupeDisplayNames(live).filter((e) => e.session_id !== identity.session_id);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Resolve the same public target emitted by `exocom_list` to its authenticated registry
|
|
553
|
+
* identity. Work-ledger frames persist raw session ids, so callers must canonicalize through
|
|
554
|
+
* this seam before committing an addressed event. */
|
|
555
|
+
resolvePeer(target: string): DisplayPeer {
|
|
556
|
+
const peers = this.listPeers();
|
|
557
|
+
if (isRoutingToken(target)) {
|
|
558
|
+
const qualified = this.qualifiedPeers(target, peers);
|
|
559
|
+
if (qualified.length === 1) return qualified[0]!;
|
|
560
|
+
if (qualified.length > 1) throw new Error(`exocom: ambiguous qualified target "${target}"`);
|
|
561
|
+
const namesake = peers.find((peer) => peer.displayName === target);
|
|
562
|
+
throw new Error([
|
|
563
|
+
`exocom: unknown qualified target "${target}"`,
|
|
564
|
+
namesake ? ` — a live peer holds that string as its display name; address it as "${namesake.target}"` : "",
|
|
565
|
+
].join(""));
|
|
566
|
+
}
|
|
567
|
+
const entry = peers.find((peer) => peer.displayName === target);
|
|
568
|
+
if (!entry) throw new Error(`exocom: unknown peer "${target}"`);
|
|
569
|
+
return entry;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Read-only live pool used for human labels. Unlike listPeers(), this never prunes: an
|
|
573
|
+
* authenticated inbound sender may be just past its heartbeat window while its socket is
|
|
574
|
+
* still serving the message. The same liveness predicate keeps numbering aligned with the
|
|
575
|
+
* normal roster whenever the registry is healthy. */
|
|
576
|
+
private livePoolForDisplay(): RegistryEntry[] {
|
|
577
|
+
const now = this.now();
|
|
578
|
+
return this.pool().filter((entry) => {
|
|
579
|
+
const heartbeat = Date.parse(entry.heartbeat_at);
|
|
580
|
+
return isAlive(entry.pid) && Number.isFinite(heartbeat) && now - heartbeat <= EXOCOM.STALE_AFTER_MS;
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The whole registry pool, read-only (the `readPool` seam's single choke point). */
|
|
585
|
+
private pool(): RegistryEntry[] {
|
|
586
|
+
const { agentDir, hash } = this.deps;
|
|
587
|
+
return this.deps.readPool ? this.deps.readPool(agentDir, hash) : readAll(agentDir, hash);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Resolve a session-qualified token. The readable name before its hash may be stale after a
|
|
591
|
+
* rename; the stable hash is the routing identity. A REPLY may still reach a sender whose registry entry
|
|
592
|
+
* has already been pruned (the whole pool is searched before `listPeers()` would evict it) —
|
|
593
|
+
* direct stale targeting stays limited to replies, so an ordinary send resolves only against
|
|
594
|
+
* the live roster `exocom_list` publishes. */
|
|
595
|
+
private qualifiedPeers<T extends RegistryEntry>(target: string, peers: T[]): T[] {
|
|
596
|
+
const suffix = routingSessionHash(target);
|
|
597
|
+
if (!suffix) return [];
|
|
598
|
+
return peers.filter((entry) => sessionRoutingHash(entry.session_id) === suffix);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private qualifiedPeer(target: string, inReplyTo: string | undefined): RegistryEntry[] {
|
|
602
|
+
if (!isRoutingToken(target)) return [];
|
|
603
|
+
try {
|
|
604
|
+
if (inReplyTo === undefined) return this.qualifiedPeers(target, this.listPeers());
|
|
605
|
+
const own = this.deps.identity.session_id;
|
|
606
|
+
return this.qualifiedPeers(target, this.pool().filter((entry) => entry.session_id !== own));
|
|
607
|
+
} catch {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The routing token of a live peer that carries `target` as its DISPLAY name. A call-sign is
|
|
613
|
+
* the model's free choice, so one can legitimately look like a routing token — and such a peer
|
|
614
|
+
* must not simply become unaddressable. Routing still refuses (a token names one session and is
|
|
615
|
+
* never answered by whoever happens to hold that string as a label), but the refusal can hand
|
|
616
|
+
* back the address that does work, so the caller repairs the send instead of giving up. */
|
|
617
|
+
private namesakeTarget(target: string): string | undefined {
|
|
618
|
+
try {
|
|
619
|
+
return this.listPeers().find((peer) => peer.displayName === target)?.target;
|
|
620
|
+
} catch {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** An authenticated inbound frame pins its endpoint/key even if its registry
|
|
626
|
+
* file is pruned before the supervisor gets a chance to answer. Match the exact
|
|
627
|
+
* target token emitted for that frame, not merely a shared raw name. */
|
|
628
|
+
private cachedReplyPeer(target: string, msgId: string): RegistryEntry | undefined {
|
|
629
|
+
for (const context of this.inboundContext.values()) {
|
|
630
|
+
if (context.msg_id === msgId && context.replyTarget === target) return context.entry;
|
|
631
|
+
}
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private hasCachedReply(msgId: string): boolean {
|
|
636
|
+
for (const context of this.inboundContext.values()) if (context.msg_id === msgId) return true;
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Stable, routable token for an authenticated inbound sender. Human display
|
|
641
|
+
* names remain the concern of `listPeers()`/`exocom_list`; reply hints always
|
|
642
|
+
* use this token so a later prune or same-name peer cannot retarget them. */
|
|
643
|
+
replyTargetFor(entry: RegistryEntry): string {
|
|
644
|
+
return qualifiedTarget(entry);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Human-facing, stable label for an authenticated registry entry. This is deliberately
|
|
648
|
+
* separate from replyTargetFor(): display labels may be `name#2`, while routing always uses
|
|
649
|
+
* the qualified session token. A registry read failure degrades to the entry's own sanitized
|
|
650
|
+
* name so naming cannot reject an otherwise valid inbound message. */
|
|
651
|
+
humanDisplayLabelFor(entry: RegistryEntry): string {
|
|
652
|
+
try {
|
|
653
|
+
const live = this.livePoolForDisplay();
|
|
654
|
+
const display = dedupeDisplayNames(live).find((peer) => peer.session_id === entry.session_id);
|
|
655
|
+
if (display) return display.displayName;
|
|
656
|
+
const siblings = live.filter((peer) => peer.name === entry.name);
|
|
657
|
+
if (siblings.length > 0) return `${entry.name}@${createHash("sha256").update(entry.session_id).digest("hex").slice(0, 8)}`;
|
|
658
|
+
} catch {
|
|
659
|
+
/* Human naming must never block authenticated delivery. */
|
|
660
|
+
}
|
|
661
|
+
return entry.name;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Registry lookup for an inbound frame's claimed sender. Fails CLOSED: an unreadable pool
|
|
665
|
+
* yields no entry (⇒ "authentication failed"), rather than escaping the frame reader — which
|
|
666
|
+
* poisons the connection, so the sender reads it as unreachable and evicts US. */
|
|
667
|
+
private lookupPeer(sessionId: string): RegistryEntry | undefined {
|
|
668
|
+
try {
|
|
669
|
+
return this.pool().find((e) => e.session_id === sessionId);
|
|
670
|
+
} catch {
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** A spill descriptor is a CLAIM about a file, and the receiver turns it into `Full payload:
|
|
676
|
+
* <path>` for its own model plus a byte charge against the sender's window (exocom/inbound.ts).
|
|
677
|
+
* The peer authors both fields, so verify them against local ground truth here — the only
|
|
678
|
+
* layer that has it — before the claim can become an instruction to read something. What is
|
|
679
|
+
* established: the path names THIS Exocom scope's artifacts directory (however it is spelled), the
|
|
680
|
+
* file there is a regular file this Exocom scope itself wrote rather than a link to some other
|
|
681
|
+
* file, and its size on disk both matches the declared number and sits inside the artifact
|
|
682
|
+
* band. What is NOT established is the CONTENT: a peer that can write the file can put anything
|
|
683
|
+
* in it, and the delivery is fenced as untrusted peer data for exactly that reason. Returns a
|
|
684
|
+
* nack reason, or undefined when the message carries no descriptor / a verified one. */
|
|
685
|
+
private artifactClaimError(msg: ExocomMessage): string | undefined {
|
|
686
|
+
const descriptor = parseExocomArtifactDescriptor(msg.text);
|
|
687
|
+
if (!descriptor) return undefined;
|
|
688
|
+
const artifactsDir = join(exocomRoot(this.deps.agentDir, this.deps.hash), "artifacts");
|
|
689
|
+
if (!safeArtifactDirectory(artifactsDir)) return "artifact directory is unsafe";
|
|
690
|
+
const expected = join(artifactsDir, `${msg.msg_id}.txt`);
|
|
691
|
+
if (!samePath(descriptor.path, expected)) return "artifact path is not this Exocom scope's spill for that message";
|
|
692
|
+
let stat: Stats;
|
|
693
|
+
try {
|
|
694
|
+
// lstat, never stat: the check above constrains a NAME, and a name is not a file. A link
|
|
695
|
+
// left at that name would otherwise hand the receiver's model any file this user can read
|
|
696
|
+
// — a symlink fails `isFile()`, and a hard link is a second name for a file this
|
|
697
|
+
// scope never spilled, which is what `nlink` reports.
|
|
698
|
+
stat = lstatSync(expected);
|
|
699
|
+
} catch {
|
|
700
|
+
return "artifact payload is missing";
|
|
701
|
+
}
|
|
702
|
+
if (!stat.isFile()) return "artifact payload is not a regular file";
|
|
703
|
+
if (stat.nlink > 1) return "artifact payload is linked to another file";
|
|
704
|
+
if (stat.size !== descriptor.size) return "artifact size does not match the payload on disk";
|
|
705
|
+
// Only a payload the sender could not inline may travel as an artifact (see `payloadFor`);
|
|
706
|
+
// anything smaller belongs in the message text, where the inject budget governs it.
|
|
707
|
+
if (stat.size <= EXOCOM.INLINE_MAX_BYTES) return "artifact payload fits the inline budget";
|
|
708
|
+
if (stat.size > ARTIFACT_MAX_BYTES) return "artifact payload exceeds the artifact ceiling";
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Copy a verified spill into an exclusive, receiver-chosen `artifacts/received/<uuid>.txt`
|
|
714
|
+
* and rewrite the descriptor path so the model reads a snapshot, not a file the sender can
|
|
715
|
+
* still mutate (TOCTOU).
|
|
716
|
+
* Returns an error when the message carries a descriptor but the copy failed.
|
|
717
|
+
*/
|
|
718
|
+
private snapshotArtifact(msg: ExocomMessage): { ok: true; msg: ExocomMessage } | { ok: false; error: string } {
|
|
719
|
+
const descriptor = parseExocomArtifactDescriptor(msg.text);
|
|
720
|
+
if (!descriptor) return { ok: true, msg };
|
|
721
|
+
const artifactsDir = join(exocomRoot(this.deps.agentDir, this.deps.hash), "artifacts");
|
|
722
|
+
const src = join(artifactsDir, `${msg.msg_id}.txt`);
|
|
723
|
+
const destDir = join(artifactsDir, "received");
|
|
724
|
+
let sourceFd: number | undefined;
|
|
725
|
+
let payload: Buffer;
|
|
726
|
+
let artifactsIdentity: Stats;
|
|
727
|
+
try {
|
|
728
|
+
// The pathname check above is only a preflight. Open the file without following a final
|
|
729
|
+
// symlink where the OS supports it, then bind every validation and the read to that held
|
|
730
|
+
// descriptor. The before/after pathname checks detect POSIX rename-and-replace while the
|
|
731
|
+
// descriptor stays pinned to the original inode; the repeated fd checks also reject an
|
|
732
|
+
// in-place truncate/write during the read.
|
|
733
|
+
const safeDir = safeArtifactDirectory(artifactsDir);
|
|
734
|
+
if (!safeDir) throw new Error("unsafe artifact directory");
|
|
735
|
+
artifactsIdentity = safeDir;
|
|
736
|
+
const pathBefore = lstatSync(src);
|
|
737
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
738
|
+
sourceFd = openSync(src, fsConstants.O_RDONLY | noFollow);
|
|
739
|
+
const fdBefore = fstatSync(sourceFd);
|
|
740
|
+
if (!pathBefore.isFile() || !fdBefore.isFile() || !sameFileIdentity(pathBefore, fdBefore)) throw new Error("unsafe source identity");
|
|
741
|
+
if (pathBefore.nlink > 1 || fdBefore.nlink > 1) throw new Error("linked source");
|
|
742
|
+
if (fdBefore.size !== descriptor.size || fdBefore.size <= EXOCOM.INLINE_MAX_BYTES || fdBefore.size > ARTIFACT_MAX_BYTES) throw new Error("invalid source size");
|
|
743
|
+
this.deps.beforeArtifactRead?.(src);
|
|
744
|
+
payload = readFileSync(sourceFd);
|
|
745
|
+
const fdAfter = fstatSync(sourceFd);
|
|
746
|
+
const pathAfter = lstatSync(src);
|
|
747
|
+
const artifactsAfterRead = safeArtifactDirectory(artifactsDir);
|
|
748
|
+
if (!fdAfter.isFile() || !pathAfter.isFile() || fdAfter.nlink > 1 || pathAfter.nlink > 1) throw new Error("unsafe source after read");
|
|
749
|
+
if (!artifactsAfterRead || !sameFileIdentity(artifactsIdentity, artifactsAfterRead)) throw new Error("artifact directory changed during snapshot");
|
|
750
|
+
if (!unchangedOpenFile(fdBefore, fdAfter) || !sameFileIdentity(fdAfter, pathAfter)) throw new Error("source changed during snapshot");
|
|
751
|
+
if (fdAfter.size !== descriptor.size || payload.byteLength !== descriptor.size) throw new Error("source size changed during snapshot");
|
|
752
|
+
} catch {
|
|
753
|
+
return { ok: false, error: "artifact snapshot failed" };
|
|
754
|
+
} finally {
|
|
755
|
+
if (sourceFd !== undefined) {
|
|
756
|
+
try { closeSync(sourceFd); } catch { /* best-effort close after a failed read */ }
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
let destFd: number | undefined;
|
|
761
|
+
let dest: string | undefined;
|
|
762
|
+
let destCreated = false;
|
|
763
|
+
try {
|
|
764
|
+
const artifactsBeforeWrite = safeArtifactDirectory(artifactsDir);
|
|
765
|
+
if (!artifactsBeforeWrite || !sameFileIdentity(artifactsIdentity, artifactsBeforeWrite)) throw new Error("unsafe artifact directory");
|
|
766
|
+
mkdirSync(destDir, { recursive: true, mode: 0o700 });
|
|
767
|
+
const artifactsAfterMkdir = safeArtifactDirectory(artifactsDir);
|
|
768
|
+
if (!artifactsAfterMkdir || !sameFileIdentity(artifactsIdentity, artifactsAfterMkdir)) throw new Error("artifact directory changed during snapshot");
|
|
769
|
+
const destDirStat = lstatSync(destDir);
|
|
770
|
+
if (!destDirStat.isDirectory() || destDirStat.isSymbolicLink()) throw new Error("unsafe received directory");
|
|
771
|
+
this.cleanupArtifacts();
|
|
772
|
+
const snapshotId = this.deps.artifactSnapshotId?.() ?? randomUUID();
|
|
773
|
+
if (!/^[0-9a-f-]{36}$/i.test(snapshotId)) throw new Error("invalid snapshot id");
|
|
774
|
+
dest = join(destDir, `${snapshotId}.txt`);
|
|
775
|
+
// `wx` is O_CREAT|O_EXCL: even if somebody guessed the random name and planted a
|
|
776
|
+
// regular file or symlink, this fails instead of following or overwriting it.
|
|
777
|
+
destFd = openSync(dest, "wx", 0o600);
|
|
778
|
+
destCreated = true;
|
|
779
|
+
writeFileSync(destFd, payload);
|
|
780
|
+
const destStat = fstatSync(destFd);
|
|
781
|
+
if (!destStat.isFile() || destStat.nlink > 1 || destStat.size !== payload.byteLength) throw new Error("unsafe snapshot destination");
|
|
782
|
+
} catch {
|
|
783
|
+
if (destFd !== undefined) {
|
|
784
|
+
try { closeSync(destFd); } catch { /* best-effort close before removing our partial file */ }
|
|
785
|
+
destFd = undefined;
|
|
786
|
+
}
|
|
787
|
+
if (destCreated && dest !== undefined) {
|
|
788
|
+
try { unlinkSync(dest); } catch { /* best-effort removal of our partial snapshot */ }
|
|
789
|
+
}
|
|
790
|
+
return { ok: false, error: "artifact snapshot failed" };
|
|
791
|
+
} finally {
|
|
792
|
+
if (destFd !== undefined) {
|
|
793
|
+
try { closeSync(destFd); } catch { /* best-effort close after a failed write */ }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (dest === undefined) return { ok: false, error: "artifact snapshot failed" };
|
|
797
|
+
const quarantined: ExocomArtifactDescriptor = {
|
|
798
|
+
kind: "exocom_artifact",
|
|
799
|
+
preview: descriptor.preview,
|
|
800
|
+
path: dest,
|
|
801
|
+
size: descriptor.size,
|
|
802
|
+
};
|
|
803
|
+
return { ok: true, msg: { ...msg, text: JSON.stringify(quarantined) } };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private handleConnection(socket: net.Socket): void {
|
|
807
|
+
socket.unref?.(); // M5: an accepted connection must never keep the process alive on its own (mirrors the server's own unref())
|
|
808
|
+
this.sockets.add(socket);
|
|
809
|
+
const write = (frame: ExocomFrame): void => {
|
|
810
|
+
try { socket.write(encodeFrame(frame)); } catch { /* peer went away mid-write */ }
|
|
811
|
+
};
|
|
812
|
+
const onFrame = (raw: unknown): void => {
|
|
813
|
+
if (!isExocomFrame(raw)) return; // fail-closed preflight (R5) — silently drop junk
|
|
814
|
+
switch (raw.kind) {
|
|
815
|
+
case "message": {
|
|
816
|
+
const entry = this.lookupPeer(raw.from_session);
|
|
817
|
+
if (!entry || raw.from_endpoint !== entry.endpoint || !verifyFrameOrigin(raw, entry)) {
|
|
818
|
+
write(this.nack(raw.msg_id, "authentication failed"));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const artifactError = this.artifactClaimError(raw);
|
|
822
|
+
if (artifactError) {
|
|
823
|
+
write(this.nack(raw.msg_id, artifactError));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const snapshot = this.snapshotArtifact(raw);
|
|
827
|
+
if (!snapshot.ok) {
|
|
828
|
+
write(this.nack(raw.msg_id, snapshot.error));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let disposition: ExocomInboundResult | undefined;
|
|
832
|
+
try {
|
|
833
|
+
disposition = this.deps.onInbound(snapshot.msg, entry);
|
|
834
|
+
} catch {
|
|
835
|
+
write(this.nack(raw.msg_id, "receiver rejected message"));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (disposition?.accepted === false) {
|
|
839
|
+
write(this.nack(raw.msg_id, disposition.reason.slice(0, 256) || "receiver rejected message"));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (disposition?.accepted === true && disposition.duplicate === true) {
|
|
843
|
+
write(this.ack(raw.msg_id));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
this.receivedFrom.set(raw.from_session, (this.receivedFrom.get(raw.from_session) ?? 0) + 1);
|
|
847
|
+
this.inboundContext.set(inboundHopKey(raw.from_session, raw.msg_id), {
|
|
848
|
+
msg_id: raw.msg_id, hops: raw.hops, entry, replyTarget: this.replyTargetFor(entry),
|
|
849
|
+
});
|
|
850
|
+
if (this.inboundContext.size > MAX_TRACKED_HOPS) {
|
|
851
|
+
const oldest = this.inboundContext.keys().next().value;
|
|
852
|
+
if (oldest !== undefined) this.inboundContext.delete(oldest);
|
|
853
|
+
}
|
|
854
|
+
try { this.deps.onPoolChange?.(); } catch { /* UI refresh must never block the transport ACK */ }
|
|
855
|
+
write(this.ack(raw.msg_id));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
case "bye": {
|
|
859
|
+
const entry = this.lookupPeer(raw.from_session);
|
|
860
|
+
if (entry && raw.from_endpoint === entry.endpoint && verifyFrameOrigin(raw, entry)) {
|
|
861
|
+
removeEntryIfMatches(this.deps.agentDir, this.deps.hash, entry);
|
|
862
|
+
this.deps.onPoolChange?.(); // a peer left cleanly — refresh the pool now, don't wait 30s
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
case "claim":
|
|
867
|
+
case "ask":
|
|
868
|
+
case "answer":
|
|
869
|
+
case "progress":
|
|
870
|
+
case "release": {
|
|
871
|
+
if (!isSemanticFrame(raw)) return;
|
|
872
|
+
const entry = this.lookupPeer(raw.from_session);
|
|
873
|
+
if (!entry || !verifyFrameOrigin(raw, entry)) {
|
|
874
|
+
write(this.nack(raw.msg_id, "authentication failed"));
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
let disposition: ExocomInboundResult | undefined;
|
|
878
|
+
try {
|
|
879
|
+
disposition = this.deps.onSemantic ? this.deps.onSemantic(raw, entry) : { accepted: true };
|
|
880
|
+
} catch {
|
|
881
|
+
write(this.nack(raw.msg_id, "receiver rejected frame"));
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (disposition?.accepted === false) {
|
|
885
|
+
write(this.nack(raw.msg_id, disposition.reason.slice(0, 256) || "receiver rejected frame"));
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
write(this.ack(raw.msg_id));
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
default:
|
|
892
|
+
return; // ack/nack land on a SENDER's own connection, never here
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
socket.on("data", createFrameReader(onFrame, () => socket.destroy()));
|
|
896
|
+
socket.on("error", () => socket.destroy());
|
|
897
|
+
socket.once("close", () => this.sockets.delete(socket));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private cleanupArtifacts(): void {
|
|
901
|
+
const dir = join(exocomRoot(this.deps.agentDir, this.deps.hash), "artifacts");
|
|
902
|
+
if (!existsSync(dir)) return;
|
|
903
|
+
try {
|
|
904
|
+
const rootStat = lstatSync(dir);
|
|
905
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return;
|
|
906
|
+
const now = Date.now();
|
|
907
|
+
const live: Array<{ path: string; mtime: number }> = [];
|
|
908
|
+
const collect = (candidateDir: string): void => {
|
|
909
|
+
let dirStat: Stats;
|
|
910
|
+
try { dirStat = lstatSync(candidateDir); } catch { return; }
|
|
911
|
+
// Never recurse through a planted `received` link (or any non-directory object).
|
|
912
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return;
|
|
913
|
+
for (const file of readdirSync(candidateDir)) {
|
|
914
|
+
if (!/^[0-9a-f-]{36}\.txt$/i.test(file)) continue;
|
|
915
|
+
const path = join(candidateDir, file);
|
|
916
|
+
let stat: Stats;
|
|
917
|
+
try { stat = lstatSync(path); } catch { continue; }
|
|
918
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
919
|
+
if (now - stat.mtimeMs > EXOCOM.ARTIFACT_TTL_MS) { try { unlinkSync(path); } catch { /* best-effort */ } }
|
|
920
|
+
else live.push({ path, mtime: stat.mtimeMs });
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
collect(dir);
|
|
924
|
+
collect(join(dir, "received"));
|
|
925
|
+
live.sort((a, b) => a.mtime - b.mtime);
|
|
926
|
+
// The artifacts directory is shared by every peer in the scope and this sweep runs on
|
|
927
|
+
// any of them starting, stopping or spilling. A spill is written, sent and verified by its
|
|
928
|
+
// receiver inside the sender's ack budget, so anything younger than that may be IN FLIGHT:
|
|
929
|
+
// reaping it turns a capacity sweep into a hard, lossy send failure ("artifact payload is
|
|
930
|
+
// missing") for a peer that did nothing wrong. The file cap is therefore a soft bound —
|
|
931
|
+
// a burst can briefly exceed it, and the TTL sweep above is what ultimately reclaims.
|
|
932
|
+
let excess = Math.max(0, live.length - (EXOCOM.ARTIFACT_MAX_FILES - 1));
|
|
933
|
+
for (const candidate of live) {
|
|
934
|
+
if (excess === 0) break;
|
|
935
|
+
if (now - candidate.mtime < EXOCOM.ACK_TIMEOUT_MS) continue;
|
|
936
|
+
try { unlinkSync(candidate.path); } catch { /* best-effort */ }
|
|
937
|
+
excess -= 1;
|
|
938
|
+
}
|
|
939
|
+
} catch { /* cleanup must not break messaging */ }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private removeArtifact(msgId: string): void {
|
|
943
|
+
const path = this.artifacts.get(msgId);
|
|
944
|
+
if (!path) return;
|
|
945
|
+
try { unlinkSync(path); } catch { /* best-effort */ }
|
|
946
|
+
this.artifacts.delete(msgId);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Spill to a scope-selected artifact once the payload exceeds the inline budget (R3);
|
|
950
|
+
* the receiver reads `path` on its own turn instead of the full text landing inline. */
|
|
951
|
+
private payloadFor(msgId: string, text: string): string {
|
|
952
|
+
if (Buffer.byteLength(text, "utf8") <= EXOCOM.INLINE_MAX_BYTES) return text;
|
|
953
|
+
const dir = join(exocomRoot(this.deps.agentDir, this.deps.hash), "artifacts");
|
|
954
|
+
mkdirSync(dir, { recursive: true });
|
|
955
|
+
const dirBefore = safeArtifactDirectory(dir);
|
|
956
|
+
if (!dirBefore) throw new Error("exocom: unsafe artifact directory");
|
|
957
|
+
this.cleanupArtifacts();
|
|
958
|
+
const dirAfterCleanup = safeArtifactDirectory(dir);
|
|
959
|
+
if (!dirAfterCleanup || !sameFileIdentity(dirBefore, dirAfterCleanup)) throw new Error("exocom: unsafe artifact directory");
|
|
960
|
+
const path = join(dir, `${msgId}.txt`);
|
|
961
|
+
writeFileSync(path, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
962
|
+
const dirAfterWrite = safeArtifactDirectory(dir);
|
|
963
|
+
if (!dirAfterWrite || !sameFileIdentity(dirBefore, dirAfterWrite)) throw new Error("exocom: unsafe artifact directory");
|
|
964
|
+
this.artifacts.set(msgId, path);
|
|
965
|
+
const descriptor: ExocomArtifactDescriptor = {
|
|
966
|
+
kind: "exocom_artifact",
|
|
967
|
+
preview: truncateForInject(text, EXOCOM.PREVIEW_BYTES).text,
|
|
968
|
+
path,
|
|
969
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
970
|
+
};
|
|
971
|
+
return JSON.stringify(descriptor);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async send(target: string, text: string, inReplyTo?: string): Promise<{ msg_id: string }> {
|
|
975
|
+
const { agentDir, hash, identity } = this.deps;
|
|
976
|
+
// Routing order: the authenticated inbound context (replies), then the session-qualified
|
|
977
|
+
// token, then the human display name. A routing token names ONE session, so it NEVER falls
|
|
978
|
+
// back to a display name — peer names are self-chosen and unreserved, and `normalizePeerName`
|
|
979
|
+
// keeps `@`, so a peer can register another's token as its own label. A call-sign may
|
|
980
|
+
// legitimately look like a token, so the refusal names the address that peer does answer to
|
|
981
|
+
// rather than leaving the caller with a dead end.
|
|
982
|
+
const cachedReply = inReplyTo !== undefined ? this.cachedReplyPeer(target, inReplyTo) : undefined;
|
|
983
|
+
if (inReplyTo !== undefined && this.hasCachedReply(inReplyTo) && !cachedReply) {
|
|
984
|
+
throw new Error(`exocom: reply target "${target}" does not match the authenticated sender for "${inReplyTo}"`);
|
|
985
|
+
}
|
|
986
|
+
const qualified = this.qualifiedPeer(target, inReplyTo);
|
|
987
|
+
if (qualified.length > 1 && !cachedReply) {
|
|
988
|
+
throw new Error(`exocom: ambiguous qualified target "${target}"`);
|
|
989
|
+
}
|
|
990
|
+
if (isRoutingToken(target) && !cachedReply && qualified.length === 0) {
|
|
991
|
+
const namesake = this.namesakeTarget(target);
|
|
992
|
+
throw new Error([
|
|
993
|
+
inReplyTo === undefined
|
|
994
|
+
? `exocom: unknown qualified target "${target}"`
|
|
995
|
+
: `exocom: unknown qualified reply target "${target}"`,
|
|
996
|
+
namesake ? ` — a live peer holds that string as its display name; address it as "${namesake}"` : "",
|
|
997
|
+
].join(""));
|
|
998
|
+
}
|
|
999
|
+
const entry = cachedReply
|
|
1000
|
+
?? qualified[0]
|
|
1001
|
+
?? this.listPeers().find((e) => e.displayName === target);
|
|
1002
|
+
if (!entry) throw new Error(`exocom: unknown peer "${target}"`);
|
|
1003
|
+
|
|
1004
|
+
const msg_id = randomUUID();
|
|
1005
|
+
const hops = inReplyTo !== undefined ? nextHops(this.inboundContext.get(inboundHopKey(entry.session_id, inReplyTo))?.hops ?? 0) : 0;
|
|
1006
|
+
const unsigned: ExocomMessage = {
|
|
1007
|
+
kind: "message", msg_id, from_session: identity.session_id, from_endpoint: identity.endpoint,
|
|
1008
|
+
from_name: this.name, text: this.payloadFor(msg_id, text), hops, ts: new Date(this.now()).toISOString(),
|
|
1009
|
+
...(inReplyTo !== undefined ? { in_reply_to: inReplyTo } : {}),
|
|
1010
|
+
};
|
|
1011
|
+
const message = this.signFrame(unsigned);
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
await sendFrame(this.netImpl, entry.endpoint, message, this.ackTimeoutMs, entry);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
// A frozen-but-registered peer (accepted the connection, never acked) is left for
|
|
1017
|
+
// the normal heartbeat/stale prune to evict — a single slow ack doesn't warrant
|
|
1018
|
+
// mutating the registry here.
|
|
1019
|
+
if (err instanceof PeerNackError) { this.removeArtifact(msg_id); throw new ExocomPeerRejection(target, err.message); }
|
|
1020
|
+
if (err instanceof AckTimeoutError) { this.removeArtifact(msg_id); throw new Error(`exocom: ack timeout from "${target}"`); }
|
|
1021
|
+
if (err instanceof PeerAuthenticationError || err instanceof PeerProtocolError) {
|
|
1022
|
+
this.removeArtifact(msg_id);
|
|
1023
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1024
|
+
throw new Error(`${err.message} from "${target}"`);
|
|
1025
|
+
}
|
|
1026
|
+
if (!isRestartingError(err)) {
|
|
1027
|
+
this.removeArtifact(msg_id);
|
|
1028
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1029
|
+
throw new Error(`exocom: peer "${target}" unreachable`);
|
|
1030
|
+
}
|
|
1031
|
+
await delay(RECONNECT_DELAY_MS);
|
|
1032
|
+
try {
|
|
1033
|
+
await sendFrame(this.netImpl, entry.endpoint, message, this.ackTimeoutMs, entry);
|
|
1034
|
+
} catch (err2) {
|
|
1035
|
+
this.removeArtifact(msg_id);
|
|
1036
|
+
if (err2 instanceof PeerNackError) throw new ExocomPeerRejection(target, err2.message);
|
|
1037
|
+
if (err2 instanceof AckTimeoutError) throw new Error(`exocom: ack timeout from "${target}"`);
|
|
1038
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1039
|
+
if (err2 instanceof PeerAuthenticationError || err2 instanceof PeerProtocolError) throw new Error(`${err2.message} from "${target}"`);
|
|
1040
|
+
throw new Error(`exocom: peer "${target}" unreachable`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Delivered: the spill file now belongs to the RECEIVER's later turn (EXOCOM.ARTIFACT_TTL_MS),
|
|
1044
|
+
// so drop our claim on it — `stop()` must never reap a handed-off artifact, and the TTL /
|
|
1045
|
+
// max-files sweep in `cleanupArtifacts` is what reclaims it.
|
|
1046
|
+
this.artifacts.delete(msg_id);
|
|
1047
|
+
this.sentTo.set(entry.session_id, (this.sentTo.get(entry.session_id) ?? 0) + 1);
|
|
1048
|
+
return { msg_id };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Point-to-point send of a semantic frame. Transport ACK still means bytes received (and ledger-accepted if the peer applied it). */
|
|
1052
|
+
async sendSemantic(target: string, unsigned: ExocomSemanticFrame): Promise<{ msg_id: string }> {
|
|
1053
|
+
const { agentDir, hash } = this.deps;
|
|
1054
|
+
const entry = this.resolvePeer(target);
|
|
1055
|
+
const frame = this.signFrame(unsigned);
|
|
1056
|
+
try {
|
|
1057
|
+
await sendFrame(this.netImpl, entry.endpoint, frame, this.ackTimeoutMs, entry);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
if (err instanceof PeerNackError) throw new ExocomPeerRejection(target, err.message);
|
|
1060
|
+
if (err instanceof AckTimeoutError) throw new Error(`exocom: ack timeout from "${target}"`);
|
|
1061
|
+
if (err instanceof PeerAuthenticationError || err instanceof PeerProtocolError) {
|
|
1062
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1063
|
+
throw new Error(`${err.message} from "${target}"`);
|
|
1064
|
+
}
|
|
1065
|
+
if (!isRestartingError(err)) {
|
|
1066
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1067
|
+
throw new Error(`exocom: peer "${target}" unreachable`);
|
|
1068
|
+
}
|
|
1069
|
+
await delay(RECONNECT_DELAY_MS);
|
|
1070
|
+
try {
|
|
1071
|
+
await sendFrame(this.netImpl, entry.endpoint, frame, this.ackTimeoutMs, entry);
|
|
1072
|
+
} catch (err2) {
|
|
1073
|
+
if (err2 instanceof PeerNackError) throw new ExocomPeerRejection(target, err2.message);
|
|
1074
|
+
if (err2 instanceof AckTimeoutError) throw new Error(`exocom: ack timeout from "${target}"`);
|
|
1075
|
+
removeEntryIfMatches(agentDir, hash, entry);
|
|
1076
|
+
if (err2 instanceof PeerAuthenticationError || err2 instanceof PeerProtocolError) throw new Error(`${err2.message} from "${target}"`);
|
|
1077
|
+
throw new Error(`exocom: peer "${target}" unreachable`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
this.sentTo.set(entry.session_id, (this.sentTo.get(entry.session_id) ?? 0) + 1);
|
|
1081
|
+
return { msg_id: frame.msg_id };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async stop(): Promise<void> {
|
|
1085
|
+
|
|
1086
|
+
const { agentDir, hash, identity } = this.deps;
|
|
1087
|
+
const bye: ExocomBye = this.signFrame({ kind: "bye", from_session: identity.session_id, from_endpoint: identity.endpoint });
|
|
1088
|
+
try {
|
|
1089
|
+
await Promise.all(this.listPeers().map((p) => sendNoReply(this.netImpl, p.endpoint, bye)));
|
|
1090
|
+
} catch { /* shutdown remains best-effort */ }
|
|
1091
|
+
for (const s of this.sockets) {
|
|
1092
|
+
try { s.destroy(); } catch { /* ignore */ }
|
|
1093
|
+
}
|
|
1094
|
+
this.sockets.clear();
|
|
1095
|
+
const server = this.server;
|
|
1096
|
+
this.server = undefined;
|
|
1097
|
+
if (server) {
|
|
1098
|
+
await closeServer(server);
|
|
1099
|
+
}
|
|
1100
|
+
if (this.endpointOwned) {
|
|
1101
|
+
try { this.unlinkEndpoint(); } catch { /* ignore */ }
|
|
1102
|
+
this.endpointOwned = false;
|
|
1103
|
+
}
|
|
1104
|
+
const ownership = this.registeredOwnership;
|
|
1105
|
+
if (ownership) {
|
|
1106
|
+
removeEntryIfMatches(agentDir, hash, ownership);
|
|
1107
|
+
this.registeredOwnership = undefined;
|
|
1108
|
+
}
|
|
1109
|
+
for (const msgId of [...this.artifacts.keys()]) this.removeArtifact(msgId); // only spills whose send never landed are still tracked here
|
|
1110
|
+
// A DELIVERED spill is deliberately left for its receiver's later turn, so its reclamation
|
|
1111
|
+
// falls to the TTL/max-files sweep — which otherwise runs only on a plane start or the next
|
|
1112
|
+
// spill. In a scope where Exocom is never enabled again, that is never: sweeping on the
|
|
1113
|
+
// way out bounds the retention without touching anything still inside its TTL.
|
|
1114
|
+
this.cleanupArtifacts();
|
|
1115
|
+
}
|
|
1116
|
+
}
|