agent-embassy 3.0.0 → 4.0.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 +42 -0
- package/CONTRIBUTING.md +92 -200
- package/README.md +159 -138
- package/SECURITY.md +191 -335
- package/dist/src/gateway/broker-check.d.ts +26 -0
- package/dist/src/gateway/broker-check.js +89 -0
- package/dist/src/gateway/broker-check.js.map +1 -0
- package/dist/src/gateway/broker-control.d.ts +72 -0
- package/dist/src/gateway/broker-control.js +164 -0
- package/dist/src/gateway/broker-control.js.map +1 -0
- package/dist/src/gateway/broker.d.ts +117 -0
- package/dist/src/gateway/broker.js +186 -0
- package/dist/src/gateway/broker.js.map +1 -0
- package/dist/src/gateway/claude-peer.d.ts +1 -124
- package/dist/src/gateway/claude-peer.js +62 -1053
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/codex-stateless-transport.d.ts +0 -12
- package/dist/src/gateway/codex-stateless-transport.js +8 -142
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +2 -6
- package/dist/src/gateway/config.js +17 -27
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/coordinator.d.ts +57 -0
- package/dist/src/gateway/coordinator.js +217 -0
- package/dist/src/gateway/coordinator.js.map +1 -0
- package/dist/src/gateway/core-cli.d.ts +15 -0
- package/dist/src/gateway/core-cli.js +282 -0
- package/dist/src/gateway/core-cli.js.map +1 -0
- package/dist/src/gateway/core-service-command.d.ts +16 -0
- package/dist/src/gateway/core-service-command.js +71 -0
- package/dist/src/gateway/core-service-command.js.map +1 -0
- package/dist/src/gateway/endpoint-directory.d.ts +33 -0
- package/dist/src/gateway/endpoint-directory.js +223 -0
- package/dist/src/gateway/endpoint-directory.js.map +1 -0
- package/dist/src/gateway/federation.d.ts +89 -0
- package/dist/src/gateway/federation.js +632 -0
- package/dist/src/gateway/federation.js.map +1 -0
- package/dist/src/gateway/ledger-codec.d.ts +3 -0
- package/dist/src/gateway/ledger-codec.js +159 -0
- package/dist/src/gateway/ledger-codec.js.map +1 -0
- package/dist/src/gateway/ledger.d.ts +138 -0
- package/dist/src/gateway/ledger.js +319 -0
- package/dist/src/gateway/ledger.js.map +1 -0
- package/dist/src/gateway/local-control.d.ts +23 -0
- package/dist/src/gateway/local-control.js +489 -0
- package/dist/src/gateway/local-control.js.map +1 -0
- package/dist/src/gateway/native-destinations.d.ts +34 -0
- package/dist/src/gateway/native-destinations.js +272 -0
- package/dist/src/gateway/native-destinations.js.map +1 -0
- package/dist/src/gateway/owned-state.d.ts +57 -0
- package/dist/src/gateway/owned-state.js +398 -0
- package/dist/src/gateway/owned-state.js.map +1 -0
- package/dist/src/gateway/provenance-envelope.d.ts +3 -3
- package/dist/src/gateway/provenance-envelope.js +3 -23
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/runtime.d.ts +41 -0
- package/dist/src/gateway/runtime.js +175 -0
- package/dist/src/gateway/runtime.js.map +1 -0
- package/docs/CONFIGURATION.md +178 -266
- package/docs/DELIVERY.md +133 -82
- package/docs/GATEWAY-ARCHITECTURE.md +276 -943
- package/package.json +5 -5
- package/skills/embassy-peer/SKILL.md +38 -194
- package/skills/embassy-peer/agents/openai.yaml +2 -2
- package/dist/src/gateway/claude-helper-client.d.ts +0 -2
- package/dist/src/gateway/claude-helper-client.js +0 -2
- package/dist/src/gateway/claude-helper-client.js.map +0 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +0 -117
- package/dist/src/gateway/claude-helper-protocol.js +0 -122
- package/dist/src/gateway/claude-helper-protocol.js.map +0 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +0 -87
- package/dist/src/gateway/claude-helper-supervisor.js +0 -347
- package/dist/src/gateway/claude-helper-supervisor.js.map +0 -1
- package/dist/src/gateway/claude-helper.d.ts +0 -1
- package/dist/src/gateway/claude-helper.js +0 -252
- package/dist/src/gateway/claude-helper.js.map +0 -1
- package/dist/src/gateway/cli.d.ts +0 -57
- package/dist/src/gateway/cli.js +0 -1503
- package/dist/src/gateway/cli.js.map +0 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +0 -26
- package/dist/src/gateway/codex-socket-holder.js +0 -76
- package/dist/src/gateway/codex-socket-holder.js.map +0 -1
- package/dist/src/gateway/control.d.ts +0 -261
- package/dist/src/gateway/control.js +0 -903
- package/dist/src/gateway/control.js.map +0 -1
- package/dist/src/gateway/peer-client.d.ts +0 -58
- package/dist/src/gateway/peer-client.js +0 -217
- package/dist/src/gateway/peer-client.js.map +0 -1
- package/dist/src/gateway/peer-mailbox.d.ts +0 -78
- package/dist/src/gateway/peer-mailbox.js +0 -168
- package/dist/src/gateway/peer-mailbox.js.map +0 -1
- package/dist/src/gateway/peer-protocol.d.ts +0 -105
- package/dist/src/gateway/peer-protocol.js +0 -70
- package/dist/src/gateway/peer-protocol.js.map +0 -1
- package/dist/src/gateway/peer-stdio.d.ts +0 -23
- package/dist/src/gateway/peer-stdio.js +0 -100
- package/dist/src/gateway/peer-stdio.js.map +0 -1
- package/dist/src/gateway/providers.d.ts +0 -163
- package/dist/src/gateway/providers.js +0 -972
- package/dist/src/gateway/providers.js.map +0 -1
- package/dist/src/gateway/server.d.ts +0 -47
- package/dist/src/gateway/server.js +0 -219
- package/dist/src/gateway/server.js.map +0 -1
- package/dist/src/gateway/service.d.ts +0 -373
- package/dist/src/gateway/service.js +0 -2274
- package/dist/src/gateway/service.js.map +0 -1
- package/dist/src/gateway/status-view.d.ts +0 -167
- package/dist/src/gateway/status-view.js +0 -488
- package/dist/src/gateway/status-view.js.map +0 -1
- package/dist/src/gateway/store.d.ts +0 -185
- package/dist/src/gateway/store.js +0 -2359
- package/dist/src/gateway/store.js.map +0 -1
- package/dist/src/gateway/types.d.ts +0 -528
- package/dist/src/gateway/types.js +0 -341
- package/dist/src/gateway/types.js.map +0 -1
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { BridgeError } from "../errors.js";
|
|
5
|
+
export const FEDERATION_PROTOCOL_VERSION = 3;
|
|
6
|
+
const MAX_FRAME_BYTES = 256 * 1024;
|
|
7
|
+
const MAX_ENDPOINTS = 128;
|
|
8
|
+
const MAX_MESSAGES = 128;
|
|
9
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
10
|
+
const MAX_BATCH_BYTES = 128 * 1024;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
|
+
const METHOD_NOT_FOUND = -32601;
|
|
13
|
+
const INVALID_PARAMS = -32602;
|
|
14
|
+
const INTERNAL_ERROR = -32603;
|
|
15
|
+
const SAFE_REFUSALS = new Set([
|
|
16
|
+
"INVALID_PEER_HANDOFF", "ROUTE_UNREGISTERED", "ROUTE_BINDING_MISMATCH",
|
|
17
|
+
"INVALID_MESSAGE_BODY", "MESSAGE_EXPIRED", "RATE_LIMITED", "QUEUE_FULL",
|
|
18
|
+
"MESSAGE_TOO_LARGE", "INVALID_DEADLINE", "GATEWAY_RATE_LIMITED", "GATEWAY_QUEUE_FULL",
|
|
19
|
+
]);
|
|
20
|
+
class FederationTransportError extends Error {
|
|
21
|
+
constructor(message = "Federation transport lost") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "FederationTransportError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
class FederationInputError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "FederationInputError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
const exact = (value, keys) => object(value) && Object.keys(value).length === keys.length &&
|
|
34
|
+
keys.every((key) => Object.hasOwn(value, key));
|
|
35
|
+
const host = (value) => typeof value === "string" &&
|
|
36
|
+
/^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/.test(value);
|
|
37
|
+
const alias = (value) => typeof value === "string" &&
|
|
38
|
+
/^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/.test(value);
|
|
39
|
+
const token = (value, prefix) => typeof value === "string" &&
|
|
40
|
+
value.startsWith(prefix) && value.length > prefix.length && value.length <= 256 &&
|
|
41
|
+
/^[A-Za-z0-9_-]+$/.test(value.slice(prefix.length));
|
|
42
|
+
const endpoint = (value) => exact(value, ["id", "host", "provider", "alias"]) &&
|
|
43
|
+
token(value.id, "reg_") && host(value.host) && (value.provider === "claude" || value.provider === "codex") &&
|
|
44
|
+
alias(value.alias) && value.alias.endsWith(`@${value.host}`);
|
|
45
|
+
const endpointRef = (value) => exact(value, ["id", "host", "provider"]) &&
|
|
46
|
+
token(value.id, "reg_") && host(value.host) && (value.provider === "claude" || value.provider === "codex");
|
|
47
|
+
const messageId = (value) => typeof value === "string" &&
|
|
48
|
+
/^msg_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
49
|
+
const conversationId = (value) => typeof value === "string" &&
|
|
50
|
+
/^conv_[A-Za-z0-9_-]{16,64}$/.test(value);
|
|
51
|
+
const sameRef = (left, right) => left.id === right.id && left.host === right.host && left.provider === right.provider;
|
|
52
|
+
const publicEndpoint = ({ id, host: hostId, provider, alias: name }) => ({ id, host: hostId, provider, alias: name });
|
|
53
|
+
const localEndpoint = (value) => ({ ...value, handle: value.id });
|
|
54
|
+
export function isFederatedHandoff(value) {
|
|
55
|
+
if (!exact(value, ["target", "messages"]) || !endpoint(value.target) ||
|
|
56
|
+
!Array.isArray(value.messages) || value.messages.length < 1 || value.messages.length > MAX_MESSAGES)
|
|
57
|
+
return false;
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
for (const item of value.messages) {
|
|
60
|
+
if (!exact(item, ["id", "reply", "source", "body", "deadline", "steer"]) ||
|
|
61
|
+
!messageId(item.id) || !conversationId(item.reply) || !endpoint(item.source) ||
|
|
62
|
+
typeof item.body !== "string" || item.body.length === 0 || item.body.includes("\0") ||
|
|
63
|
+
Buffer.byteLength(item.body) > MAX_BODY_BYTES || !Number.isSafeInteger(item.deadline) ||
|
|
64
|
+
Number(item.deadline) < 0 || typeof item.steer !== "boolean")
|
|
65
|
+
return false;
|
|
66
|
+
bytes += Buffer.byteLength(item.body);
|
|
67
|
+
}
|
|
68
|
+
return bytes <= MAX_BATCH_BYTES;
|
|
69
|
+
}
|
|
70
|
+
function encode(value) {
|
|
71
|
+
const frame = `${JSON.stringify(value)}\n`;
|
|
72
|
+
if (Buffer.byteLength(frame) > MAX_FRAME_BYTES)
|
|
73
|
+
throw new FederationTransportError("Federation frame too large");
|
|
74
|
+
return frame;
|
|
75
|
+
}
|
|
76
|
+
function decodeResult(method, value) {
|
|
77
|
+
if (method === "initialize") {
|
|
78
|
+
if (!exact(value, ["version", "host"]) || value.version !== FEDERATION_PROTOCOL_VERSION || !host(value.host))
|
|
79
|
+
throw new FederationTransportError("Federation protocol mismatch");
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
if (method === "resolve") {
|
|
83
|
+
if (exact(value, ["refused", "code"]) && value.refused === true &&
|
|
84
|
+
value.code === "PEER_ALIAS_COLLISION")
|
|
85
|
+
return value;
|
|
86
|
+
if (value !== null && !endpoint(value))
|
|
87
|
+
throw new FederationTransportError("Invalid resolve result");
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
if (method === "catalog") {
|
|
91
|
+
if (!Array.isArray(value) || value.length > MAX_ENDPOINTS || !value.every(endpoint))
|
|
92
|
+
throw new FederationTransportError("Invalid catalog result");
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
if (exact(value, ["accepted"]) && value.accepted === true)
|
|
96
|
+
return value;
|
|
97
|
+
if (exact(value, ["accepted", "code"]) && value.accepted === false &&
|
|
98
|
+
typeof value.code === "string" && SAFE_REFUSALS.has(value.code))
|
|
99
|
+
return value;
|
|
100
|
+
throw new FederationTransportError("Invalid handoff result");
|
|
101
|
+
}
|
|
102
|
+
class FederationConnection {
|
|
103
|
+
child;
|
|
104
|
+
timers;
|
|
105
|
+
pending = new Map();
|
|
106
|
+
nextId = 1;
|
|
107
|
+
buffer = Buffer.alloc(0);
|
|
108
|
+
writes = Promise.resolve();
|
|
109
|
+
closed = false;
|
|
110
|
+
constructor(child, timers) {
|
|
111
|
+
this.child = child;
|
|
112
|
+
this.timers = timers;
|
|
113
|
+
child.stderr.resume();
|
|
114
|
+
child.stdout.on("data", (chunk) => this.read(chunk));
|
|
115
|
+
child.once("error", () => this.fail(new FederationTransportError()));
|
|
116
|
+
child.once("exit", () => this.fail(new FederationTransportError()));
|
|
117
|
+
}
|
|
118
|
+
static async spawn(options) {
|
|
119
|
+
let child;
|
|
120
|
+
try {
|
|
121
|
+
child = (options.spawn ?? nodeSpawn)("/usr/bin/ssh", [
|
|
122
|
+
"-T", "-x", "-o", "BatchMode=yes", "-o", "ClearAllForwardings=yes",
|
|
123
|
+
"-o", "ForwardAgent=no", "-o", "PermitLocalCommand=no", "-o", "SendEnv=-*",
|
|
124
|
+
"-o", "Tunnel=no", options.node, "embassy", "peer-stdio",
|
|
125
|
+
], { env: cleanEnvironment(process.env), shell: false, stdio: ["pipe", "pipe", "pipe"] });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new FederationTransportError("Federation process could not be spawned");
|
|
129
|
+
}
|
|
130
|
+
const connection = new FederationConnection(child, options.timers ?? { setTimeout, clearTimeout });
|
|
131
|
+
try {
|
|
132
|
+
const initialized = await connection.request("initialize", {
|
|
133
|
+
version: FEDERATION_PROTOCOL_VERSION,
|
|
134
|
+
host: options.localHost,
|
|
135
|
+
});
|
|
136
|
+
if (initialized.host !== options.node)
|
|
137
|
+
throw new FederationTransportError("Federation host mismatch");
|
|
138
|
+
return connection;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
connection.close();
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
request(method, params) {
|
|
146
|
+
const id = this.nextId++;
|
|
147
|
+
return this.requestFrame(method, id, encode({ jsonrpc: "2.0", id, method, params }));
|
|
148
|
+
}
|
|
149
|
+
prepareHandoff(input) {
|
|
150
|
+
if (!isFederatedHandoff(input))
|
|
151
|
+
throw new FederationInputError("Invalid federation handoff");
|
|
152
|
+
if (this.closed)
|
|
153
|
+
throw new FederationTransportError();
|
|
154
|
+
const id = this.nextId++;
|
|
155
|
+
let frame;
|
|
156
|
+
try {
|
|
157
|
+
frame = encode({ jsonrpc: "2.0", id, method: "handoff", params: input });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
throw new FederationInputError("Federation handoff frame too large");
|
|
161
|
+
}
|
|
162
|
+
let state = "prepared";
|
|
163
|
+
return {
|
|
164
|
+
bytes: Buffer.byteLength(frame),
|
|
165
|
+
sha256: createHash("sha256").update(frame).digest("hex"),
|
|
166
|
+
cancel: () => { if (state === "prepared")
|
|
167
|
+
state = "cancelled"; },
|
|
168
|
+
perform: async () => {
|
|
169
|
+
if (state !== "prepared")
|
|
170
|
+
throw new FederationTransportError("Federation handoff already consumed");
|
|
171
|
+
state = "performed";
|
|
172
|
+
return await this.requestFrame("handoff", id, frame);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
close() {
|
|
177
|
+
this.fail(new FederationTransportError());
|
|
178
|
+
}
|
|
179
|
+
requestFrame(method, id, frame) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
return Promise.reject(new FederationTransportError());
|
|
182
|
+
if (this.pending.size >= 32)
|
|
183
|
+
return Promise.reject(new FederationTransportError("Federation request capacity reached"));
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const timer = this.timers.setTimeout(() => {
|
|
186
|
+
this.pending.delete(id);
|
|
187
|
+
const error = new FederationTransportError("Federation request timed out");
|
|
188
|
+
this.fail(error);
|
|
189
|
+
reject(error);
|
|
190
|
+
}, REQUEST_TIMEOUT_MS);
|
|
191
|
+
timer.unref();
|
|
192
|
+
this.pending.set(id, { method, timer, resolve, reject });
|
|
193
|
+
void this.write(frame).catch((error) => {
|
|
194
|
+
const pending = this.pending.get(id);
|
|
195
|
+
if (pending)
|
|
196
|
+
this.timers.clearTimeout(pending.timer);
|
|
197
|
+
this.pending.delete(id);
|
|
198
|
+
this.fail(error);
|
|
199
|
+
reject(error);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
write(frame) {
|
|
204
|
+
const operation = this.writes.then(() => new Promise((resolve, reject) => {
|
|
205
|
+
if (this.closed)
|
|
206
|
+
return reject(new FederationTransportError());
|
|
207
|
+
this.child.stdin.write(frame, (error) => error ? reject(new FederationTransportError()) : resolve());
|
|
208
|
+
}));
|
|
209
|
+
this.writes = operation.catch(() => undefined);
|
|
210
|
+
return operation;
|
|
211
|
+
}
|
|
212
|
+
read(chunk) {
|
|
213
|
+
if (this.closed)
|
|
214
|
+
return;
|
|
215
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
216
|
+
for (;;) {
|
|
217
|
+
const newline = this.buffer.indexOf(0x0a);
|
|
218
|
+
if (newline < 0) {
|
|
219
|
+
if (this.buffer.length > MAX_FRAME_BYTES)
|
|
220
|
+
this.fail(new FederationTransportError("Federation response too large"));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const line = this.buffer.subarray(0, newline);
|
|
224
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
225
|
+
if (line.length > MAX_FRAME_BYTES)
|
|
226
|
+
return this.fail(new FederationTransportError("Federation response too large"));
|
|
227
|
+
let message;
|
|
228
|
+
try {
|
|
229
|
+
message = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return this.fail(new FederationTransportError("Federation returned malformed JSON"));
|
|
233
|
+
}
|
|
234
|
+
if (!object(message) || message.jsonrpc !== "2.0")
|
|
235
|
+
return this.fail(new FederationTransportError("Invalid federation response"));
|
|
236
|
+
if (typeof message.method === "string") {
|
|
237
|
+
if (!exact(message, ["jsonrpc", "id", "method", "params"]) ||
|
|
238
|
+
(typeof message.id !== "number" && typeof message.id !== "string"))
|
|
239
|
+
return this.fail(new FederationTransportError("Invalid federation request"));
|
|
240
|
+
void this.write(encode({ jsonrpc: "2.0", id: message.id,
|
|
241
|
+
error: { code: METHOD_NOT_FOUND, message: "Method not found" } })).catch((error) => this.fail(error));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if ((typeof message.id !== "number" && typeof message.id !== "string") ||
|
|
245
|
+
(Object.hasOwn(message, "result") === Object.hasOwn(message, "error")) || Object.keys(message).length !== 3)
|
|
246
|
+
return this.fail(new FederationTransportError("Invalid federation response"));
|
|
247
|
+
const pending = this.pending.get(message.id);
|
|
248
|
+
if (!pending)
|
|
249
|
+
return this.fail(new FederationTransportError("Uncorrelated federation response"));
|
|
250
|
+
this.pending.delete(message.id);
|
|
251
|
+
this.timers.clearTimeout(pending.timer);
|
|
252
|
+
if (Object.hasOwn(message, "error")) {
|
|
253
|
+
if (!exact(message.error, ["code", "message"]) ||
|
|
254
|
+
!Number.isSafeInteger(message.error.code) || typeof message.error.message !== "string" ||
|
|
255
|
+
message.error.message.length > 256) {
|
|
256
|
+
const error = new FederationTransportError("Invalid federation error response");
|
|
257
|
+
pending.reject(error);
|
|
258
|
+
this.fail(error);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
pending.reject(new FederationTransportError("Federation request refused"));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
pending.resolve(decodeResult(pending.method, message.result));
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
pending.reject(error);
|
|
269
|
+
this.fail(error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
fail(error) {
|
|
274
|
+
if (this.closed)
|
|
275
|
+
return;
|
|
276
|
+
this.closed = true;
|
|
277
|
+
for (const pending of this.pending.values()) {
|
|
278
|
+
this.timers.clearTimeout(pending.timer);
|
|
279
|
+
pending.reject(error);
|
|
280
|
+
}
|
|
281
|
+
this.pending.clear();
|
|
282
|
+
this.child.kill();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const cleanEnvironment = (env) => Object.fromEntries(["HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK"].flatMap((key) => env[key] === undefined ? [] : [[key, env[key]]]));
|
|
286
|
+
export class Federation {
|
|
287
|
+
options;
|
|
288
|
+
nodes;
|
|
289
|
+
connections = new Map();
|
|
290
|
+
catalogs = new Map();
|
|
291
|
+
catalogCounts = new Map();
|
|
292
|
+
observedAt = new Map();
|
|
293
|
+
failed = new Set();
|
|
294
|
+
now;
|
|
295
|
+
catalogTruncated = false;
|
|
296
|
+
closing = false;
|
|
297
|
+
constructor(options) {
|
|
298
|
+
this.options = options;
|
|
299
|
+
if (!host(options.host) || options.nodes.length > 32 ||
|
|
300
|
+
options.nodes.some((node) => !host(node) || node === options.host) ||
|
|
301
|
+
new Set(options.nodes).size !== options.nodes.length)
|
|
302
|
+
throw new TypeError("Invalid federation nodes");
|
|
303
|
+
this.nodes = new Set(options.nodes);
|
|
304
|
+
this.now = options.now ?? Date.now;
|
|
305
|
+
}
|
|
306
|
+
async named(name) {
|
|
307
|
+
if (!alias(name))
|
|
308
|
+
return [];
|
|
309
|
+
const hostId = name.slice(name.lastIndexOf("@") + 1);
|
|
310
|
+
if (!this.nodes.has(hostId))
|
|
311
|
+
return [];
|
|
312
|
+
const resolved = await this.resolveAt(hostId, name);
|
|
313
|
+
return resolved === undefined ? [] : [resolved];
|
|
314
|
+
}
|
|
315
|
+
async exact(identity) {
|
|
316
|
+
if (!endpointRef(identity) || !this.nodes.has(identity.host))
|
|
317
|
+
return undefined;
|
|
318
|
+
return await this.resolveAt(identity.host, identity);
|
|
319
|
+
}
|
|
320
|
+
async catalog() {
|
|
321
|
+
await Promise.all(this.options.nodes.map(async (node) => {
|
|
322
|
+
try {
|
|
323
|
+
const value = await (await this.connection(node)).request("catalog", {});
|
|
324
|
+
const rows = value;
|
|
325
|
+
if (rows.some((row) => row.host !== node) || new Set(rows.map((row) => row.id)).size !== rows.length)
|
|
326
|
+
throw new FederationTransportError("Non-local federation catalog");
|
|
327
|
+
this.catalogs.set(node, rows.map((row) => ({ ...row })).sort((left, right) => {
|
|
328
|
+
const a = `${left.alias}\0${left.provider}\0${left.id}`;
|
|
329
|
+
const b = `${right.alias}\0${right.provider}\0${right.id}`;
|
|
330
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
331
|
+
}));
|
|
332
|
+
this.catalogCounts.set(node, rows.length);
|
|
333
|
+
this.observedAt.set(node, new Date(this.now()).toISOString());
|
|
334
|
+
this.failed.delete(node);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
this.drop(node);
|
|
338
|
+
this.failed.add(node);
|
|
339
|
+
}
|
|
340
|
+
}));
|
|
341
|
+
this.compactCatalogs();
|
|
342
|
+
return this.options.nodes.flatMap((node) => (this.catalogs.get(node) ?? []).map((row) => ({ ...row })));
|
|
343
|
+
}
|
|
344
|
+
/** Last bounded display observation only. Reading it performs no network I/O;
|
|
345
|
+
* exact and named routing continue to use the authenticated owner RPC. */
|
|
346
|
+
snapshot() {
|
|
347
|
+
return {
|
|
348
|
+
nodes: this.options.nodes.map((node) => ({
|
|
349
|
+
host: node,
|
|
350
|
+
...(this.observedAt.has(node) ? { observedAt: this.observedAt.get(node) } : {}),
|
|
351
|
+
...(this.failed.has(node) ? { safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" } : {}),
|
|
352
|
+
routes: (this.catalogs.get(node) ?? []).map((row) => ({ ...row })),
|
|
353
|
+
})),
|
|
354
|
+
truncated: this.catalogTruncated,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
async deliver(input) {
|
|
358
|
+
if (this.closing || !this.nodes.has(input.target.host) || input.messages.length < 1)
|
|
359
|
+
return { outcome: "failed", code: "INVALID_PEER_HANDOFF" };
|
|
360
|
+
const target = publicEndpoint(input.target);
|
|
361
|
+
const value = {
|
|
362
|
+
target,
|
|
363
|
+
messages: input.messages.map(({ delivery, source }) => ({
|
|
364
|
+
id: delivery.id,
|
|
365
|
+
reply: delivery.reply,
|
|
366
|
+
source: publicEndpoint(source),
|
|
367
|
+
body: delivery.body,
|
|
368
|
+
deadline: delivery.deadline,
|
|
369
|
+
steer: delivery.steer,
|
|
370
|
+
})),
|
|
371
|
+
};
|
|
372
|
+
if (!isFederatedHandoff(value) || value.target.host !== input.target.host ||
|
|
373
|
+
value.messages.some((message, index) => message.source.host !== this.options.host ||
|
|
374
|
+
!sameRef(input.messages[index].delivery.target, input.target) ||
|
|
375
|
+
!sameRef(input.messages[index].delivery.source, message.source)))
|
|
376
|
+
return { outcome: "failed", code: "INVALID_PEER_HANDOFF" };
|
|
377
|
+
let connection;
|
|
378
|
+
let prepared;
|
|
379
|
+
try {
|
|
380
|
+
connection = await this.connection(input.target.host);
|
|
381
|
+
prepared = connection.prepareHandoff(value);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (error instanceof FederationInputError)
|
|
385
|
+
return { outcome: "failed", code: "INVALID_PEER_HANDOFF" };
|
|
386
|
+
this.drop(input.target.host);
|
|
387
|
+
return { outcome: "deferred", code: "PEER_TUNNEL_UNAVAILABLE" };
|
|
388
|
+
}
|
|
389
|
+
let authorized;
|
|
390
|
+
try {
|
|
391
|
+
authorized = await input.authorize({ bytes: prepared.bytes, sha256: prepared.sha256 });
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
prepared.cancel();
|
|
395
|
+
return { outcome: "ambiguous", code: "WRITE_AUTHORIZATION_UNCERTAIN" };
|
|
396
|
+
}
|
|
397
|
+
if (!authorized) {
|
|
398
|
+
prepared.cancel();
|
|
399
|
+
return { outcome: "deferred", code: "WRITE_AUTHORIZATION_DENIED" };
|
|
400
|
+
}
|
|
401
|
+
let result;
|
|
402
|
+
try {
|
|
403
|
+
result = await prepared.perform();
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
this.drop(input.target.host);
|
|
407
|
+
return { outcome: "ambiguous", code: "PEER_HANDOFF_OUTCOME_UNKNOWN" };
|
|
408
|
+
}
|
|
409
|
+
if (!result.accepted)
|
|
410
|
+
return { outcome: "failed", code: result.code };
|
|
411
|
+
try {
|
|
412
|
+
await input.accepted("unconfirmed");
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return { outcome: "unconfirmed", code: "ACCEPTANCE_UNCONFIRMED" };
|
|
416
|
+
}
|
|
417
|
+
return { outcome: "delivered", code: "PEER_HANDOFF_CONFIRMED" };
|
|
418
|
+
}
|
|
419
|
+
async close() {
|
|
420
|
+
if (this.closing)
|
|
421
|
+
return;
|
|
422
|
+
this.closing = true;
|
|
423
|
+
for (const connection of this.connections.values())
|
|
424
|
+
void connection.then((client) => client.close()).catch(() => undefined);
|
|
425
|
+
await Promise.allSettled(this.connections.values());
|
|
426
|
+
this.connections.clear();
|
|
427
|
+
this.catalogs.clear();
|
|
428
|
+
this.catalogCounts.clear();
|
|
429
|
+
this.observedAt.clear();
|
|
430
|
+
this.failed.clear();
|
|
431
|
+
this.catalogTruncated = false;
|
|
432
|
+
}
|
|
433
|
+
async resolveAt(node, selector) {
|
|
434
|
+
try {
|
|
435
|
+
const value = await (await this.connection(node)).request("resolve", { selector });
|
|
436
|
+
if (value === null)
|
|
437
|
+
return undefined;
|
|
438
|
+
if (exact(value, ["refused", "code"]) && value.refused === true &&
|
|
439
|
+
value.code === "PEER_ALIAS_COLLISION") {
|
|
440
|
+
throw new BridgeError("PEER_ALIAS_COLLISION", "The endpoint name is ambiguous.");
|
|
441
|
+
}
|
|
442
|
+
const resolved = value;
|
|
443
|
+
if (resolved.host !== node || (typeof selector === "string" ? resolved.alias !== selector : !sameRef(resolved, selector)))
|
|
444
|
+
throw new FederationTransportError("Federation resolved another endpoint");
|
|
445
|
+
return localEndpoint(resolved);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (error instanceof BridgeError && error.code === "PEER_ALIAS_COLLISION")
|
|
449
|
+
throw error;
|
|
450
|
+
this.drop(node);
|
|
451
|
+
throw new BridgeError("PEER_TUNNEL_UNAVAILABLE", "The owning gateway could not resolve the endpoint.", true);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
connection(node) {
|
|
455
|
+
if (this.closing || !this.nodes.has(node))
|
|
456
|
+
return Promise.reject(new FederationTransportError());
|
|
457
|
+
const existing = this.connections.get(node);
|
|
458
|
+
if (existing)
|
|
459
|
+
return existing;
|
|
460
|
+
const pending = FederationConnection.spawn({ node, localHost: this.options.host,
|
|
461
|
+
...(this.options.spawn === undefined ? {} : { spawn: this.options.spawn }) });
|
|
462
|
+
this.connections.set(node, pending);
|
|
463
|
+
void pending.catch(() => { if (this.connections.get(node) === pending)
|
|
464
|
+
this.connections.delete(node); });
|
|
465
|
+
return pending;
|
|
466
|
+
}
|
|
467
|
+
drop(node) {
|
|
468
|
+
const pending = this.connections.get(node);
|
|
469
|
+
this.connections.delete(node);
|
|
470
|
+
void pending?.then((connection) => connection.close()).catch(() => undefined);
|
|
471
|
+
}
|
|
472
|
+
compactCatalogs() {
|
|
473
|
+
let remaining = MAX_ENDPOINTS;
|
|
474
|
+
this.catalogTruncated = false;
|
|
475
|
+
for (const node of this.options.nodes) {
|
|
476
|
+
const rows = this.catalogs.get(node) ?? [];
|
|
477
|
+
const retained = rows.slice(0, remaining);
|
|
478
|
+
if ((this.catalogCounts.get(node) ?? rows.length) > retained.length)
|
|
479
|
+
this.catalogTruncated = true;
|
|
480
|
+
if (retained.length || this.catalogs.has(node))
|
|
481
|
+
this.catalogs.set(node, retained);
|
|
482
|
+
remaining -= retained.length;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
export function runFederationStdio(options) {
|
|
487
|
+
if (!host(options.host) || options.nodes.length > 32 ||
|
|
488
|
+
options.nodes.some((node) => !host(node) || node === options.host) ||
|
|
489
|
+
new Set(options.nodes).size !== options.nodes.length)
|
|
490
|
+
throw new TypeError("Invalid federation nodes");
|
|
491
|
+
const input = options.input ?? process.stdin;
|
|
492
|
+
const output = options.output ?? process.stdout;
|
|
493
|
+
const allowed = new Set(options.nodes);
|
|
494
|
+
let buffer = Buffer.alloc(0);
|
|
495
|
+
let peerHost;
|
|
496
|
+
let closed = false;
|
|
497
|
+
let operations = Promise.resolve();
|
|
498
|
+
let queued = 0;
|
|
499
|
+
let finish;
|
|
500
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
501
|
+
const close = () => {
|
|
502
|
+
if (closed)
|
|
503
|
+
return;
|
|
504
|
+
closed = true;
|
|
505
|
+
input.off("data", read);
|
|
506
|
+
input.off("end", end);
|
|
507
|
+
finish();
|
|
508
|
+
};
|
|
509
|
+
const write = (id, result, error) => {
|
|
510
|
+
if (closed)
|
|
511
|
+
return Promise.resolve();
|
|
512
|
+
let frame;
|
|
513
|
+
try {
|
|
514
|
+
frame = encode({ jsonrpc: "2.0", id, ...(error === undefined ? { result } : { error }) });
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
frame = encode({ jsonrpc: "2.0", id, error: { code: INTERNAL_ERROR, message: "Internal error" } });
|
|
518
|
+
}
|
|
519
|
+
return new Promise((resolve, reject) => output.write(frame, (fault) => fault ? reject(fault) : resolve()));
|
|
520
|
+
};
|
|
521
|
+
const dispatch = async (message) => {
|
|
522
|
+
if (closed)
|
|
523
|
+
return;
|
|
524
|
+
const id = message.id;
|
|
525
|
+
const method = message.method;
|
|
526
|
+
if (typeof method !== "string" || !["initialize", "resolve", "catalog", "handoff"].includes(method)) {
|
|
527
|
+
await write(id, undefined, { code: METHOD_NOT_FOUND, message: "Method not found" });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
if (method === "initialize") {
|
|
532
|
+
if (peerHost !== undefined || !exact(message.params, ["version", "host"]) ||
|
|
533
|
+
message.params.version !== FEDERATION_PROTOCOL_VERSION || !host(message.params.host) ||
|
|
534
|
+
!allowed.has(message.params.host))
|
|
535
|
+
throw new TypeError("invalid params");
|
|
536
|
+
peerHost = message.params.host;
|
|
537
|
+
await write(id, { version: FEDERATION_PROTOCOL_VERSION, host: options.host });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (peerHost === undefined)
|
|
541
|
+
throw new TypeError("invalid params");
|
|
542
|
+
if (method === "resolve") {
|
|
543
|
+
if (!exact(message.params, ["selector"]))
|
|
544
|
+
throw new TypeError("invalid params");
|
|
545
|
+
const selector = message.params.selector;
|
|
546
|
+
if (typeof selector !== "string" && !endpointRef(selector))
|
|
547
|
+
throw new TypeError("invalid params");
|
|
548
|
+
if (typeof selector === "string" ? !alias(selector) || !selector.endsWith(`@${options.host}`) : selector.host !== options.host)
|
|
549
|
+
throw new TypeError("invalid params");
|
|
550
|
+
let result;
|
|
551
|
+
try {
|
|
552
|
+
result = await options.handlers.resolve(peerHost, selector);
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error instanceof BridgeError && error.code === "PEER_ALIAS_COLLISION") {
|
|
556
|
+
await write(id, { refused: true, code: "PEER_ALIAS_COLLISION" });
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
if (result !== null && (!endpoint(result) || result.host !== options.host ||
|
|
562
|
+
(typeof selector === "string" ? result.alias !== selector : !sameRef(result, selector))))
|
|
563
|
+
throw new TypeError("invalid result");
|
|
564
|
+
await write(id, result);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (method === "catalog") {
|
|
568
|
+
if (!exact(message.params, []))
|
|
569
|
+
throw new TypeError("invalid params");
|
|
570
|
+
const result = await options.handlers.catalog(peerHost);
|
|
571
|
+
if (result.length > MAX_ENDPOINTS || result.some((row) => !endpoint(row) || row.host !== options.host))
|
|
572
|
+
throw new TypeError("invalid result");
|
|
573
|
+
await write(id, result);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!isFederatedHandoff(message.params) || message.params.target.host !== options.host ||
|
|
577
|
+
message.params.messages.some((item) => item.source.host !== peerHost))
|
|
578
|
+
throw new TypeError("invalid params");
|
|
579
|
+
const result = await options.handlers.handoff(peerHost, message.params);
|
|
580
|
+
if (!(exact(result, ["accepted"]) && result.accepted === true) &&
|
|
581
|
+
!(exact(result, ["accepted", "code"]) && result.accepted === false &&
|
|
582
|
+
typeof result.code === "string" && SAFE_REFUSALS.has(result.code)))
|
|
583
|
+
throw new TypeError("invalid result");
|
|
584
|
+
await write(id, result);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
await write(id, undefined, {
|
|
588
|
+
code: error instanceof TypeError ? INVALID_PARAMS : INTERNAL_ERROR,
|
|
589
|
+
message: error instanceof TypeError ? "Invalid params" : "Internal error",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
const read = (chunk) => {
|
|
594
|
+
if (closed)
|
|
595
|
+
return;
|
|
596
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
597
|
+
for (;;) {
|
|
598
|
+
const newline = buffer.indexOf(0x0a);
|
|
599
|
+
if (newline < 0) {
|
|
600
|
+
if (buffer.length > MAX_FRAME_BYTES)
|
|
601
|
+
close();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const line = buffer.subarray(0, newline);
|
|
605
|
+
buffer = buffer.subarray(newline + 1);
|
|
606
|
+
if (line.length > MAX_FRAME_BYTES)
|
|
607
|
+
return close();
|
|
608
|
+
if (++queued > 32)
|
|
609
|
+
return close();
|
|
610
|
+
let value;
|
|
611
|
+
try {
|
|
612
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line));
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
operations = operations.then(() => write(null, undefined, { code: -32700, message: "Parse error" })).catch(close).finally(() => { queued--; });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (!exact(value, ["jsonrpc", "id", "method", "params"]) || value.jsonrpc !== "2.0" ||
|
|
619
|
+
(typeof value.id !== "number" && typeof value.id !== "string") || typeof value.method !== "string") {
|
|
620
|
+
operations = operations.then(() => write(null, undefined, { code: -32600, message: "Invalid Request" })).catch(close).finally(() => { queued--; });
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
operations = operations.then(() => dispatch(value)).catch(close).finally(() => { queued--; });
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
const end = () => { void operations.then(close); };
|
|
627
|
+
input.on("data", read);
|
|
628
|
+
input.once("end", end);
|
|
629
|
+
input.once("error", close);
|
|
630
|
+
return { done, close };
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=federation.js.map
|