@scp-dev/broker 0.0.0-stage → 0.1.1
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/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/index.d.ts +422 -0
- package/dist/index.js +2514 -0
- package/dist/testing.d.ts +30 -0
- package/dist/testing.js +131 -0
- package/package.json +48 -4
- package/src/bridge.ts +642 -0
- package/src/client.ts +336 -0
- package/src/index.ts +6 -0
- package/src/judge-gate.ts +552 -0
- package/src/lock.ts +113 -0
- package/src/ndjson.ts +95 -0
- package/src/paths.ts +132 -0
- package/src/server.ts +1124 -0
- package/src/socket.ts +57 -0
- package/src/sqlite.ts +90 -0
- package/src/testing.ts +168 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2514 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import {
|
|
3
|
+
PROTOCOL,
|
|
4
|
+
RelayError
|
|
5
|
+
} from "@scp-dev/core";
|
|
6
|
+
import { existsSync, readFileSync as readFileSync3 } from "fs";
|
|
7
|
+
import { createConnection } from "net";
|
|
8
|
+
import { dirname, join as join2 } from "path";
|
|
9
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
10
|
+
|
|
11
|
+
// src/lock.ts
|
|
12
|
+
import { randomBytes } from "crypto";
|
|
13
|
+
import { closeSync, linkSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "fs";
|
|
14
|
+
import { uptime } from "os";
|
|
15
|
+
var BrokerRunning = class extends Error {
|
|
16
|
+
};
|
|
17
|
+
var ATTEMPTS = 8;
|
|
18
|
+
var BOOT_SLACK_S = 60;
|
|
19
|
+
function readLock(path) {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path, "utf8");
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code === "ENOENT") return void 0;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function holderAlive(content) {
|
|
28
|
+
const [pid, started] = content.split(":").map(Number);
|
|
29
|
+
if (pid === void 0 || !Number.isInteger(pid) || pid <= 0) return false;
|
|
30
|
+
if (started !== void 0 && Number.isFinite(started) && started < Date.now() / 1e3 - uptime() - BOOT_SLACK_S) return false;
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return error.code === "EPERM";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function clearStale(path, stale) {
|
|
39
|
+
const aside = `${path}.${process.pid}.${randomBytes(4).toString("hex")}`;
|
|
40
|
+
try {
|
|
41
|
+
renameSync(path, aside);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === "ENOENT") return;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
if (readLock(aside) !== stale) linkSync(aside, path);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(aside);
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var held = /* @__PURE__ */ new Set();
|
|
56
|
+
var exitHook = false;
|
|
57
|
+
function takeBrokerLock(path) {
|
|
58
|
+
const content = `${process.pid}:${Math.floor(Date.now() / 1e3)}`;
|
|
59
|
+
let holder;
|
|
60
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
61
|
+
let fd;
|
|
62
|
+
try {
|
|
63
|
+
fd = openSync(path, "wx", 384);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "EEXIST") throw error;
|
|
66
|
+
holder = readLock(path);
|
|
67
|
+
if (holder === void 0) continue;
|
|
68
|
+
if (holderAlive(holder)) break;
|
|
69
|
+
clearStale(path, holder);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
writeSync(fd, content, 0);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
unlinkSync(path);
|
|
76
|
+
throw error;
|
|
77
|
+
} finally {
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
const lock = { path, content };
|
|
81
|
+
held.add(lock);
|
|
82
|
+
if (!exitHook) {
|
|
83
|
+
exitHook = true;
|
|
84
|
+
process.on("exit", () => {
|
|
85
|
+
for (const lock2 of held) releaseBrokerLock(lock2);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return lock;
|
|
89
|
+
}
|
|
90
|
+
const pid = holder?.split(":")[0] ?? "";
|
|
91
|
+
throw new BrokerRunning(`a relay broker already runs${/^\d+$/.test(pid) ? ` (pid ${pid})` : ""}`);
|
|
92
|
+
}
|
|
93
|
+
function brokerLockHeld(path) {
|
|
94
|
+
try {
|
|
95
|
+
const content = readLock(path);
|
|
96
|
+
return content !== void 0 && holderAlive(content);
|
|
97
|
+
} catch {
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function releaseBrokerLock(lock) {
|
|
102
|
+
if (!held.delete(lock)) return;
|
|
103
|
+
try {
|
|
104
|
+
if (readLock(lock.path) === lock.content) unlinkSync(lock.path);
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/ndjson.ts
|
|
110
|
+
var MAX_LINE = 256 * 1024;
|
|
111
|
+
var NEWLINE = 10;
|
|
112
|
+
var decoder = new TextDecoder();
|
|
113
|
+
var encoder = new TextEncoder();
|
|
114
|
+
var LineReader = class {
|
|
115
|
+
constructor(maxBytes) {
|
|
116
|
+
this.maxBytes = maxBytes;
|
|
117
|
+
}
|
|
118
|
+
maxBytes;
|
|
119
|
+
pending = Buffer.alloc(0);
|
|
120
|
+
/** bytes of a line that has not ended yet */
|
|
121
|
+
get buffered() {
|
|
122
|
+
return this.pending.length;
|
|
123
|
+
}
|
|
124
|
+
/** the complete lines so far; `tooLong` once a line passes the cap (lines before it still count) */
|
|
125
|
+
push(chunk) {
|
|
126
|
+
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : Buffer.from(chunk);
|
|
127
|
+
const lines = [];
|
|
128
|
+
for (let newline = this.pending.indexOf(NEWLINE); newline !== -1; newline = this.pending.indexOf(NEWLINE)) {
|
|
129
|
+
if (newline > this.maxBytes) return { lines, tooLong: true };
|
|
130
|
+
const line = decoder.decode(this.pending.subarray(0, newline));
|
|
131
|
+
this.pending = this.pending.subarray(newline + 1);
|
|
132
|
+
if (line.trim()) lines.push(line);
|
|
133
|
+
}
|
|
134
|
+
return { lines, tooLong: this.pending.length > this.maxBytes };
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var BATCH = 64 * 1024;
|
|
138
|
+
var LineWriter = class _LineWriter {
|
|
139
|
+
chunks = [];
|
|
140
|
+
static EMPTY = new Uint8Array(0);
|
|
141
|
+
head = 0;
|
|
142
|
+
queued = 0;
|
|
143
|
+
/** bytes waiting for the socket */
|
|
144
|
+
get pending() {
|
|
145
|
+
return this.queued;
|
|
146
|
+
}
|
|
147
|
+
push(value) {
|
|
148
|
+
this.pushBytes(encodeLine(value));
|
|
149
|
+
}
|
|
150
|
+
pushBytes(line) {
|
|
151
|
+
this.chunks.push(line);
|
|
152
|
+
this.queued += line.length;
|
|
153
|
+
}
|
|
154
|
+
flush(write) {
|
|
155
|
+
while (this.queued) {
|
|
156
|
+
const first = this.chunks[this.head];
|
|
157
|
+
let batch = first;
|
|
158
|
+
if (first.length < BATCH && this.head + 1 < this.chunks.length) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
let size = 0;
|
|
161
|
+
for (let i = this.head; i < this.chunks.length && size < BATCH; i++) {
|
|
162
|
+
parts.push(this.chunks[i]);
|
|
163
|
+
size += this.chunks[i].length;
|
|
164
|
+
}
|
|
165
|
+
batch = Buffer.concat(parts, size);
|
|
166
|
+
}
|
|
167
|
+
const written = write(batch);
|
|
168
|
+
if (written <= 0) return;
|
|
169
|
+
this.consume(written);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
consume(bytes) {
|
|
173
|
+
this.queued -= bytes;
|
|
174
|
+
while (bytes > 0) {
|
|
175
|
+
const first = this.chunks[this.head];
|
|
176
|
+
if (bytes < first.length) {
|
|
177
|
+
this.chunks[this.head] = first.subarray(bytes);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
bytes -= first.length;
|
|
181
|
+
this.chunks[this.head++] = _LineWriter.EMPTY;
|
|
182
|
+
}
|
|
183
|
+
if (this.head > 1024 || this.head === this.chunks.length) {
|
|
184
|
+
this.chunks = this.chunks.slice(this.head);
|
|
185
|
+
this.head = 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function encodeLine(value) {
|
|
190
|
+
return encoder.encode(`${JSON.stringify(value)}
|
|
191
|
+
`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/paths.ts
|
|
195
|
+
import { spawnSync } from "child_process";
|
|
196
|
+
import { createHash } from "crypto";
|
|
197
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync as readFileSync2 } from "fs";
|
|
198
|
+
import { homedir, hostname, userInfo } from "os";
|
|
199
|
+
import { join } from "path";
|
|
200
|
+
var runQuiet = (cmd) => {
|
|
201
|
+
const result = spawnSync(cmd[0], cmd.slice(1), { stdio: ["ignore", "pipe", "ignore"], timeout: 2e3 });
|
|
202
|
+
return { exitCode: result.status, stdout: result.stdout ?? "" };
|
|
203
|
+
};
|
|
204
|
+
function localHostName(run = runQuiet) {
|
|
205
|
+
try {
|
|
206
|
+
const result = run(["scutil", "--get", "LocalHostName"]);
|
|
207
|
+
return result.exitCode === 0 && result.stdout.toString().trim() || void 0;
|
|
208
|
+
} catch {
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function systemHostname(platform = process.platform, local = localHostName, fallback = hostname) {
|
|
213
|
+
return platform === "darwin" && local() || fallback();
|
|
214
|
+
}
|
|
215
|
+
var host;
|
|
216
|
+
function thisHostname() {
|
|
217
|
+
host ??= systemHostname();
|
|
218
|
+
return host;
|
|
219
|
+
}
|
|
220
|
+
function shortHostname(name = thisHostname()) {
|
|
221
|
+
return (name.split(".")[0] ?? "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "unknown";
|
|
222
|
+
}
|
|
223
|
+
function relayDir(env = process.env) {
|
|
224
|
+
if (env.SCP_DIR) return env.SCP_DIR;
|
|
225
|
+
return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), ".local", "state"), "scp");
|
|
226
|
+
}
|
|
227
|
+
function policyPath() {
|
|
228
|
+
return join(accountHome(), ".config", "scp", "policy.json");
|
|
229
|
+
}
|
|
230
|
+
var home;
|
|
231
|
+
var verified = false;
|
|
232
|
+
function accountHomeVerified() {
|
|
233
|
+
accountHome();
|
|
234
|
+
return verified;
|
|
235
|
+
}
|
|
236
|
+
function userDatabaseHome(uid, options = {}) {
|
|
237
|
+
const platform = options.platform ?? process.platform;
|
|
238
|
+
if (!(options.bun ?? typeof process.versions.bun === "string")) return userInfo().homedir || void 0;
|
|
239
|
+
const run = options.run ?? runQuiet;
|
|
240
|
+
if (platform === "darwin") {
|
|
241
|
+
const result2 = run(["dscacheutil", "-q", "user", "-a", "uid", String(uid)]);
|
|
242
|
+
return result2.exitCode === 0 && /^dir: (.+)$/m.exec(result2.stdout.toString())?.[1]?.trim() || void 0;
|
|
243
|
+
}
|
|
244
|
+
const entry = (text) => text.split("\n").find((line2) => line2.split(":")[2] === String(uid));
|
|
245
|
+
const result = run(["getent", "passwd", String(uid)]);
|
|
246
|
+
let line = result.exitCode === 0 ? entry(result.stdout.toString()) : void 0;
|
|
247
|
+
if (!line) {
|
|
248
|
+
try {
|
|
249
|
+
line = entry((options.passwd ?? (() => readFileSync2("/etc/passwd", "utf8")))());
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return line?.split(":")[5] || void 0;
|
|
254
|
+
}
|
|
255
|
+
function accountHome() {
|
|
256
|
+
if (home) return home;
|
|
257
|
+
try {
|
|
258
|
+
if (typeof process.getuid === "function") home = userDatabaseHome(process.getuid());
|
|
259
|
+
verified = Boolean(home);
|
|
260
|
+
} catch {
|
|
261
|
+
}
|
|
262
|
+
home ||= homedir();
|
|
263
|
+
return home;
|
|
264
|
+
}
|
|
265
|
+
var SUN_PATH_MAX = 100;
|
|
266
|
+
function privateDir(path) {
|
|
267
|
+
mkdirSync(path, { recursive: true, mode: 448 });
|
|
268
|
+
const stat = lstatSync(path);
|
|
269
|
+
if (!stat.isDirectory() || typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
270
|
+
throw new Error(`${path} is not a directory this user owns`);
|
|
271
|
+
}
|
|
272
|
+
chmodSync(path, 448);
|
|
273
|
+
return path;
|
|
274
|
+
}
|
|
275
|
+
function ownPrivateDir(path) {
|
|
276
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
277
|
+
return !!stat && stat.isDirectory() && (typeof process.getuid !== "function" || stat.uid === process.getuid()) && (stat.mode & 18) === 0;
|
|
278
|
+
}
|
|
279
|
+
function socketPath(dir) {
|
|
280
|
+
const inside = join(dir, "relay.sock");
|
|
281
|
+
if (Buffer.byteLength(inside) <= SUN_PATH_MAX) return inside;
|
|
282
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
283
|
+
const key = createHash("sha256").update(dir).digest("hex").slice(0, 12);
|
|
284
|
+
return join("/tmp", `scp-${uid}-${key}`, "relay.sock");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/socket.ts
|
|
288
|
+
var END_GRACE_MS = 2e3;
|
|
289
|
+
var Conn = class {
|
|
290
|
+
constructor(socket, data, handlers) {
|
|
291
|
+
this.socket = socket;
|
|
292
|
+
this.data = data;
|
|
293
|
+
socket.on("data", (chunk) => {
|
|
294
|
+
if (!this.ending) handlers.data(this, chunk);
|
|
295
|
+
});
|
|
296
|
+
socket.on("drain", () => handlers.drain(this));
|
|
297
|
+
socket.on("error", (error) => handlers.close(this, error));
|
|
298
|
+
socket.on("close", () => handlers.close(this));
|
|
299
|
+
}
|
|
300
|
+
socket;
|
|
301
|
+
data;
|
|
302
|
+
ending = false;
|
|
303
|
+
/** bytes the socket took but has not sent yet */
|
|
304
|
+
get unsent() {
|
|
305
|
+
return this.socket.writableLength;
|
|
306
|
+
}
|
|
307
|
+
/** takes all of `bytes`, or none while the socket waits to drain */
|
|
308
|
+
write(bytes) {
|
|
309
|
+
if (this.ending || this.socket.destroyed || this.socket.writableNeedDrain) return 0;
|
|
310
|
+
this.socket.write(bytes);
|
|
311
|
+
return bytes.length;
|
|
312
|
+
}
|
|
313
|
+
/** sends what was written, then closes once the peer does, or after a grace period; what the peer still sends is discarded */
|
|
314
|
+
end() {
|
|
315
|
+
if (this.ending || this.socket.destroyed) return;
|
|
316
|
+
this.ending = true;
|
|
317
|
+
this.socket.resume();
|
|
318
|
+
this.socket.end();
|
|
319
|
+
setTimeout(() => this.socket.destroy(), END_GRACE_MS).unref();
|
|
320
|
+
}
|
|
321
|
+
pause() {
|
|
322
|
+
if (!this.ending) this.socket.pause();
|
|
323
|
+
}
|
|
324
|
+
resume() {
|
|
325
|
+
this.socket.resume();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/client.ts
|
|
330
|
+
var RelayClient = class _RelayClient {
|
|
331
|
+
nextId = 1;
|
|
332
|
+
pending = /* @__PURE__ */ new Map();
|
|
333
|
+
reader = new LineReader(64 * 1024 * 1024);
|
|
334
|
+
writer = new LineWriter();
|
|
335
|
+
socket;
|
|
336
|
+
closedError;
|
|
337
|
+
listeners = [];
|
|
338
|
+
static connect(path) {
|
|
339
|
+
const client = new _RelayClient();
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
const socket = createConnection({ path });
|
|
342
|
+
socket.once("error", reject);
|
|
343
|
+
socket.once("connect", () => {
|
|
344
|
+
socket.off("error", reject);
|
|
345
|
+
client.socket = new Conn(socket, void 0, {
|
|
346
|
+
data: (_s, chunk) => client.onData(chunk),
|
|
347
|
+
drain: () => client.flush(),
|
|
348
|
+
close: (_s, error) => client.onClose(error ?? new Error("the relay broker closed the connection"))
|
|
349
|
+
});
|
|
350
|
+
resolve(client);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
onNotification(fn) {
|
|
355
|
+
this.listeners.push(fn);
|
|
356
|
+
}
|
|
357
|
+
request(method, params = {}) {
|
|
358
|
+
if (this.closedError) return Promise.reject(this.closedError);
|
|
359
|
+
const id = this.nextId++;
|
|
360
|
+
const line = encodeLine({ jsonrpc: "2.0", id, method, params });
|
|
361
|
+
const bytes = line.length - 1;
|
|
362
|
+
if (bytes > MAX_LINE) {
|
|
363
|
+
return Promise.reject(
|
|
364
|
+
new RelayError(
|
|
365
|
+
"too_large",
|
|
366
|
+
`the request is ${bytes} bytes as JSON, over the broker's ${MAX_LINE} byte line limit (control characters count 6 bytes each once escaped)`
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
this.pending.set(id, { resolve, reject });
|
|
372
|
+
this.writer.pushBytes(line);
|
|
373
|
+
this.flush();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
hello(hello) {
|
|
377
|
+
return this.request("hello", { protocol: PROTOCOL, capabilities: [], ...hello });
|
|
378
|
+
}
|
|
379
|
+
rules(endpoint) {
|
|
380
|
+
return this.request("rules", endpoint ? { endpoint } : {});
|
|
381
|
+
}
|
|
382
|
+
rulesSet(params) {
|
|
383
|
+
return this.request("rules_set", params);
|
|
384
|
+
}
|
|
385
|
+
rulesWiden(params) {
|
|
386
|
+
return this.request("rules_widen", params);
|
|
387
|
+
}
|
|
388
|
+
rulesLift(params) {
|
|
389
|
+
return this.request("rules_lift", params);
|
|
390
|
+
}
|
|
391
|
+
/** a `krt_` grant for a spawn: `env` holds SCP_GRANT, which is a secret */
|
|
392
|
+
grant(params) {
|
|
393
|
+
return this.request("grant", params);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* the harness a spawn ran with `grant` exited: its endpoint goes done and offline, or a grant no one spent is revoked, and the spawner gets
|
|
397
|
+
* `output` back in the handback message, unless the harness never `started`
|
|
398
|
+
*/
|
|
399
|
+
spawnExit(params) {
|
|
400
|
+
return this.request("spawn_exit", params);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* a spawned worker that exited: its harness session id, the transcript its hooks named with it and its spawner's unread messages to it;
|
|
404
|
+
* with `take` and a session they are marked read, for a resume to carry them
|
|
405
|
+
*/
|
|
406
|
+
spawnSteers(params) {
|
|
407
|
+
return this.request("spawn_steers", params);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* at a stop: the spawned workers' unread messages, held for the hook to print, waiting up to `wait_s` while workers still run, none came,
|
|
411
|
+
* pushes are left and no prompt moved the `prompts` mark inject handed out; with `wait_s` above 0 and other workers still running, the
|
|
412
|
+
* broker gathers more of their results into the same push for up to ${LIMITS.pushGraceS} s, which may run past `wait_s`
|
|
413
|
+
*/
|
|
414
|
+
stopWait(waitS = 0, prompts) {
|
|
415
|
+
return this.request("stop_wait", { wait_s: waitS, ...prompts !== void 0 ? { prompts } : {} });
|
|
416
|
+
}
|
|
417
|
+
group(params) {
|
|
418
|
+
return this.request("group", params);
|
|
419
|
+
}
|
|
420
|
+
admin(params) {
|
|
421
|
+
return this.request("admin", params);
|
|
422
|
+
}
|
|
423
|
+
async peers() {
|
|
424
|
+
return (await this.request("peers")).peers;
|
|
425
|
+
}
|
|
426
|
+
why(from, to) {
|
|
427
|
+
return this.request("why", { from, to });
|
|
428
|
+
}
|
|
429
|
+
close() {
|
|
430
|
+
this.socket.end();
|
|
431
|
+
}
|
|
432
|
+
flush() {
|
|
433
|
+
this.writer.flush((bytes) => this.socket.write(bytes));
|
|
434
|
+
}
|
|
435
|
+
onData(chunk) {
|
|
436
|
+
const { lines, tooLong } = this.reader.push(chunk);
|
|
437
|
+
this.dispatchLines(lines);
|
|
438
|
+
if (tooLong) this.fail(new Error("the relay broker sent a line over the size cap"));
|
|
439
|
+
}
|
|
440
|
+
dispatchLines(lines) {
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
let parsed;
|
|
443
|
+
try {
|
|
444
|
+
parsed = JSON.parse(line);
|
|
445
|
+
} catch {
|
|
446
|
+
this.fail(new Error("the relay broker sent a line that is not JSON"));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const message = parsed;
|
|
450
|
+
if (message.method) {
|
|
451
|
+
for (const fn of this.listeners) fn(message.method, message.params);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const pending = message.id !== void 0 ? this.pending.get(message.id) : void 0;
|
|
455
|
+
if (!pending) continue;
|
|
456
|
+
this.pending.delete(message.id);
|
|
457
|
+
if (message.error) pending.reject(new RelayError(message.error.data?.code ?? "invalid_params", message.error.message, message.error.data?.retry_after));
|
|
458
|
+
else pending.resolve(message.result);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
fail(error) {
|
|
462
|
+
this.onClose(error);
|
|
463
|
+
this.socket.end();
|
|
464
|
+
}
|
|
465
|
+
onClose(error) {
|
|
466
|
+
if (this.closedError) return;
|
|
467
|
+
this.closedError = error;
|
|
468
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
469
|
+
this.pending.clear();
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
var BrokerUnavailable = class extends Error {
|
|
473
|
+
};
|
|
474
|
+
function brokerLive(dir) {
|
|
475
|
+
const lock = join2(dir, "broker.lock");
|
|
476
|
+
const held2 = brokerLockHeld(lock);
|
|
477
|
+
if (held2 !== void 0) return held2;
|
|
478
|
+
let pid;
|
|
479
|
+
try {
|
|
480
|
+
pid = Number(readFileSync3(lock, "utf8").split(":")[0]);
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
485
|
+
try {
|
|
486
|
+
process.kill(pid, 0);
|
|
487
|
+
return true;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return error.code === "EPERM";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function connectBroker(dir, start, timeoutMs = 5e3) {
|
|
493
|
+
const path = socketPath(dir);
|
|
494
|
+
const refuse = (code) => new BrokerUnavailable(`the sandbox blocks the relay socket (${code}); allow ${path} or run outside the sandbox`);
|
|
495
|
+
const owned = (client) => {
|
|
496
|
+
if (ownPrivateDir(dirname(path))) return client;
|
|
497
|
+
client.close();
|
|
498
|
+
throw new BrokerUnavailable(`${dirname(path)} is a link, not this user's, or writable by others; refusing the relay socket in it`);
|
|
499
|
+
};
|
|
500
|
+
const sandboxed = (error) => {
|
|
501
|
+
if (error instanceof BrokerUnavailable) throw error;
|
|
502
|
+
const code = error?.code;
|
|
503
|
+
if (code === "EPERM" || code === "EACCES") throw refuse(code);
|
|
504
|
+
};
|
|
505
|
+
try {
|
|
506
|
+
return owned(await RelayClient.connect(path));
|
|
507
|
+
} catch (error) {
|
|
508
|
+
sandboxed(error);
|
|
509
|
+
if (!start) throw new BrokerUnavailable(`no relay broker at ${path} (${error instanceof Error ? error.message : String(error)})`);
|
|
510
|
+
if (existsSync(path) && brokerLive(dir)) throw refuse(error?.code ?? "refused");
|
|
511
|
+
}
|
|
512
|
+
start();
|
|
513
|
+
const deadline = Date.now() + timeoutMs;
|
|
514
|
+
for (; ; ) {
|
|
515
|
+
await sleep(50);
|
|
516
|
+
try {
|
|
517
|
+
return owned(await RelayClient.connect(path));
|
|
518
|
+
} catch (error) {
|
|
519
|
+
sandboxed(error);
|
|
520
|
+
if (Date.now() >= deadline)
|
|
521
|
+
throw new BrokerUnavailable(`the relay broker did not start within ${timeoutMs} ms (${error instanceof Error ? error.message : String(error)})`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/bridge.ts
|
|
527
|
+
import {
|
|
528
|
+
findSecret,
|
|
529
|
+
matches,
|
|
530
|
+
newId,
|
|
531
|
+
PROTOCOL as PROTOCOL2,
|
|
532
|
+
RelayError as RelayError2,
|
|
533
|
+
XLIMITS
|
|
534
|
+
} from "@scp-dev/core";
|
|
535
|
+
import { HUB_LIMITS } from "@scp-dev/hub";
|
|
536
|
+
var CONNECT_TIMEOUT_MS = 15e3;
|
|
537
|
+
var MIRROR_FRAME_BYTES = HUB_LIMITS.frameBytes - 1024;
|
|
538
|
+
var DEAD_LETTER_ATTEMPTS = 3;
|
|
539
|
+
function remoteSend(core, to) {
|
|
540
|
+
const targets = Array.isArray(to) ? to : [to];
|
|
541
|
+
const remote = targets.filter((address) => core.isRemote(address)).length;
|
|
542
|
+
if (remote && remote !== targets.length)
|
|
543
|
+
throw new RelayError2("invalid_params", "a message goes to endpoints on this machine or on other machines, not both; send it twice");
|
|
544
|
+
return remote > 0;
|
|
545
|
+
}
|
|
546
|
+
function relaySocketUrl(origin) {
|
|
547
|
+
const url = new URL("/v1/relay/ws", origin);
|
|
548
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
549
|
+
return url.toString();
|
|
550
|
+
}
|
|
551
|
+
function startBridge(options) {
|
|
552
|
+
const { core, onRemote: hooks, machine } = options;
|
|
553
|
+
const selectors2 = options.mirror ?? ["*"];
|
|
554
|
+
const minMs = options.backoff?.minMs ?? 1e3;
|
|
555
|
+
const maxMs = options.backoff?.maxMs ?? 6e4;
|
|
556
|
+
const forwardWaitMs = options.forwardWaitMs ?? 3e4;
|
|
557
|
+
const injectBackoffMs = options.injectBackoffMs ?? 1e3;
|
|
558
|
+
const Socket = options.webSocket ?? WebSocket;
|
|
559
|
+
const log = hooks.log ?? ((line) => console.error(line));
|
|
560
|
+
const encoder2 = new TextEncoder();
|
|
561
|
+
let token = options.token;
|
|
562
|
+
let ws = null;
|
|
563
|
+
let connected = false;
|
|
564
|
+
let ready = false;
|
|
565
|
+
let stopped = false;
|
|
566
|
+
let lastError = options.token ? null : options.tokenError ?? "no hub credential; the broker runs local-only";
|
|
567
|
+
let since = (/* @__PURE__ */ new Date()).toISOString();
|
|
568
|
+
let attempt = 0;
|
|
569
|
+
let account = null;
|
|
570
|
+
let revokedReason = null;
|
|
571
|
+
let retryTimer;
|
|
572
|
+
let nextId = 1;
|
|
573
|
+
const pending = /* @__PURE__ */ new Map();
|
|
574
|
+
let waitingForLink = [];
|
|
575
|
+
let deliveries = Promise.resolve();
|
|
576
|
+
let ackedMax = 0;
|
|
577
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
578
|
+
const injectFailures = /* @__PURE__ */ new Map();
|
|
579
|
+
const fromHub = /* @__PURE__ */ new WeakSet();
|
|
580
|
+
const setState = (up, error) => {
|
|
581
|
+
if (up !== connected || error !== lastError) since = (/* @__PURE__ */ new Date()).toISOString();
|
|
582
|
+
connected = up;
|
|
583
|
+
lastError = error;
|
|
584
|
+
};
|
|
585
|
+
function request(method, params = {}) {
|
|
586
|
+
const socket = ws;
|
|
587
|
+
if (socket?.readyState !== 1) return Promise.reject(new RelayError2("unavailable", "the hub is not connected"));
|
|
588
|
+
const id = nextId++;
|
|
589
|
+
return new Promise((resolve, reject) => {
|
|
590
|
+
pending.set(id, { resolve, reject });
|
|
591
|
+
try {
|
|
592
|
+
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
593
|
+
} catch (error) {
|
|
594
|
+
pending.delete(id);
|
|
595
|
+
reject(new RelayError2("unavailable", `the hub connection failed (${error instanceof Error ? error.message : String(error)})`));
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
function fireAndForget(method, params) {
|
|
600
|
+
request(method, params).catch((error) => {
|
|
601
|
+
if (!(error instanceof RelayError2 && error.code === "unavailable"))
|
|
602
|
+
log(`relay bridge: ${method} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function connect() {
|
|
606
|
+
if (stopped || !token) return;
|
|
607
|
+
let socket;
|
|
608
|
+
try {
|
|
609
|
+
socket = new Socket(relaySocketUrl(options.url), { headers: { Authorization: `Bearer ${token}` } });
|
|
610
|
+
} catch (error) {
|
|
611
|
+
setState(false, `cannot open the hub socket (${error instanceof Error ? error.message : String(error)})`);
|
|
612
|
+
return retry();
|
|
613
|
+
}
|
|
614
|
+
ws = socket;
|
|
615
|
+
revokedReason = null;
|
|
616
|
+
let handshakeError = null;
|
|
617
|
+
const timer = setTimeout(() => {
|
|
618
|
+
if (socket.readyState === 0) socket.close();
|
|
619
|
+
}, CONNECT_TIMEOUT_MS);
|
|
620
|
+
socket.addEventListener("open", () => {
|
|
621
|
+
clearTimeout(timer);
|
|
622
|
+
handshake(socket).catch((error) => {
|
|
623
|
+
if (ws !== socket) return;
|
|
624
|
+
const relay = error instanceof RelayError2 ? error : null;
|
|
625
|
+
if (relay && (relay.code === "unauthorized" || relay.code === "invalid_params")) {
|
|
626
|
+
stopped = true;
|
|
627
|
+
handshakeError = `the hub refused this bridge: ${relay.message}`;
|
|
628
|
+
} else if (relay?.code === "too_large") {
|
|
629
|
+
handshakeError = `the hub refused this bridge for now, retrying: ${relay.message}`;
|
|
630
|
+
} else {
|
|
631
|
+
handshakeError = `the handshake failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
632
|
+
}
|
|
633
|
+
socket.close(1e3, "handshake failed");
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
socket.addEventListener("message", (event) => onFrame(String(event.data)));
|
|
637
|
+
socket.addEventListener("error", () => {
|
|
638
|
+
});
|
|
639
|
+
socket.addEventListener("close", (event) => {
|
|
640
|
+
clearTimeout(timer);
|
|
641
|
+
if (ws !== socket) return;
|
|
642
|
+
ws = null;
|
|
643
|
+
ready = false;
|
|
644
|
+
const close = event;
|
|
645
|
+
const wasUp = connected;
|
|
646
|
+
for (const p of pending.values()) p.reject(new RelayError2("unavailable", "the hub connection closed"));
|
|
647
|
+
pending.clear();
|
|
648
|
+
const why = `${close.code}${close.reason ? ` ${close.reason}` : ""}`;
|
|
649
|
+
if (close.code === 1008) {
|
|
650
|
+
stopped = true;
|
|
651
|
+
setState(false, `revoked or unauthorized: ${revokedReason ?? (close.reason || "the hub closed the bridge")}`);
|
|
652
|
+
} else if (close.code === 4e3) {
|
|
653
|
+
stopped = true;
|
|
654
|
+
setState(false, `replaced: ${close.reason || "another bridge connected for this machine"}`);
|
|
655
|
+
} else if (close.code === 4001) {
|
|
656
|
+
setState(false, `the credential expired, reconnecting (${why})`);
|
|
657
|
+
} else {
|
|
658
|
+
setState(false, handshakeError ?? (wasUp ? `disconnected (${why})` : `cannot connect to ${options.url} (${why})`));
|
|
659
|
+
}
|
|
660
|
+
retry();
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
function retry() {
|
|
664
|
+
if (stopped) return;
|
|
665
|
+
const base = Math.min(maxMs, minMs * 2 ** Math.min(attempt, 16));
|
|
666
|
+
attempt++;
|
|
667
|
+
retryTimer = setTimeout(reconnect, base / 2 + Math.random() * (base / 2));
|
|
668
|
+
}
|
|
669
|
+
async function reconnect() {
|
|
670
|
+
if (options.credentials && !stopped) {
|
|
671
|
+
try {
|
|
672
|
+
token = await options.credentials() ?? token;
|
|
673
|
+
} catch (error) {
|
|
674
|
+
log(`relay bridge: the credential could not be read again: ${error instanceof Error ? error.message : String(error)}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
connect();
|
|
678
|
+
}
|
|
679
|
+
async function handshake(socket) {
|
|
680
|
+
const hello = await request("hello", { protocol: PROTOCOL2, machine, bridge: true });
|
|
681
|
+
account = hello.account;
|
|
682
|
+
core.setAliases([account]);
|
|
683
|
+
ready = true;
|
|
684
|
+
applyRemote(hello.endpoints, true);
|
|
685
|
+
await Promise.all(fullMirror().map((params) => request("mirror", params)));
|
|
686
|
+
await request("watch");
|
|
687
|
+
await request("subscribe", { after: cursor() });
|
|
688
|
+
if (ws !== socket) return;
|
|
689
|
+
attempt = 0;
|
|
690
|
+
setState(true, null);
|
|
691
|
+
for (const fn of waitingForLink.splice(0)) fn();
|
|
692
|
+
}
|
|
693
|
+
function applyRemote(views, full) {
|
|
694
|
+
const byMachine = /* @__PURE__ */ new Map();
|
|
695
|
+
for (const view of views) {
|
|
696
|
+
if (view.machine === machine) continue;
|
|
697
|
+
byMachine.set(view.machine, [...byMachine.get(view.machine) ?? [], view]);
|
|
698
|
+
}
|
|
699
|
+
if (full) {
|
|
700
|
+
for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) byMachine.set(view.machine, []);
|
|
701
|
+
}
|
|
702
|
+
for (const [name, list] of byMachine) {
|
|
703
|
+
try {
|
|
704
|
+
core.mirror(name, list, { full });
|
|
705
|
+
} catch (error) {
|
|
706
|
+
log(`relay bridge: the endpoints of ${name} were not applied: ${error instanceof Error ? error.message : String(error)}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function mirrored(view) {
|
|
711
|
+
if (view.kind === "system" || core.machineOf(view.id) !== null) return false;
|
|
712
|
+
const subject = {
|
|
713
|
+
handle: view.kind === "human" ? "" : view.address.slice(view.address.lastIndexOf("/") + 1),
|
|
714
|
+
harness: view.harness,
|
|
715
|
+
kind: view.kind,
|
|
716
|
+
labels: view.labels
|
|
717
|
+
};
|
|
718
|
+
return selectors2.some((selector) => matches(selector, subject));
|
|
719
|
+
}
|
|
720
|
+
function outward(view) {
|
|
721
|
+
const { title, ...scope } = core.mirrorScope(view.id);
|
|
722
|
+
return {
|
|
723
|
+
...scope,
|
|
724
|
+
...title === void 0 ? {} : { title: options.redact ? null : title },
|
|
725
|
+
address: view.address,
|
|
726
|
+
kind: view.kind,
|
|
727
|
+
harness: view.harness,
|
|
728
|
+
model: view.model,
|
|
729
|
+
cwd: options.redact ? null : view.cwd,
|
|
730
|
+
labels: view.labels,
|
|
731
|
+
status: view.status ? { state: view.status.state, note: options.redact ? "" : view.status.note } : null,
|
|
732
|
+
presence: view.presence,
|
|
733
|
+
last_seen: view.last_seen
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function localViews() {
|
|
737
|
+
return core.list().filter(mirrored).map(outward);
|
|
738
|
+
}
|
|
739
|
+
function fullMirror() {
|
|
740
|
+
const frames = [[]];
|
|
741
|
+
let bytes = 0;
|
|
742
|
+
for (const view of localViews()) {
|
|
743
|
+
const size = encoder2.encode(JSON.stringify(view)).length + 1;
|
|
744
|
+
if (size > MIRROR_FRAME_BYTES)
|
|
745
|
+
throw new RelayError2("too_large", `${view.address} mirrors ${size} bytes, over the hub's ${MIRROR_FRAME_BYTES} byte frame`);
|
|
746
|
+
if (bytes + size > MIRROR_FRAME_BYTES) {
|
|
747
|
+
frames.push([]);
|
|
748
|
+
bytes = 0;
|
|
749
|
+
}
|
|
750
|
+
frames.at(-1).push(view);
|
|
751
|
+
bytes += size;
|
|
752
|
+
}
|
|
753
|
+
if (frames.length === 1) return [{ endpoints: frames[0], full: true }];
|
|
754
|
+
const id = newId("sync");
|
|
755
|
+
return frames.map((endpoints, i) => ({ endpoints, full: i === 0, sync: { id, part: i + 1, parts: frames.length } }));
|
|
756
|
+
}
|
|
757
|
+
function handleOf(endpoint) {
|
|
758
|
+
const view = core.whoami(endpoint);
|
|
759
|
+
if (!mirrored(view)) return null;
|
|
760
|
+
return view.kind === "human" ? "" : view.address.slice(view.address.lastIndexOf("/") + 1);
|
|
761
|
+
}
|
|
762
|
+
function localAddress(address) {
|
|
763
|
+
if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address;
|
|
764
|
+
const rest = address.slice(account.length + 1);
|
|
765
|
+
return rest.includes("/") ? `${core.user}/${rest}` : `${core.user}/${account}/${rest}`;
|
|
766
|
+
}
|
|
767
|
+
function cursor() {
|
|
768
|
+
return inflight.size ? Math.min(...inflight) - 1 : ackedMax;
|
|
769
|
+
}
|
|
770
|
+
function onFrame(text) {
|
|
771
|
+
let frame;
|
|
772
|
+
try {
|
|
773
|
+
frame = JSON.parse(text);
|
|
774
|
+
} catch {
|
|
775
|
+
log("relay bridge: the hub sent a frame that is not JSON");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (frame.method) return onNotification(frame.method, frame.params);
|
|
779
|
+
const waiter = frame.id !== void 0 ? pending.get(frame.id) : void 0;
|
|
780
|
+
if (!waiter) return;
|
|
781
|
+
pending.delete(frame.id);
|
|
782
|
+
if (!frame.error) return waiter.resolve(frame.result);
|
|
783
|
+
const error = new RelayError2(frame.error.data?.code ?? "unavailable", frame.error.message, frame.error.data?.retry_after);
|
|
784
|
+
fromHub.add(error);
|
|
785
|
+
waiter.reject(error);
|
|
786
|
+
}
|
|
787
|
+
function onNotification(method, params) {
|
|
788
|
+
switch (method) {
|
|
789
|
+
case "deliver": {
|
|
790
|
+
const seq = params.seq;
|
|
791
|
+
if (!Number.isInteger(seq)) return;
|
|
792
|
+
inflight.add(seq);
|
|
793
|
+
deliveries = deliveries.then(() => deliver(seq, params.endpoint, params.message));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
case "endpoints": {
|
|
797
|
+
if (typeof params.machine !== "string" || params.machine === machine) return;
|
|
798
|
+
try {
|
|
799
|
+
core.mirror(params.machine, params.endpoints, { full: params.full === true });
|
|
800
|
+
} catch (error) {
|
|
801
|
+
log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`);
|
|
802
|
+
}
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
case "fleet":
|
|
806
|
+
return remoteFleet(params);
|
|
807
|
+
case "revoked":
|
|
808
|
+
revokedReason = typeof params.reason === "string" ? params.reason : null;
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function deliver(seq, target, message) {
|
|
813
|
+
try {
|
|
814
|
+
let refused = null;
|
|
815
|
+
for (; ; ) {
|
|
816
|
+
try {
|
|
817
|
+
refused = inject(target, message);
|
|
818
|
+
break;
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (error instanceof RelayError2) {
|
|
821
|
+
refused = error.code;
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
const attempts = (injectFailures.get(seq) ?? 0) + 1;
|
|
825
|
+
injectFailures.set(seq, attempts);
|
|
826
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
827
|
+
log(`relay bridge: delivery ${seq} failed (attempt ${attempts} of ${DEAD_LETTER_ATTEMPTS}): ${why}`);
|
|
828
|
+
if (attempts >= DEAD_LETTER_ATTEMPTS) {
|
|
829
|
+
await settled("reject", { seq, code: "dead_letter" });
|
|
830
|
+
settle(seq);
|
|
831
|
+
core.record("dead_letter", null, typeof message?.id === "string" ? message.id : null, { seq, target: String(target), attempts, error: why });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
await new Promise((resolve) => setTimeout(resolve, injectBackoffMs * 2 ** (attempts - 1)));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (refused !== null) {
|
|
838
|
+
await settled("reject", { seq, code: refused });
|
|
839
|
+
settle(seq);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
await settled("ack", { seqs: [seq] });
|
|
843
|
+
settle(seq);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (error instanceof RelayError2 && error.code === "unavailable") return;
|
|
846
|
+
log(`relay bridge: delivery ${seq} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function inject(target, message) {
|
|
850
|
+
const endpoint = core.localTarget(target);
|
|
851
|
+
if ((hooks.policyError() ?? core.inboundRefusal(endpoint, message)) !== null) return "policy_refused";
|
|
852
|
+
if (core.admit(endpoint, message).duplicate) return null;
|
|
853
|
+
hooks.wake(endpoint);
|
|
854
|
+
hooks.fleet({
|
|
855
|
+
type: "message",
|
|
856
|
+
machine: message.from.machine ?? null,
|
|
857
|
+
id: message.id,
|
|
858
|
+
thread: message.thread,
|
|
859
|
+
kind: message.kind,
|
|
860
|
+
from: localAddress(message.from.address),
|
|
861
|
+
to: [core.addressOf(endpoint)],
|
|
862
|
+
hop: message.hop
|
|
863
|
+
});
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
async function settled(method, params) {
|
|
867
|
+
for (; ; ) {
|
|
868
|
+
try {
|
|
869
|
+
await request(method, params);
|
|
870
|
+
return;
|
|
871
|
+
} catch (error) {
|
|
872
|
+
if (!(error instanceof RelayError2 && error.code === "rate_limited")) throw error;
|
|
873
|
+
await new Promise((resolve) => setTimeout(resolve, (error.retryAfterS ?? 1) * 1e3));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function settle(seq) {
|
|
878
|
+
inflight.delete(seq);
|
|
879
|
+
injectFailures.delete(seq);
|
|
880
|
+
ackedMax = Math.max(ackedMax, seq);
|
|
881
|
+
}
|
|
882
|
+
function remoteFleet(event) {
|
|
883
|
+
if (event.machine === machine) return;
|
|
884
|
+
if (event.type === "message" && Array.isArray(event.to) && account && event.to.some((to) => typeof to === "string" && to.startsWith(`${account}/${machine}/`)))
|
|
885
|
+
return;
|
|
886
|
+
const local = {
|
|
887
|
+
...event,
|
|
888
|
+
...event.from !== void 0 ? { from: localAddress(event.from) } : {},
|
|
889
|
+
...event.endpoint !== void 0 ? { endpoint: localAddress(event.endpoint) } : {}
|
|
890
|
+
};
|
|
891
|
+
if (Array.isArray(event.to)) local.to = event.to.map(localAddress);
|
|
892
|
+
if (event.type === "activity" && typeof event.machine === "string" && typeof event.endpoint === "string") {
|
|
893
|
+
try {
|
|
894
|
+
const proxy = core.remoteEndpoint(event.machine, event.endpoint.slice(event.endpoint.lastIndexOf("/") + 1));
|
|
895
|
+
const row = core.event(proxy, { kind: event.kind, tool: event.tool, summary: event.summary });
|
|
896
|
+
local.id = row.id;
|
|
897
|
+
} catch {
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
hooks.fleet(local);
|
|
901
|
+
}
|
|
902
|
+
function linkUp(ms) {
|
|
903
|
+
if (connected) return Promise.resolve(true);
|
|
904
|
+
if (stopped) return Promise.resolve(false);
|
|
905
|
+
return new Promise((resolve) => {
|
|
906
|
+
const done = (up) => {
|
|
907
|
+
clearTimeout(timer);
|
|
908
|
+
waitingForLink = waitingForLink.filter((fn) => fn !== onUp);
|
|
909
|
+
resolve(up);
|
|
910
|
+
};
|
|
911
|
+
const onUp = () => done(true);
|
|
912
|
+
const timer = setTimeout(() => done(false), ms);
|
|
913
|
+
waitingForLink.push(onUp);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
function unavailable() {
|
|
917
|
+
return new RelayError2("unavailable", `the hub is not connected${lastError ? ` (${lastError})` : ""}; try again`, 5);
|
|
918
|
+
}
|
|
919
|
+
function crossAccount(params, targets) {
|
|
920
|
+
if (targets.length !== 1) throw new RelayError2("invalid_params", "a message to another account goes to exactly one endpoint: no lists, groups or tree:");
|
|
921
|
+
if (typeof params.body === "string") {
|
|
922
|
+
const bytes = new TextEncoder().encode(params.body).length;
|
|
923
|
+
if (bytes > XLIMITS.bodyBytes)
|
|
924
|
+
throw new RelayError2("too_large", `a message to another account is at most ${XLIMITS.bodyBytes} bytes; this one is ${bytes}`);
|
|
925
|
+
}
|
|
926
|
+
if (params.attachments?.length) throw new RelayError2("data_class_refused", "attachments stay in this account; send a reference instead");
|
|
927
|
+
if (params.data_class === "private") throw new RelayError2("data_class_refused", "a private message stays in this account");
|
|
928
|
+
}
|
|
929
|
+
async function forward(from, params, origin = {}) {
|
|
930
|
+
const sender = core.addressOf(from);
|
|
931
|
+
const targets = Array.isArray(params.to) ? params.to : [params.to];
|
|
932
|
+
if (targets.some((to) => core.foreignAccount(to) !== null)) crossAccount(params, targets);
|
|
933
|
+
for (const to of targets) {
|
|
934
|
+
core.scopeCheck(from, to, typeof params.reply_to === "string" ? params.reply_to : void 0);
|
|
935
|
+
const refusal = core.checkPolicy(sender, String(to));
|
|
936
|
+
if (refusal) throw new RelayError2("policy_refused", `${refusal} (${String(to)})`);
|
|
937
|
+
}
|
|
938
|
+
if (typeof params.body !== "string") throw new RelayError2("invalid_params", "body must be a string");
|
|
939
|
+
if (params.data_class === "private" && !options.privateOk) {
|
|
940
|
+
throw new RelayError2(
|
|
941
|
+
"data_class_refused",
|
|
942
|
+
"a private message stays on this machine; allow private messages in the bridge configuration to send them through the hub"
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
const secret = findSecret(params.body) ?? (params.attachments ?? []).map((a) => findSecret(String(a?.path))).find(Boolean) ?? null;
|
|
946
|
+
if (secret) throw new RelayError2("secret_refused", `the message looks like it carries a credential (${secret}); send a reference instead`);
|
|
947
|
+
const earlier = core.forwarded(from, params);
|
|
948
|
+
if (earlier) return earlier;
|
|
949
|
+
const handle = handleOf(from);
|
|
950
|
+
if (handle === null)
|
|
951
|
+
throw new RelayError2("policy_refused", `${sender} is not mirrored to the hub (the bridge's mirror selectors), so it can't message other machines`);
|
|
952
|
+
if (!connected) throw unavailable();
|
|
953
|
+
const clientId = params.client_id ?? newId("fwd");
|
|
954
|
+
const sent = { ...params, client_id: clientId, to: targets.map((to) => core.route(to)) };
|
|
955
|
+
delete sent.wait_s;
|
|
956
|
+
delete sent.attachments;
|
|
957
|
+
const deadline = Date.now() + forwardWaitMs;
|
|
958
|
+
for (let tries = 0; ; ) {
|
|
959
|
+
try {
|
|
960
|
+
const result = await request("forward", {
|
|
961
|
+
from: handle,
|
|
962
|
+
params: sent,
|
|
963
|
+
hop_floor: core.causalHop(from),
|
|
964
|
+
...origin.tty === void 0 ? {} : { tty: origin.tty }
|
|
965
|
+
});
|
|
966
|
+
core.recordForward(from, { ...params, client_id: clientId }, result, origin);
|
|
967
|
+
return result;
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (!(error instanceof RelayError2 && error.code === "unavailable")) throw error;
|
|
970
|
+
if (fromHub.has(error)) {
|
|
971
|
+
const base = Math.min(maxMs, minMs * 2 ** tries++);
|
|
972
|
+
const wait = Math.max(base / 2 + Math.random() * (base / 2), (error.retryAfterS ?? 0) * 1e3);
|
|
973
|
+
if (Date.now() + wait >= deadline) throw error;
|
|
974
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
975
|
+
} else if (!await linkUp(deadline - Date.now())) throw unavailable();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function changed(endpoint) {
|
|
980
|
+
if (!ready) return;
|
|
981
|
+
let view;
|
|
982
|
+
try {
|
|
983
|
+
view = core.whoami(endpoint);
|
|
984
|
+
} catch {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
|
|
988
|
+
}
|
|
989
|
+
function resync() {
|
|
990
|
+
if (!ready) return;
|
|
991
|
+
let frames;
|
|
992
|
+
try {
|
|
993
|
+
frames = fullMirror();
|
|
994
|
+
} catch (error) {
|
|
995
|
+
return log(`relay bridge: mirror failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
996
|
+
}
|
|
997
|
+
for (const params of frames) fireAndForget("mirror", params);
|
|
998
|
+
}
|
|
999
|
+
function activity(endpoint, row) {
|
|
1000
|
+
if (!ready) return;
|
|
1001
|
+
const handle = handleOf(endpoint);
|
|
1002
|
+
if (handle === null) return;
|
|
1003
|
+
fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? void 0, summary: options.redact ? void 0 : row.summary ?? void 0 });
|
|
1004
|
+
}
|
|
1005
|
+
async function contacts() {
|
|
1006
|
+
if (!connected) throw unavailable();
|
|
1007
|
+
return (await request("list", { contacts: true })).contacts;
|
|
1008
|
+
}
|
|
1009
|
+
function status() {
|
|
1010
|
+
return { enabled: true, machine, url: options.url, connected, last_error: lastError, since };
|
|
1011
|
+
}
|
|
1012
|
+
async function stop() {
|
|
1013
|
+
stopped = true;
|
|
1014
|
+
clearTimeout(retryTimer);
|
|
1015
|
+
for (const fn of waitingForLink.splice(0)) fn();
|
|
1016
|
+
const socket = ws;
|
|
1017
|
+
ws = null;
|
|
1018
|
+
connected = false;
|
|
1019
|
+
ready = false;
|
|
1020
|
+
for (const p of pending.values()) p.reject(new RelayError2("unavailable", "the bridge stopped"));
|
|
1021
|
+
pending.clear();
|
|
1022
|
+
if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
|
|
1023
|
+
}
|
|
1024
|
+
connect();
|
|
1025
|
+
return { forward, changed, resync, activity, contacts, status, stop };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/server.ts
|
|
1029
|
+
import { createHash as createHash2 } from "crypto";
|
|
1030
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync5, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
1031
|
+
import { createServer } from "net";
|
|
1032
|
+
import { homedir as homedir2 } from "os";
|
|
1033
|
+
import { dirname as dirname2, isAbsolute, join as join3, sep } from "path";
|
|
1034
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
1035
|
+
import {
|
|
1036
|
+
BROKER_CAPABILITIES,
|
|
1037
|
+
CLI_NAME,
|
|
1038
|
+
LIMITS as LIMITS2,
|
|
1039
|
+
parsePolicy,
|
|
1040
|
+
PREAMBLE,
|
|
1041
|
+
PROTOCOL as PROTOCOL3,
|
|
1042
|
+
RelayError as RelayError4,
|
|
1043
|
+
SERVER_FIELDS,
|
|
1044
|
+
stripControl
|
|
1045
|
+
} from "@scp-dev/core";
|
|
1046
|
+
import { FederatedCore } from "@scp-dev/hub";
|
|
1047
|
+
|
|
1048
|
+
// src/judge-gate.ts
|
|
1049
|
+
import { accessSync, constants, readFileSync as readFileSync4 } from "fs";
|
|
1050
|
+
import {
|
|
1051
|
+
applyRedactions,
|
|
1052
|
+
DEFAULT_JUDGE_BUDGET,
|
|
1053
|
+
findSecret as findSecret2,
|
|
1054
|
+
HttpJudgeClient,
|
|
1055
|
+
JUDGE_PROMPT_VERSION,
|
|
1056
|
+
judgeBoundary,
|
|
1057
|
+
LIMITS,
|
|
1058
|
+
mergeVerdicts,
|
|
1059
|
+
newId as newId2,
|
|
1060
|
+
newJudgeStats,
|
|
1061
|
+
one,
|
|
1062
|
+
RelayError as RelayError3,
|
|
1063
|
+
runJudge,
|
|
1064
|
+
sha256Hex,
|
|
1065
|
+
VerdictCache
|
|
1066
|
+
} from "@scp-dev/core";
|
|
1067
|
+
var HELD_PER_SENDER = 100;
|
|
1068
|
+
var iso = (ms) => ms === null ? null : new Date(ms).toISOString();
|
|
1069
|
+
var JudgeGate = class {
|
|
1070
|
+
constructor(o) {
|
|
1071
|
+
this.o = o;
|
|
1072
|
+
o.sql.all(
|
|
1073
|
+
`CREATE TABLE IF NOT EXISTS judge_holds (id TEXT PRIMARY KEY, from_ep TEXT NOT NULL, params TEXT NOT NULL, origin TEXT NOT NULL, verdict TEXT NOT NULL,
|
|
1074
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, by TEXT, resolved_at INTEGER)`
|
|
1075
|
+
);
|
|
1076
|
+
o.sql.all(
|
|
1077
|
+
`CREATE TABLE IF NOT EXISTS judge_sent (from_ep TEXT NOT NULL, client_id TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
1078
|
+
PRIMARY KEY (from_ep, client_id))`
|
|
1079
|
+
);
|
|
1080
|
+
o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE state = 'releasing'`);
|
|
1081
|
+
}
|
|
1082
|
+
o;
|
|
1083
|
+
stats = newJudgeStats();
|
|
1084
|
+
policy;
|
|
1085
|
+
version = "";
|
|
1086
|
+
client = null;
|
|
1087
|
+
cache = new VerdictCache();
|
|
1088
|
+
shadows = /* @__PURE__ */ new Set();
|
|
1089
|
+
/** judged sends in the last hour per budget: `ep:<id>`, `tree:<root id>` and `*` for the whole broker */
|
|
1090
|
+
spent = /* @__PURE__ */ new Map();
|
|
1091
|
+
/** the policy's judge section; a model client only when its key is readable, else the prefilter alone */
|
|
1092
|
+
configure(policy) {
|
|
1093
|
+
this.policy = policy;
|
|
1094
|
+
this.version = policy ? sha256Hex(JSON.stringify(policy)) : "";
|
|
1095
|
+
this.client = null;
|
|
1096
|
+
const model = policy?.model;
|
|
1097
|
+
if (!policy || policy.mode === "off" || !model) return;
|
|
1098
|
+
const key = this.keyReader(model);
|
|
1099
|
+
if (!key) {
|
|
1100
|
+
this.o.log(
|
|
1101
|
+
`relay broker: the judge model's key (${model.key_env ? `key_env ${model.key_env}` : `key_file ${model.key_file}`}) is not readable; judging with the prefilter only`
|
|
1102
|
+
);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
this.client = (this.o.client ?? ((m, k) => new HttpJudgeClient({ model: m.id, effort: m.effort, key: k })))(model, key);
|
|
1106
|
+
}
|
|
1107
|
+
keyReader(model) {
|
|
1108
|
+
if (model.key_env) {
|
|
1109
|
+
const name = model.key_env;
|
|
1110
|
+
return this.o.env[name] ? async () => this.o.env[name] ?? "" : null;
|
|
1111
|
+
}
|
|
1112
|
+
const file = model.key_file;
|
|
1113
|
+
try {
|
|
1114
|
+
accessSync(file, constants.R_OK);
|
|
1115
|
+
} catch {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
return async () => readFileSync4(file, "utf8").trim();
|
|
1119
|
+
}
|
|
1120
|
+
/** resolves once every shadow judgement started so far finished */
|
|
1121
|
+
async settled() {
|
|
1122
|
+
await Promise.all([...this.shadows]);
|
|
1123
|
+
}
|
|
1124
|
+
/** a send or ask through the judge; an unjudged one goes straight to `deliver` */
|
|
1125
|
+
async send(from, params, origin, deliver) {
|
|
1126
|
+
const policy = this.policy;
|
|
1127
|
+
const body = params.body;
|
|
1128
|
+
if (!policy || policy.mode === "off" || typeof body !== "string" || new TextEncoder().encode(body).length > LIMITS.bodyBytes || findSecret2(body))
|
|
1129
|
+
return deliver(from, params, origin);
|
|
1130
|
+
const targets = Array.isArray(params.to) ? params.to : [params.to];
|
|
1131
|
+
if (targets.some((to) => this.o.core.foreignAccount(to) !== null)) {
|
|
1132
|
+
if (policy.mode === "enforce") {
|
|
1133
|
+
this.o.core.auditJudge("judge_skipped", from, null, {
|
|
1134
|
+
side: "local",
|
|
1135
|
+
boundary: "other account",
|
|
1136
|
+
prompt_version: JUDGE_PROMPT_VERSION,
|
|
1137
|
+
categories: [],
|
|
1138
|
+
sha256: sha256Hex(body),
|
|
1139
|
+
verdict: "skipped",
|
|
1140
|
+
reason: "other_account_judged_by_hub",
|
|
1141
|
+
to: targets.map(String)
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
return deliver(from, params, origin);
|
|
1145
|
+
}
|
|
1146
|
+
if (policy.mode === "shadow") {
|
|
1147
|
+
const result = await deliver(from, params, origin);
|
|
1148
|
+
if (!result.duplicate) this.shadow(policy, from, params, origin, result.id);
|
|
1149
|
+
return result;
|
|
1150
|
+
}
|
|
1151
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin);
|
|
1152
|
+
if (!groups.size) return deliver(from, params, origin);
|
|
1153
|
+
const ttl = params.ttl_s ?? LIMITS.ttlDefaultS;
|
|
1154
|
+
if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS.ttlMaxS) throw new RelayError3("invalid_params", `ttl_s must be 1 to ${LIMITS.ttlMaxS}`);
|
|
1155
|
+
const seen = params.client_id === void 0 ? void 0 : this.seen(from, params);
|
|
1156
|
+
if (seen) return seen;
|
|
1157
|
+
const waiting = one(this.o.sql, `SELECT COUNT(*) AS n FROM judge_holds WHERE from_ep = ? AND state IN ('held', 'releasing')`, from).n;
|
|
1158
|
+
if (waiting >= HELD_PER_SENDER)
|
|
1159
|
+
throw new RelayError3("rate_limited", `${waiting} of your messages wait for review by your human; send more once they released or refused some`, 3600);
|
|
1160
|
+
this.spend(policy, from, tree);
|
|
1161
|
+
const verdict = await this.judge(policy, from, body, groups);
|
|
1162
|
+
const detail = this.detail(verdict, body, groups);
|
|
1163
|
+
switch (verdict.verdict) {
|
|
1164
|
+
case "refuse":
|
|
1165
|
+
this.o.core.auditJudge("judge_refuse", from, null, detail);
|
|
1166
|
+
throw new RelayError3(
|
|
1167
|
+
"judge_refused",
|
|
1168
|
+
`the relay judge refused this message (local; ${verdict.categories.join(", ") || "no category"}): ${verdict.reason}`,
|
|
1169
|
+
void 0,
|
|
1170
|
+
{
|
|
1171
|
+
side: "local",
|
|
1172
|
+
categories: verdict.categories,
|
|
1173
|
+
reason: verdict.reason
|
|
1174
|
+
}
|
|
1175
|
+
);
|
|
1176
|
+
case "hold":
|
|
1177
|
+
return this.hold(from, params, origin, verdict, groups, detail);
|
|
1178
|
+
case "redact": {
|
|
1179
|
+
const applied = applyRedactions(body, verdict.redactions);
|
|
1180
|
+
const result = await deliver(from, { ...params, body: applied.text }, origin);
|
|
1181
|
+
const redacted = { n: applied.n, kinds: applied.kinds };
|
|
1182
|
+
this.o.core.auditJudge(verdict.source === "error" ? "judge_skipped" : "judge_redact", from, result.id, { ...detail, redacted });
|
|
1183
|
+
return this.remember(from, params, { ...result, redacted });
|
|
1184
|
+
}
|
|
1185
|
+
case "pass": {
|
|
1186
|
+
const result = await deliver(from, params, origin);
|
|
1187
|
+
if (verdict.source === "error") this.o.core.auditJudge("judge_skipped", from, result.id, detail);
|
|
1188
|
+
return this.remember(from, params, result);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/** an earlier judged send or waiting hold under the send's client_id: the same message is answered as before, another one refused as core refuses it */
|
|
1193
|
+
seen(from, params) {
|
|
1194
|
+
const clientId = params.client_id;
|
|
1195
|
+
const held2 = one(
|
|
1196
|
+
this.o.sql,
|
|
1197
|
+
`SELECT id, params FROM judge_holds WHERE from_ep = ? AND json_extract(params, '$.client_id') = ? AND state IN ('held', 'releasing')`,
|
|
1198
|
+
from,
|
|
1199
|
+
clientId
|
|
1200
|
+
);
|
|
1201
|
+
const sent = held2 ? void 0 : one(
|
|
1202
|
+
this.o.sql,
|
|
1203
|
+
`SELECT fingerprint, result FROM judge_sent WHERE from_ep = ? AND client_id = ? AND created_at > ?`,
|
|
1204
|
+
from,
|
|
1205
|
+
clientId,
|
|
1206
|
+
this.o.now() - LIMITS.idempotencyS * 1e3
|
|
1207
|
+
);
|
|
1208
|
+
const before = held2 ? fingerprint(JSON.parse(held2.params)) : sent?.fingerprint;
|
|
1209
|
+
if (before === void 0) return void 0;
|
|
1210
|
+
if (before !== fingerprint(params)) throw new RelayError3("invalid_params", `client_id ${clientId} was already used for a different message`);
|
|
1211
|
+
return held2 ? heldResult(held2.id, params) : { ...JSON.parse(sent.result), duplicate: true };
|
|
1212
|
+
}
|
|
1213
|
+
remember(from, params, result) {
|
|
1214
|
+
if (params.client_id !== void 0 && !result.duplicate) {
|
|
1215
|
+
this.o.sql.all(
|
|
1216
|
+
`INSERT OR REPLACE INTO judge_sent (from_ep, client_id, fingerprint, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
1217
|
+
from,
|
|
1218
|
+
params.client_id,
|
|
1219
|
+
fingerprint(params),
|
|
1220
|
+
JSON.stringify(result),
|
|
1221
|
+
this.o.now()
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
return result;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* a judged send counts before any model call against its sender's, its lineage tree's and the whole broker's budget, since refused and
|
|
1228
|
+
* held sends never reach the send's own limits, and a new handle or a helper process must not bring a fresh budget
|
|
1229
|
+
*/
|
|
1230
|
+
spend(policy, from, tree) {
|
|
1231
|
+
const now = this.o.now();
|
|
1232
|
+
const global = policy.budget ?? DEFAULT_JUDGE_BUDGET;
|
|
1233
|
+
const budgets = [
|
|
1234
|
+
[`ep:${from}`, LIMITS.pairPerMinute, LIMITS.pairPerHour, "from this endpoint"],
|
|
1235
|
+
[`tree:${tree}`, LIMITS.pairPerMinute * 2, LIMITS.pairPerHour * 2, "from this lineage tree"],
|
|
1236
|
+
["*", global.per_minute, global.per_hour, "on this broker"]
|
|
1237
|
+
];
|
|
1238
|
+
const recent = budgets.map(([key]) => (this.spent.get(key) ?? []).filter((at) => at > now - 36e5));
|
|
1239
|
+
budgets.forEach(([key, perMinute, perHour, what], i) => {
|
|
1240
|
+
for (const [limit, windowS] of [
|
|
1241
|
+
[perMinute, 60],
|
|
1242
|
+
[perHour, 3600]
|
|
1243
|
+
]) {
|
|
1244
|
+
const inWindow = recent[i].filter((at) => at > now - windowS * 1e3);
|
|
1245
|
+
if (inWindow.length >= limit) {
|
|
1246
|
+
this.spent.set(key, recent[i]);
|
|
1247
|
+
throw new RelayError3(
|
|
1248
|
+
"rate_limited",
|
|
1249
|
+
`${limit} judged messages ${what} in ${windowS} s`,
|
|
1250
|
+
Math.max(1, Math.ceil((inWindow[0] + windowS * 1e3 - now) / 1e3))
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
for (const [i, [key]] of budgets.entries()) this.spent.set(key, [...recent[i], now]);
|
|
1256
|
+
}
|
|
1257
|
+
/** how many budgets are tracked (idle ones are evicted by the sweep) */
|
|
1258
|
+
budgetKeys() {
|
|
1259
|
+
return this.spent.size;
|
|
1260
|
+
}
|
|
1261
|
+
/** the held messages, newest first, without bodies */
|
|
1262
|
+
holds(state) {
|
|
1263
|
+
const rows = state ? this.o.sql.all(`SELECT * FROM judge_holds WHERE state = ? ORDER BY created_at DESC, rowid DESC LIMIT 200`, state) : this.o.sql.all(`SELECT * FROM judge_holds ORDER BY created_at DESC, rowid DESC LIMIT 200`);
|
|
1264
|
+
return rows.map((row) => this.view(row));
|
|
1265
|
+
}
|
|
1266
|
+
/** one hold with its body and the judge's redactions while it waits */
|
|
1267
|
+
show(id) {
|
|
1268
|
+
const row = this.row(id);
|
|
1269
|
+
const params = JSON.parse(row.params);
|
|
1270
|
+
const verdict = JSON.parse(row.verdict);
|
|
1271
|
+
return { ...this.view(row), ...typeof params.body === "string" ? { body: params.body } : {}, redactions: verdict.redactions };
|
|
1272
|
+
}
|
|
1273
|
+
/** delivers a held message as its sender, as stored or redacted: scope, policy and limits are checked again by the send itself */
|
|
1274
|
+
async release(id, options, deliver) {
|
|
1275
|
+
const row = one(
|
|
1276
|
+
this.o.sql,
|
|
1277
|
+
`UPDATE judge_holds SET state = 'releasing' WHERE id = ? AND state = 'held' AND expires_at > ? RETURNING *`,
|
|
1278
|
+
id,
|
|
1279
|
+
this.o.now()
|
|
1280
|
+
);
|
|
1281
|
+
if (!row) throw new RelayError3("not_found", `no held message ${id}`);
|
|
1282
|
+
const params = JSON.parse(row.params);
|
|
1283
|
+
const verdict = JSON.parse(row.verdict);
|
|
1284
|
+
let sent = params;
|
|
1285
|
+
let redacted;
|
|
1286
|
+
if (options.redacted) {
|
|
1287
|
+
const applied = applyRedactions(params.body, verdict.redactions);
|
|
1288
|
+
if (!applied.n) {
|
|
1289
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id);
|
|
1290
|
+
throw new RelayError3("invalid_params", `hold ${id} has no redactions: release it as it is or refuse it`);
|
|
1291
|
+
}
|
|
1292
|
+
sent = { ...params, body: applied.text };
|
|
1293
|
+
redacted = { n: applied.n, kinds: applied.kinds };
|
|
1294
|
+
}
|
|
1295
|
+
let result;
|
|
1296
|
+
try {
|
|
1297
|
+
result = await deliver(row.from_ep, sent, JSON.parse(row.origin));
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id);
|
|
1300
|
+
throw error;
|
|
1301
|
+
}
|
|
1302
|
+
this.remember(row.from_ep, params, { ...result, ...redacted ? { redacted } : {} });
|
|
1303
|
+
this.resolve(row, "released", options.by);
|
|
1304
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, {
|
|
1305
|
+
...this.storedDetail(row, verdict),
|
|
1306
|
+
by: options.by,
|
|
1307
|
+
action: redacted ? "release_redacted" : "release",
|
|
1308
|
+
...redacted ? { redacted } : {}
|
|
1309
|
+
});
|
|
1310
|
+
this.tell(
|
|
1311
|
+
row.from_ep,
|
|
1312
|
+
`relay: your message held for review as ${id} was released by your human${redacted ? " with redactions" : ""} and delivered as ${result.id}`
|
|
1313
|
+
);
|
|
1314
|
+
return { ...result, ...redacted ? { redacted } : {} };
|
|
1315
|
+
}
|
|
1316
|
+
/** refuses a held message: it is never delivered, and its sender hears the human's reason */
|
|
1317
|
+
refuse(id, reason, by) {
|
|
1318
|
+
this.expire(this.o.now());
|
|
1319
|
+
const row = one(this.o.sql, `SELECT * FROM judge_holds WHERE id = ? AND state = 'held'`, id);
|
|
1320
|
+
if (!row) throw new RelayError3("not_found", `no held message ${id}`);
|
|
1321
|
+
const verdict = JSON.parse(row.verdict);
|
|
1322
|
+
this.resolve(row, "refused", by);
|
|
1323
|
+
this.o.core.auditJudge("judge_override", row.from_ep, id, { ...this.storedDetail(row, verdict), by, action: "refuse", reason });
|
|
1324
|
+
this.tell(row.from_ep, `relay: your message held for review as ${id} was refused by your human: ${reason}`);
|
|
1325
|
+
return this.view(this.row(id));
|
|
1326
|
+
}
|
|
1327
|
+
/** expires holds past their message TTL (body purged, sender told) and drops resolved ones past the audit window */
|
|
1328
|
+
sweep() {
|
|
1329
|
+
const now = this.o.now();
|
|
1330
|
+
this.expire(now);
|
|
1331
|
+
this.o.sql.all(`DELETE FROM judge_holds WHERE state IN ('released', 'refused', 'expired') AND resolved_at <= ?`, now - LIMITS.auditKeepS * 1e3);
|
|
1332
|
+
this.o.sql.all(`DELETE FROM judge_sent WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1e3);
|
|
1333
|
+
for (const [key, times] of this.spent) if (!times.length || times[times.length - 1] <= now - 36e5) this.spent.delete(key);
|
|
1334
|
+
}
|
|
1335
|
+
expire(now) {
|
|
1336
|
+
for (const row of this.o.sql.all(`SELECT * FROM judge_holds WHERE state = 'held' AND expires_at <= ?`, now)) {
|
|
1337
|
+
const verdict = JSON.parse(row.verdict);
|
|
1338
|
+
this.resolve(row, "expired", null);
|
|
1339
|
+
this.o.core.auditJudge("judge_expire", row.from_ep, row.id, this.storedDetail(row, verdict));
|
|
1340
|
+
this.tell(row.from_ep, `relay: your message held for review as ${row.id} expired without review and was not delivered`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
/** the recipients by the boundary their message crosses; throws as send does for a target the sender cannot reach */
|
|
1344
|
+
boundaries(policy, from, params, origin) {
|
|
1345
|
+
const { sender, recipients, tree } = this.o.core.judgeSubjects(from, params, origin);
|
|
1346
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1347
|
+
for (const recipient of recipients) {
|
|
1348
|
+
const label = judgeBoundary(policy, sender, recipient);
|
|
1349
|
+
if (label !== null) groups.set(label, [...groups.get(label) ?? [], recipient.address]);
|
|
1350
|
+
}
|
|
1351
|
+
return { groups, tree };
|
|
1352
|
+
}
|
|
1353
|
+
async judge(policy, from, body, groups) {
|
|
1354
|
+
const verdicts = await Promise.all(
|
|
1355
|
+
[...groups].map(
|
|
1356
|
+
([boundary, to]) => runJudge({
|
|
1357
|
+
body,
|
|
1358
|
+
side: "local",
|
|
1359
|
+
boundary,
|
|
1360
|
+
configVersion: this.version,
|
|
1361
|
+
client: this.client,
|
|
1362
|
+
cache: this.cache,
|
|
1363
|
+
timeoutMs: policy.timeout_s * 1e3,
|
|
1364
|
+
failMode: policy.fail,
|
|
1365
|
+
...this.client && policy.model ? { model: policy.model.id } : {},
|
|
1366
|
+
from: this.o.core.addressOf(from),
|
|
1367
|
+
to,
|
|
1368
|
+
now: this.o.now,
|
|
1369
|
+
stats: this.stats
|
|
1370
|
+
})
|
|
1371
|
+
)
|
|
1372
|
+
);
|
|
1373
|
+
return mergeVerdicts(verdicts);
|
|
1374
|
+
}
|
|
1375
|
+
shadow(policy, from, params, origin, ref) {
|
|
1376
|
+
const task = (async () => {
|
|
1377
|
+
try {
|
|
1378
|
+
const { groups, tree } = this.boundaries(policy, from, params, origin);
|
|
1379
|
+
if (!groups.size) return;
|
|
1380
|
+
this.spend(policy, from, tree);
|
|
1381
|
+
const body = params.body;
|
|
1382
|
+
const verdict = await this.judge(policy, from, body, groups);
|
|
1383
|
+
this.o.core.auditJudge("judge_shadow", from, ref, this.detail(verdict, body, groups));
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
this.o.log(`relay broker: a shadow judgement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1386
|
+
}
|
|
1387
|
+
})();
|
|
1388
|
+
this.shadows.add(task);
|
|
1389
|
+
void task.finally(() => this.shadows.delete(task));
|
|
1390
|
+
}
|
|
1391
|
+
hold(from, params, origin, verdict, groups, detail) {
|
|
1392
|
+
const now = this.o.now();
|
|
1393
|
+
const id = newId2("hold", now);
|
|
1394
|
+
const stored = { ...params, client_id: params.client_id ?? id };
|
|
1395
|
+
const to = [...groups.values()].flat();
|
|
1396
|
+
const record = { ...verdict, boundary: [...groups.keys()].join(" | "), to };
|
|
1397
|
+
this.o.sql.all(
|
|
1398
|
+
`INSERT INTO judge_holds (id, from_ep, params, origin, verdict, created_at, expires_at, state) VALUES (?, ?, ?, ?, ?, ?, ?, 'held')`,
|
|
1399
|
+
id,
|
|
1400
|
+
from,
|
|
1401
|
+
JSON.stringify(stored),
|
|
1402
|
+
JSON.stringify(origin),
|
|
1403
|
+
JSON.stringify(record),
|
|
1404
|
+
now,
|
|
1405
|
+
now + (params.ttl_s ?? LIMITS.ttlDefaultS) * 1e3
|
|
1406
|
+
);
|
|
1407
|
+
this.o.core.auditJudge("judge_hold", from, id, detail);
|
|
1408
|
+
return heldResult(id, params);
|
|
1409
|
+
}
|
|
1410
|
+
detail(verdict, body, groups) {
|
|
1411
|
+
return {
|
|
1412
|
+
side: "local",
|
|
1413
|
+
boundary: [...groups.keys()].join(" | "),
|
|
1414
|
+
prompt_version: verdict.prompt_version,
|
|
1415
|
+
categories: verdict.categories,
|
|
1416
|
+
sha256: sha256Hex(body),
|
|
1417
|
+
verdict: verdict.verdict,
|
|
1418
|
+
source: verdict.source,
|
|
1419
|
+
reason: verdict.reason,
|
|
1420
|
+
to: [...groups.values()].flat(),
|
|
1421
|
+
...verdict.model ? { model: verdict.model } : {},
|
|
1422
|
+
...verdict.ms !== void 0 ? { ms: verdict.ms } : {}
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
storedDetail(row, verdict) {
|
|
1426
|
+
const params = JSON.parse(row.params);
|
|
1427
|
+
return {
|
|
1428
|
+
side: "local",
|
|
1429
|
+
boundary: verdict.boundary,
|
|
1430
|
+
prompt_version: verdict.prompt_version,
|
|
1431
|
+
categories: verdict.categories,
|
|
1432
|
+
sha256: params.sha256 ?? sha256Hex(params.body ?? ""),
|
|
1433
|
+
verdict: verdict.verdict,
|
|
1434
|
+
to: verdict.to,
|
|
1435
|
+
...verdict.model ? { model: verdict.model } : {}
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
/** marks a hold resolved and purges its body and the judge's quotes of it */
|
|
1439
|
+
resolve(row, state, by) {
|
|
1440
|
+
const { body, ...params } = JSON.parse(row.params);
|
|
1441
|
+
const verdict = JSON.parse(row.verdict);
|
|
1442
|
+
const purged = { ...params, bytes: new TextEncoder().encode(body).length, sha256: sha256Hex(body) };
|
|
1443
|
+
const kinds = { ...verdict, redactions: verdict.redactions.map((r) => ({ kind: r.kind })) };
|
|
1444
|
+
this.o.sql.all(
|
|
1445
|
+
`UPDATE judge_holds SET state = ?, by = ?, resolved_at = ?, params = ?, verdict = ? WHERE id = ?`,
|
|
1446
|
+
state,
|
|
1447
|
+
by,
|
|
1448
|
+
this.o.now(),
|
|
1449
|
+
JSON.stringify(purged),
|
|
1450
|
+
JSON.stringify(kinds),
|
|
1451
|
+
row.id
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
tell(endpoint, text) {
|
|
1455
|
+
try {
|
|
1456
|
+
this.o.core.judgeNotice(endpoint, text);
|
|
1457
|
+
this.o.wake(endpoint);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
if (!(error instanceof RelayError3)) throw error;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
row(id) {
|
|
1463
|
+
const row = one(this.o.sql, `SELECT * FROM judge_holds WHERE id = ?`, id);
|
|
1464
|
+
if (!row) throw new RelayError3("not_found", `no held message ${id}`);
|
|
1465
|
+
return row;
|
|
1466
|
+
}
|
|
1467
|
+
view(row) {
|
|
1468
|
+
const params = JSON.parse(row.params);
|
|
1469
|
+
const verdict = JSON.parse(row.verdict);
|
|
1470
|
+
return {
|
|
1471
|
+
id: row.id,
|
|
1472
|
+
from: this.o.core.addressOf(row.from_ep),
|
|
1473
|
+
to: verdict.to,
|
|
1474
|
+
state: row.state,
|
|
1475
|
+
created_at: iso(row.created_at),
|
|
1476
|
+
expires_at: iso(row.expires_at),
|
|
1477
|
+
categories: verdict.categories,
|
|
1478
|
+
reason: verdict.reason,
|
|
1479
|
+
boundary: verdict.boundary,
|
|
1480
|
+
source: verdict.source,
|
|
1481
|
+
bytes: params.bytes ?? new TextEncoder().encode(params.body ?? "").length,
|
|
1482
|
+
by: row.by,
|
|
1483
|
+
resolved_at: iso(row.resolved_at)
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
var fingerprint = (p) => sha256Hex(
|
|
1488
|
+
JSON.stringify([
|
|
1489
|
+
[p.to].flat().map(String).sort(),
|
|
1490
|
+
p.body,
|
|
1491
|
+
p.kind ?? "message",
|
|
1492
|
+
p.thread ?? null,
|
|
1493
|
+
p.reply_to ?? null,
|
|
1494
|
+
p.data_class ?? "public",
|
|
1495
|
+
(p.attachments ?? []).map((a) => a?.path).sort(),
|
|
1496
|
+
p.mime ?? null,
|
|
1497
|
+
p.ttl_s ?? LIMITS.ttlDefaultS,
|
|
1498
|
+
p.require_reply ?? null
|
|
1499
|
+
])
|
|
1500
|
+
);
|
|
1501
|
+
function heldResult(id, params) {
|
|
1502
|
+
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/sqlite.ts
|
|
1506
|
+
import { createRequire } from "module";
|
|
1507
|
+
var STATEMENT_CACHE = 512;
|
|
1508
|
+
var load = createRequire(import.meta.url);
|
|
1509
|
+
function bunDriver(path) {
|
|
1510
|
+
const { Database } = load("bun:sqlite");
|
|
1511
|
+
const db = new Database(path, { create: true });
|
|
1512
|
+
return {
|
|
1513
|
+
exec: (statement) => void db.run(statement),
|
|
1514
|
+
all: (query, params) => db.query(query).all(...params),
|
|
1515
|
+
close: () => db.close()
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
function nodeDriver(path) {
|
|
1519
|
+
const { DatabaseSync } = load("node:sqlite");
|
|
1520
|
+
const db = new DatabaseSync(path);
|
|
1521
|
+
const statements = /* @__PURE__ */ new Map();
|
|
1522
|
+
return {
|
|
1523
|
+
exec: (statement) => db.exec(statement),
|
|
1524
|
+
all: (query, params) => {
|
|
1525
|
+
let statement = statements.get(query);
|
|
1526
|
+
if (!statement) {
|
|
1527
|
+
if (statements.size >= STATEMENT_CACHE) statements.clear();
|
|
1528
|
+
statement = db.prepare(query);
|
|
1529
|
+
statements.set(query, statement);
|
|
1530
|
+
}
|
|
1531
|
+
return statement.all(...params);
|
|
1532
|
+
},
|
|
1533
|
+
close: () => db.close()
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
function openDatabase(path) {
|
|
1537
|
+
const driver = typeof process.versions.bun === "string" ? bunDriver(path) : nodeDriver(path);
|
|
1538
|
+
let depth = 0;
|
|
1539
|
+
return {
|
|
1540
|
+
exec: (statement) => driver.exec(statement),
|
|
1541
|
+
all: (query, ...params) => driver.all(query, params),
|
|
1542
|
+
close: () => driver.close(),
|
|
1543
|
+
transaction(fn) {
|
|
1544
|
+
const savepoint = depth ? `tx_${depth}` : void 0;
|
|
1545
|
+
driver.exec(savepoint ? `SAVEPOINT ${savepoint}` : "BEGIN IMMEDIATE");
|
|
1546
|
+
depth++;
|
|
1547
|
+
let result;
|
|
1548
|
+
try {
|
|
1549
|
+
result = fn();
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
depth--;
|
|
1552
|
+
try {
|
|
1553
|
+
if (savepoint) {
|
|
1554
|
+
driver.exec(`ROLLBACK TO ${savepoint}`);
|
|
1555
|
+
driver.exec(`RELEASE ${savepoint}`);
|
|
1556
|
+
} else driver.exec("ROLLBACK");
|
|
1557
|
+
} catch {
|
|
1558
|
+
}
|
|
1559
|
+
throw error;
|
|
1560
|
+
}
|
|
1561
|
+
depth--;
|
|
1562
|
+
try {
|
|
1563
|
+
driver.exec(savepoint ? `RELEASE ${savepoint}` : "COMMIT");
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
if (!savepoint)
|
|
1566
|
+
try {
|
|
1567
|
+
driver.exec("ROLLBACK");
|
|
1568
|
+
} catch {
|
|
1569
|
+
}
|
|
1570
|
+
throw error;
|
|
1571
|
+
}
|
|
1572
|
+
return result;
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// src/server.ts
|
|
1578
|
+
var SWEEP_MS = 10 * 6e4;
|
|
1579
|
+
var PRESENCE_MS = 6e4;
|
|
1580
|
+
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"]);
|
|
1581
|
+
var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
1582
|
+
"rules",
|
|
1583
|
+
"rules_set",
|
|
1584
|
+
"rules_widen",
|
|
1585
|
+
"rules_lift",
|
|
1586
|
+
"grant",
|
|
1587
|
+
"group",
|
|
1588
|
+
"admin",
|
|
1589
|
+
"peers",
|
|
1590
|
+
"why",
|
|
1591
|
+
"spawn_exit",
|
|
1592
|
+
"spawn_steers",
|
|
1593
|
+
"peek",
|
|
1594
|
+
"peek_set",
|
|
1595
|
+
"peek_who"
|
|
1596
|
+
]);
|
|
1597
|
+
var CHANGES_SCOPE = /* @__PURE__ */ new Set(["rules_set", "rules_widen", "rules_lift", "group"]);
|
|
1598
|
+
var ASK_RECHECK_MS = 1e3;
|
|
1599
|
+
var STOP_RECHECK_MS = 1e3;
|
|
1600
|
+
var TRANSIENT_METHODS = /* @__PURE__ */ new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"]);
|
|
1601
|
+
var GRANT_UNBOUND = `a spawn-env grant binds only a new harness process: run the harness with it, or name the child (${CLI_NAME} spawn-env --name / --as)`;
|
|
1602
|
+
function workerResults(result) {
|
|
1603
|
+
return result.messages.length - (result.others_shown ?? 0) + result.more;
|
|
1604
|
+
}
|
|
1605
|
+
function othersRunning(result) {
|
|
1606
|
+
const senders = new Set(result.messages.slice(0, result.messages.length - (result.others_shown ?? 0)).map((m) => m.from.address));
|
|
1607
|
+
return result.workers.filter((address) => !senders.has(address)).length + result.starting;
|
|
1608
|
+
}
|
|
1609
|
+
function running(pid) {
|
|
1610
|
+
try {
|
|
1611
|
+
process.kill(pid, 0);
|
|
1612
|
+
return true;
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
return error.code === "EPERM";
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
function hookOf(s) {
|
|
1618
|
+
return { transient: s.data.transient === true, ...s.data.process ? { process: s.data.process } : {} };
|
|
1619
|
+
}
|
|
1620
|
+
async function startBroker(options) {
|
|
1621
|
+
const dir = privateDir(options.dir);
|
|
1622
|
+
const lock = takeBrokerLock(join3(dir, "broker.lock"));
|
|
1623
|
+
const socket = socketPath(dir);
|
|
1624
|
+
const connections = /* @__PURE__ */ new Set();
|
|
1625
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
1626
|
+
const policyFile = options.policyPath ?? policyPath();
|
|
1627
|
+
if (!options.policyPath && !accountHomeVerified())
|
|
1628
|
+
(options.log ?? console.error)(`relay broker: the user database has no home for this account; the policy path ${policyFile} comes from HOME`);
|
|
1629
|
+
let policyText;
|
|
1630
|
+
let policyError = null;
|
|
1631
|
+
const outboundCap = options.outboundCapBytes ?? 4 * 1024 * 1024;
|
|
1632
|
+
const maxPending = options.maxPending ?? 32;
|
|
1633
|
+
const partialLineMs = options.partialLineMs ?? 3e4;
|
|
1634
|
+
const log = options.log ?? ((line) => console.error(line));
|
|
1635
|
+
const idleMs = options.idleMs ?? 30 * 6e4;
|
|
1636
|
+
let idleTimer;
|
|
1637
|
+
let pending = 0;
|
|
1638
|
+
let closing = false;
|
|
1639
|
+
let stopping;
|
|
1640
|
+
let resolveStopped;
|
|
1641
|
+
const stopped = new Promise((resolve) => resolveStopped = resolve);
|
|
1642
|
+
const previousUmask = process.umask(63);
|
|
1643
|
+
let db;
|
|
1644
|
+
let core;
|
|
1645
|
+
let judge;
|
|
1646
|
+
let bridge = null;
|
|
1647
|
+
let listener;
|
|
1648
|
+
try {
|
|
1649
|
+
privateDir(dirname2(socket));
|
|
1650
|
+
db = openDatabase(join3(dir, "relay.db"));
|
|
1651
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
1652
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
1653
|
+
chmodSync2(join3(dir, "relay.db"), 384);
|
|
1654
|
+
const sql = db;
|
|
1655
|
+
const machine = options.machine ?? options.bridge?.machine;
|
|
1656
|
+
core = new FederatedCore(sql, {
|
|
1657
|
+
...compact({ user: options.user, machine, now: options.now }),
|
|
1658
|
+
hostname: shortHostname(),
|
|
1659
|
+
hash: (value) => createHash2("sha256").update(value).digest("hex"),
|
|
1660
|
+
transaction: (fn) => db.transaction(fn),
|
|
1661
|
+
alive: running
|
|
1662
|
+
});
|
|
1663
|
+
core.migrate();
|
|
1664
|
+
core.resetConnections();
|
|
1665
|
+
core.sweep();
|
|
1666
|
+
judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake });
|
|
1667
|
+
judge.sweep();
|
|
1668
|
+
if (existsSync2(socket)) unlinkSync2(socket);
|
|
1669
|
+
listener = createServer((raw) => {
|
|
1670
|
+
const s = new Conn(
|
|
1671
|
+
raw,
|
|
1672
|
+
{
|
|
1673
|
+
reader: new LineReader(MAX_LINE),
|
|
1674
|
+
writer: new LineWriter(),
|
|
1675
|
+
observer: false,
|
|
1676
|
+
watching: false,
|
|
1677
|
+
closed: false,
|
|
1678
|
+
inFlight: 0,
|
|
1679
|
+
queue: [],
|
|
1680
|
+
queueHead: 0
|
|
1681
|
+
},
|
|
1682
|
+
{ data: onData, drain: flush, close: (s2) => onClose(s2) }
|
|
1683
|
+
);
|
|
1684
|
+
if (closing) {
|
|
1685
|
+
s.end();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
connections.add(s);
|
|
1689
|
+
activity();
|
|
1690
|
+
});
|
|
1691
|
+
await new Promise((resolve, reject) => {
|
|
1692
|
+
listener.once("error", reject);
|
|
1693
|
+
listener.listen(socket, () => {
|
|
1694
|
+
listener.off("error", reject);
|
|
1695
|
+
resolve();
|
|
1696
|
+
});
|
|
1697
|
+
});
|
|
1698
|
+
listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`));
|
|
1699
|
+
chmodSync2(socket, 384);
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
listener?.close();
|
|
1702
|
+
db?.close();
|
|
1703
|
+
releaseBrokerLock(lock);
|
|
1704
|
+
throw error;
|
|
1705
|
+
} finally {
|
|
1706
|
+
process.umask(previousUmask);
|
|
1707
|
+
}
|
|
1708
|
+
const sweeper = setInterval(() => {
|
|
1709
|
+
core.sweep();
|
|
1710
|
+
judge.sweep();
|
|
1711
|
+
}, SWEEP_MS);
|
|
1712
|
+
const known = /* @__PURE__ */ new Map();
|
|
1713
|
+
for (const view of localViews()) known.set(view.id, view.presence);
|
|
1714
|
+
const presenceTimer = setInterval(() => {
|
|
1715
|
+
const live = /* @__PURE__ */ new Set();
|
|
1716
|
+
for (const view of localViews()) {
|
|
1717
|
+
live.add(view.id);
|
|
1718
|
+
if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view);
|
|
1719
|
+
}
|
|
1720
|
+
for (const id of known.keys()) if (!live.has(id)) known.delete(id);
|
|
1721
|
+
}, options.presenceEveryMs ?? PRESENCE_MS);
|
|
1722
|
+
if (options.bridge) {
|
|
1723
|
+
bridge = startBridge({
|
|
1724
|
+
...options.bridge,
|
|
1725
|
+
core,
|
|
1726
|
+
onRemote: {
|
|
1727
|
+
wake,
|
|
1728
|
+
fleet: notifyFleet,
|
|
1729
|
+
policyError: () => {
|
|
1730
|
+
loadPolicy();
|
|
1731
|
+
return policyError;
|
|
1732
|
+
},
|
|
1733
|
+
log
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
function activity() {
|
|
1738
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1739
|
+
idleTimer = void 0;
|
|
1740
|
+
if (!closing && idleMs > 0 && connections.size === 0 && pending === 0) idleTimer = setTimeout(() => void stop(), idleMs);
|
|
1741
|
+
}
|
|
1742
|
+
function loadPolicy() {
|
|
1743
|
+
let text;
|
|
1744
|
+
try {
|
|
1745
|
+
text = readFileSync5(policyFile, "utf8");
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
const code = error.code;
|
|
1748
|
+
const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
|
|
1749
|
+
if (code !== "ENOENT" || dangling) {
|
|
1750
|
+
policyText = void 0;
|
|
1751
|
+
policyError = dangling ? `the relay policy at ${policyFile} is a link to a missing file; messages are refused until it is fixed` : `the relay policy at ${policyFile} cannot be read (${code ?? String(error)}); messages are refused until it is fixed`;
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
text = null;
|
|
1755
|
+
}
|
|
1756
|
+
if (text === policyText) return;
|
|
1757
|
+
policyText = text;
|
|
1758
|
+
if (text === null) {
|
|
1759
|
+
core.setPolicy(null);
|
|
1760
|
+
judge.configure(void 0);
|
|
1761
|
+
policyError = null;
|
|
1762
|
+
bridge?.resync();
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
try {
|
|
1766
|
+
const policy = parsePolicy(JSON.parse(text));
|
|
1767
|
+
core.setPolicy(policy);
|
|
1768
|
+
judge.configure(policy.judge);
|
|
1769
|
+
policyError = null;
|
|
1770
|
+
bridge?.resync();
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
policyError = `the relay policy at ${policyFile} is invalid (${error instanceof Error ? error.message : String(error)}); messages are refused until it is fixed`;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function policyInfo() {
|
|
1776
|
+
loadPolicy();
|
|
1777
|
+
if (policyError) return { file: policyFile, state: "error", error: policyError };
|
|
1778
|
+
return { file: policyFile, state: policyText === null ? "open" : "loaded" };
|
|
1779
|
+
}
|
|
1780
|
+
function brokerInfo() {
|
|
1781
|
+
return { socket, dir, pid: process.pid, policy: policyInfo() };
|
|
1782
|
+
}
|
|
1783
|
+
function write(s, value) {
|
|
1784
|
+
if (s.data.closed) return;
|
|
1785
|
+
const line = encodeLine(value);
|
|
1786
|
+
const queued = unread(s);
|
|
1787
|
+
if (queued > 0 && queued + line.length > outboundCap) {
|
|
1788
|
+
log(`relay broker: ended a connection with ${queued} bytes it did not read and a ${line.length} byte response`);
|
|
1789
|
+
onClose(s);
|
|
1790
|
+
s.end();
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
s.data.writer.pushBytes(line);
|
|
1794
|
+
flush(s);
|
|
1795
|
+
}
|
|
1796
|
+
function unread(s) {
|
|
1797
|
+
return s.data.writer.pending + s.unsent;
|
|
1798
|
+
}
|
|
1799
|
+
function flush(s) {
|
|
1800
|
+
if (!s.data.closed) s.data.writer.flush((bytes) => s.write(bytes));
|
|
1801
|
+
}
|
|
1802
|
+
function localViews() {
|
|
1803
|
+
return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null);
|
|
1804
|
+
}
|
|
1805
|
+
function presenceChanged(view) {
|
|
1806
|
+
known.set(view.id, view.presence);
|
|
1807
|
+
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
1808
|
+
bridge?.changed(view.id);
|
|
1809
|
+
}
|
|
1810
|
+
function notifyFleet(event) {
|
|
1811
|
+
const at = new Date((options.now ?? Date.now)()).toISOString();
|
|
1812
|
+
let line;
|
|
1813
|
+
const views = /* @__PURE__ */ new Map();
|
|
1814
|
+
for (const s of connections) {
|
|
1815
|
+
if (!s.data.watching || s.data.closed) continue;
|
|
1816
|
+
const viewer = viewerOf(s);
|
|
1817
|
+
let bytes;
|
|
1818
|
+
if (viewer === void 0) bytes = line ??= encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
|
|
1819
|
+
else {
|
|
1820
|
+
let seen = views.get(viewer);
|
|
1821
|
+
if (!seen) {
|
|
1822
|
+
seen = core.visibleTo(viewer);
|
|
1823
|
+
views.set(viewer, seen);
|
|
1824
|
+
}
|
|
1825
|
+
const shown = filterEvent(event, seen);
|
|
1826
|
+
if (!shown) continue;
|
|
1827
|
+
bytes = encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...shown } });
|
|
1828
|
+
}
|
|
1829
|
+
const queued = unread(s);
|
|
1830
|
+
if (queued + bytes.length > outboundCap) {
|
|
1831
|
+
s.data.watching = false;
|
|
1832
|
+
s.data.writer.push({ jsonrpc: "2.0", method: "fleet", params: { at, type: "lagged", queued_bytes: queued } });
|
|
1833
|
+
} else {
|
|
1834
|
+
s.data.writer.pushBytes(bytes);
|
|
1835
|
+
}
|
|
1836
|
+
flush(s);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
function filterEvent(event, seen) {
|
|
1840
|
+
if (event.type === "message") {
|
|
1841
|
+
const to = event.to.filter((address) => seen.has(address));
|
|
1842
|
+
return seen.has(String(event.from)) && to.length ? { ...event, to } : null;
|
|
1843
|
+
}
|
|
1844
|
+
return typeof event.endpoint !== "string" || seen.has(event.endpoint) ? event : null;
|
|
1845
|
+
}
|
|
1846
|
+
function atTerminal(s) {
|
|
1847
|
+
return s.data.human === true && s.data.tty === true;
|
|
1848
|
+
}
|
|
1849
|
+
function viewerOf(s) {
|
|
1850
|
+
if (s.data.lineage) return s.data.lineage;
|
|
1851
|
+
if (s.data.observer || s.data.human) return atTerminal(s) ? void 0 : core.humanId();
|
|
1852
|
+
return s.data.endpoint;
|
|
1853
|
+
}
|
|
1854
|
+
function actorOf(s) {
|
|
1855
|
+
if (s.data.lineage) return { kind: "endpoint", id: s.data.lineage, auth: s.data.own ? "self" : "lineage" };
|
|
1856
|
+
if (s.data.observer || s.data.human) return { kind: "human", tty: atTerminal(s) };
|
|
1857
|
+
return { kind: "endpoint", id: s.data.endpoint, auth: "session" };
|
|
1858
|
+
}
|
|
1859
|
+
function bindLineage(s, endpoint, params, own = false) {
|
|
1860
|
+
s.data.endpoint = endpoint;
|
|
1861
|
+
s.data.lineage = endpoint;
|
|
1862
|
+
s.data.own = own;
|
|
1863
|
+
s.data.brief = true;
|
|
1864
|
+
s.data.cwd = typeof params.cwd === "string" ? params.cwd : void 0;
|
|
1865
|
+
return {
|
|
1866
|
+
endpoint: core.whoami(endpoint),
|
|
1867
|
+
capabilities: BROKER_CAPABILITIES,
|
|
1868
|
+
server_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1869
|
+
limits: LIMITS2,
|
|
1870
|
+
preamble: PREAMBLE,
|
|
1871
|
+
broker: brokerInfo()
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
function wake(endpoint) {
|
|
1875
|
+
const set = waiters.get(endpoint);
|
|
1876
|
+
if (!set) return;
|
|
1877
|
+
waiters.delete(endpoint);
|
|
1878
|
+
for (const fn of set) fn();
|
|
1879
|
+
}
|
|
1880
|
+
function waitFor(endpoint, ms, s) {
|
|
1881
|
+
return new Promise((resolve) => {
|
|
1882
|
+
const done = () => {
|
|
1883
|
+
clearTimeout(timer);
|
|
1884
|
+
waiters.get(endpoint)?.delete(done);
|
|
1885
|
+
resolve();
|
|
1886
|
+
};
|
|
1887
|
+
const timer = setTimeout(done, ms);
|
|
1888
|
+
let set = waiters.get(endpoint);
|
|
1889
|
+
if (!set) {
|
|
1890
|
+
set = /* @__PURE__ */ new Set();
|
|
1891
|
+
waiters.set(endpoint, set);
|
|
1892
|
+
}
|
|
1893
|
+
set.add(done);
|
|
1894
|
+
if (s.data.closed) done();
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
async function gather(me, result, peek, s) {
|
|
1898
|
+
const gathering = (r) => workerResults(r) > 0 && !r.more && !r.prompted && othersRunning(r) > 0;
|
|
1899
|
+
const cap = Date.now() + LIMITS2.pushGraceS * 1e3;
|
|
1900
|
+
let quiet = Date.now() + LIMITS2.pushQuietS * 1e3;
|
|
1901
|
+
let results = workerResults(result);
|
|
1902
|
+
while (gathering(result) && !s.data.closed && Date.now() < Math.min(quiet, cap)) {
|
|
1903
|
+
await waitFor(me, Math.min(quiet, cap) - Date.now(), s);
|
|
1904
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
1905
|
+
result = core.stopWait(me, peek);
|
|
1906
|
+
if (workerResults(result) > results) {
|
|
1907
|
+
results = workerResults(result);
|
|
1908
|
+
quiet = Date.now() + LIMITS2.pushQuietS * 1e3;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
function onData(s, chunk) {
|
|
1913
|
+
const { lines, tooLong } = s.data.reader.push(chunk);
|
|
1914
|
+
for (const line of lines) s.data.queue.push(line);
|
|
1915
|
+
pump(s);
|
|
1916
|
+
if (tooLong) {
|
|
1917
|
+
write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "request too large", data: { code: "too_large" } } });
|
|
1918
|
+
s.end();
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (!s.data.reader.buffered || lines.length) {
|
|
1922
|
+
clearTimeout(s.data.lineTimer);
|
|
1923
|
+
s.data.lineTimer = void 0;
|
|
1924
|
+
}
|
|
1925
|
+
if (s.data.reader.buffered && !s.data.lineTimer) armLineTimer(s);
|
|
1926
|
+
}
|
|
1927
|
+
function armLineTimer(s) {
|
|
1928
|
+
if (partialLineMs <= 0) return;
|
|
1929
|
+
s.data.lineTimer = setTimeout(() => {
|
|
1930
|
+
s.data.lineTimer = void 0;
|
|
1931
|
+
if (s.data.closed || !s.data.reader.buffered) return;
|
|
1932
|
+
if (s.data.queueHead < s.data.queue.length) return armLineTimer(s);
|
|
1933
|
+
write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "the request line was not finished in time", data: { code: "invalid_params" } } });
|
|
1934
|
+
s.end();
|
|
1935
|
+
}, partialLineMs);
|
|
1936
|
+
}
|
|
1937
|
+
function onClose(s) {
|
|
1938
|
+
if (s.data.closed) return;
|
|
1939
|
+
s.data.closed = true;
|
|
1940
|
+
clearTimeout(s.data.lineTimer);
|
|
1941
|
+
connections.delete(s);
|
|
1942
|
+
if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
|
|
1943
|
+
const before = core.whoami(s.data.endpoint).presence;
|
|
1944
|
+
const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief });
|
|
1945
|
+
wake(s.data.endpoint);
|
|
1946
|
+
const view = core.whoami(s.data.endpoint);
|
|
1947
|
+
known.set(s.data.endpoint, view.presence);
|
|
1948
|
+
if (!s.data.brief && view.presence !== before) notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence });
|
|
1949
|
+
if (settled) notifyFleet({ type: "status", endpoint: view.address, status: null });
|
|
1950
|
+
if (!s.data.brief || view.presence !== before || settled) bridge?.changed(s.data.endpoint);
|
|
1951
|
+
}
|
|
1952
|
+
activity();
|
|
1953
|
+
}
|
|
1954
|
+
function pump(s) {
|
|
1955
|
+
const data = s.data;
|
|
1956
|
+
while (data.queueHead < data.queue.length && data.inFlight < maxPending && !data.closed) void handleLine(s, data.queue[data.queueHead++]);
|
|
1957
|
+
if (data.queueHead === data.queue.length) {
|
|
1958
|
+
data.queue = [];
|
|
1959
|
+
data.queueHead = 0;
|
|
1960
|
+
if (!data.closed) s.resume();
|
|
1961
|
+
} else if (!data.closed) {
|
|
1962
|
+
s.pause();
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
async function handleLine(s, line) {
|
|
1966
|
+
s.data.inFlight++;
|
|
1967
|
+
try {
|
|
1968
|
+
await answer(s, line);
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
log(`relay broker: a request failed outside its handler: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
1971
|
+
} finally {
|
|
1972
|
+
s.data.inFlight--;
|
|
1973
|
+
pump(s);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
function invalidRequest(s, id, message) {
|
|
1977
|
+
write(s, { jsonrpc: "2.0", id, error: { code: -32600, message, data: { code: "invalid_params" } } });
|
|
1978
|
+
}
|
|
1979
|
+
async function answer(s, line) {
|
|
1980
|
+
if (closing) return;
|
|
1981
|
+
let parsed;
|
|
1982
|
+
try {
|
|
1983
|
+
parsed = JSON.parse(line);
|
|
1984
|
+
} catch {
|
|
1985
|
+
write(s, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return invalidRequest(s, null, "a request is a JSON object");
|
|
1989
|
+
const request = parsed;
|
|
1990
|
+
const hasId = "id" in request;
|
|
1991
|
+
const id = request.id ?? null;
|
|
1992
|
+
if (id !== null && typeof id !== "string" && typeof id !== "number") return invalidRequest(s, null, "a request id is a string or a number");
|
|
1993
|
+
if (typeof request.method !== "string") return invalidRequest(s, id, "a request needs a method");
|
|
1994
|
+
const params = request.params ?? {};
|
|
1995
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) return invalidRequest(s, id, "params is an object");
|
|
1996
|
+
pending++;
|
|
1997
|
+
try {
|
|
1998
|
+
const result = await dispatch(s, request.method, params);
|
|
1999
|
+
if (hasId) write(s, { jsonrpc: "2.0", id, result });
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
if (!hasId || closing) return;
|
|
2002
|
+
if (error instanceof RelayError4) {
|
|
2003
|
+
const code = error.code === "method_not_found" ? -32601 : error.code === "invalid_params" ? -32602 : -32e3;
|
|
2004
|
+
write(s, {
|
|
2005
|
+
jsonrpc: "2.0",
|
|
2006
|
+
id,
|
|
2007
|
+
error: {
|
|
2008
|
+
code,
|
|
2009
|
+
message: error.message,
|
|
2010
|
+
data: { ...error.detail, code: error.code, ...error.retryAfterS ? { retry_after: error.retryAfterS } : {} }
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
} else {
|
|
2014
|
+
log(`relay broker: ${request.method} failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
2015
|
+
write(s, { jsonrpc: "2.0", id, error: { code: -32603, message: "internal error" } });
|
|
2016
|
+
}
|
|
2017
|
+
} finally {
|
|
2018
|
+
pending--;
|
|
2019
|
+
activity();
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
function endpointOf(s) {
|
|
2023
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2024
|
+
if (!s.data.endpoint)
|
|
2025
|
+
throw new RelayError4("unauthorized", s.data.observer ? "this connection only observes: say hello as an agent or the human" : "call hello first");
|
|
2026
|
+
core.touch(s.data.endpoint);
|
|
2027
|
+
return s.data.endpoint;
|
|
2028
|
+
}
|
|
2029
|
+
function waitSeconds(value) {
|
|
2030
|
+
if (value === void 0) return 0;
|
|
2031
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new RelayError4("invalid_params", "wait_s must be a number of seconds");
|
|
2032
|
+
return Math.min(value, LIMITS2.waitMaxS) * 1e3;
|
|
2033
|
+
}
|
|
2034
|
+
function checkAttachments(attachments, s) {
|
|
2035
|
+
if (attachments === void 0) return void 0;
|
|
2036
|
+
if (!Array.isArray(attachments)) throw new RelayError4("invalid_params", "attachments must be a list");
|
|
2037
|
+
const tooWide = /* @__PURE__ */ new Set(["/", realOrSelf(homedir2()), realOrSelf(accountHome())]);
|
|
2038
|
+
const roots = [s.data.cwd, ...options.roots ?? []].filter((r) => typeof r === "string" && isAbsolute(r)).map(realOrSelf).filter((r) => !tooWide.has(r));
|
|
2039
|
+
return attachments.map((attachment) => {
|
|
2040
|
+
if (attachment?.kind !== "path" || typeof attachment.path !== "string" || !isAbsolute(attachment.path)) {
|
|
2041
|
+
throw new RelayError4("invalid_params", "an attachment is {kind: path, path: an absolute path}");
|
|
2042
|
+
}
|
|
2043
|
+
let real;
|
|
2044
|
+
try {
|
|
2045
|
+
real = realpathSync(attachment.path);
|
|
2046
|
+
} catch {
|
|
2047
|
+
throw new RelayError4("not_found", `no file at ${attachment.path}`);
|
|
2048
|
+
}
|
|
2049
|
+
if (!roots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep))) {
|
|
2050
|
+
throw new RelayError4("invalid_params", `${attachment.path} is outside the sender's working directory and the relay's allowed roots`);
|
|
2051
|
+
}
|
|
2052
|
+
return { kind: "path", path: real };
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
async function dispatch(s, method, params) {
|
|
2056
|
+
if (method === "hello") {
|
|
2057
|
+
if (s.data.endpoint || s.data.observer) throw new RelayError4("invalid_params", "hello was already called on this connection");
|
|
2058
|
+
if (params.protocol !== PROTOCOL3) throw new RelayError4("invalid_params", `this broker speaks ${PROTOCOL3}`);
|
|
2059
|
+
if (params.transient === true) {
|
|
2060
|
+
if (params.human === true || params.observe === true || params.resume !== void 0) {
|
|
2061
|
+
throw new RelayError4("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume");
|
|
2062
|
+
}
|
|
2063
|
+
const before2 = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]));
|
|
2064
|
+
const { endpoint: endpoint2, subagent, created } = core.claim(params.claim ?? {}, { transient: true });
|
|
2065
|
+
s.data.endpoint = endpoint2.id;
|
|
2066
|
+
s.data.transient = true;
|
|
2067
|
+
const key = params.claim?.process;
|
|
2068
|
+
if (typeof key === "string") s.data.process = key;
|
|
2069
|
+
if ((before2.get(endpoint2.id) ?? "offline") !== endpoint2.presence) presenceChanged(endpoint2);
|
|
2070
|
+
return {
|
|
2071
|
+
endpoint: endpoint2,
|
|
2072
|
+
...subagent ? { subagent, ...created ? { created } : {} } : {},
|
|
2073
|
+
capabilities: BROKER_CAPABILITIES,
|
|
2074
|
+
server_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2075
|
+
limits: LIMITS2,
|
|
2076
|
+
preamble: PREAMBLE,
|
|
2077
|
+
broker: brokerInfo()
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
if (params.observe === true) {
|
|
2081
|
+
const self = params.self === void 0 ? null : core.placeLineage(params.self);
|
|
2082
|
+
if (params.grant !== void 0) {
|
|
2083
|
+
if (!core.grantBinds(params.grant, self)) throw new RelayError4("unauthorized", GRANT_UNBOUND);
|
|
2084
|
+
return { ...bindLineage(s, self, params, true), grant_bound: true };
|
|
2085
|
+
}
|
|
2086
|
+
if (self) return bindLineage(s, self, params, true);
|
|
2087
|
+
s.data.observer = true;
|
|
2088
|
+
if (params.lineage !== void 0) s.data.lineage = core.placeLineage(params.lineage) ?? void 0;
|
|
2089
|
+
return {
|
|
2090
|
+
endpoint: null,
|
|
2091
|
+
capabilities: BROKER_CAPABILITIES,
|
|
2092
|
+
server_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2093
|
+
limits: LIMITS2,
|
|
2094
|
+
preamble: PREAMBLE,
|
|
2095
|
+
broker: brokerInfo()
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
const claim = params.claim;
|
|
2099
|
+
if (params.human === true && params.resume === void 0 && params.lineage !== void 0) {
|
|
2100
|
+
const placed = core.placeLineage(params.lineage);
|
|
2101
|
+
if (placed) return bindLineage(s, placed, params);
|
|
2102
|
+
} else if (params.human !== true && params.resume === void 0 && claim?.process === void 0 && typeof claim?.handle === "string") {
|
|
2103
|
+
const placed = claim.lineage === void 0 ? null : core.placeLineage(claim.lineage, claim.handle);
|
|
2104
|
+
if (placed && (claim.grant === void 0 || core.grantBinds(claim.grant, placed))) {
|
|
2105
|
+
return bindLineage(s, placed, { cwd: claim.cwd, ...params }, params.self !== void 0 && core.placeLineage(params.self, claim.handle) === placed);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
let token;
|
|
2109
|
+
let endpoint;
|
|
2110
|
+
const before = new Map(core.list({ all: true }).map((view) => [view.id, view]));
|
|
2111
|
+
if (typeof params.resume === "string") endpoint = core.resume(params.resume);
|
|
2112
|
+
else if (params.human === true) {
|
|
2113
|
+
s.data.tty = params.tty === true;
|
|
2114
|
+
endpoint = core.claimHuman(s.data.tty);
|
|
2115
|
+
} else {
|
|
2116
|
+
const claimed = core.claim(params.claim ?? {}, { self: params.self });
|
|
2117
|
+
endpoint = claimed.endpoint;
|
|
2118
|
+
token = claimed.token;
|
|
2119
|
+
}
|
|
2120
|
+
s.data.endpoint = endpoint.id;
|
|
2121
|
+
s.data.human = endpoint.kind === "human";
|
|
2122
|
+
s.data.brief = params.brief === true;
|
|
2123
|
+
s.data.cwd = typeof params.cwd === "string" ? params.cwd : endpoint.cwd ?? void 0;
|
|
2124
|
+
known.set(endpoint.id, endpoint.presence);
|
|
2125
|
+
const was = before.get(endpoint.id);
|
|
2126
|
+
const moved = (was?.presence ?? "offline") !== endpoint.presence || was !== void 0 && was.address !== endpoint.address;
|
|
2127
|
+
if (!s.data.brief && moved) {
|
|
2128
|
+
notifyFleet({ type: "presence", endpoint: endpoint.address, presence: endpoint.presence, harness: endpoint.harness, labels: endpoint.labels });
|
|
2129
|
+
}
|
|
2130
|
+
if (!s.data.brief || moved) bridge?.changed(endpoint.id);
|
|
2131
|
+
return {
|
|
2132
|
+
endpoint,
|
|
2133
|
+
...token ? { token } : {},
|
|
2134
|
+
capabilities: BROKER_CAPABILITIES,
|
|
2135
|
+
server_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2136
|
+
limits: LIMITS2,
|
|
2137
|
+
preamble: PREAMBLE,
|
|
2138
|
+
broker: brokerInfo()
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2142
|
+
if (s.data.observer && OBSERVER_METHODS.has(method)) return observe(s, method, params);
|
|
2143
|
+
if (s.data.observer && SCOPE_METHODS.has(method) && (s.data.lineage || method === "why")) return scope(s, method, params);
|
|
2144
|
+
if (s.data.transient && SCOPE_METHODS.has(method)) throw new RelayError4("scope_refused", "a hook connection cannot read or change scopes, groups or grants");
|
|
2145
|
+
const me = endpointOf(s);
|
|
2146
|
+
if (s.data.transient && !TRANSIENT_METHODS.has(method)) throw new RelayError4("unauthorized", "a hook connection only posts activity and status");
|
|
2147
|
+
if (OBSERVER_METHODS.has(method)) return observe(s, method, params);
|
|
2148
|
+
if (SCOPE_METHODS.has(method)) return scope(s, method, params);
|
|
2149
|
+
switch (method) {
|
|
2150
|
+
case "whoami":
|
|
2151
|
+
return s.data.transient ? core.whoami(me) : { ...core.whoami(me), ...core.peekState(me) };
|
|
2152
|
+
case "peek_turn":
|
|
2153
|
+
if (!s.data.transient || !core.hookOwns(me, s.data.process))
|
|
2154
|
+
throw new RelayError4("unauthorized", "only a hook of the endpoint's own harness process posts digest turns");
|
|
2155
|
+
return core.peekTurn(me, { role: params.role, text: params.text });
|
|
2156
|
+
case "send":
|
|
2157
|
+
case "ask": {
|
|
2158
|
+
for (const field of SERVER_FIELDS) if (field in params) throw new RelayError4("invalid_params", `${field} is set by the relay, not the sender`);
|
|
2159
|
+
loadPolicy();
|
|
2160
|
+
if (policyError) throw new RelayError4("policy_refused", policyError);
|
|
2161
|
+
const attachments = checkAttachments(params.attachments, s);
|
|
2162
|
+
const send = { ...params, ...attachments ? { attachments } : {} };
|
|
2163
|
+
if (method === "ask") send.kind = "request";
|
|
2164
|
+
const origin = s.data.tty === void 0 ? {} : { tty: s.data.tty, ...atTerminal(s) ? {} : { unverified: true } };
|
|
2165
|
+
const result = await judge.send(me, send, origin, deliver);
|
|
2166
|
+
if (method === "send" || result.held) return result;
|
|
2167
|
+
return awaitReply(s, me, result.id, waitSeconds(params.wait_s), result);
|
|
2168
|
+
}
|
|
2169
|
+
case "holds":
|
|
2170
|
+
return { holds: judge.holds(optional(reviewer(s, params), "state")) };
|
|
2171
|
+
case "hold_show":
|
|
2172
|
+
return judge.show(required(reviewer(s, params), "id"));
|
|
2173
|
+
case "hold_release": {
|
|
2174
|
+
const id = required(reviewer(s, params), "id");
|
|
2175
|
+
loadPolicy();
|
|
2176
|
+
if (policyError) throw new RelayError4("policy_refused", policyError);
|
|
2177
|
+
return judge.release(id, { redacted: params.redacted === true, by: core.addressOf(me) }, deliver);
|
|
2178
|
+
}
|
|
2179
|
+
case "hold_refuse": {
|
|
2180
|
+
const reason = required(reviewer(s, params), "reason");
|
|
2181
|
+
if (reason.length > LIMITS2.noteChars) throw new RelayError4("invalid_params", `reason takes at most ${LIMITS2.noteChars} characters`);
|
|
2182
|
+
return judge.refuse(required(params, "id"), stripControl(reason), core.addressOf(me));
|
|
2183
|
+
}
|
|
2184
|
+
case "ask_wait": {
|
|
2185
|
+
if (typeof params.id !== "string") throw new RelayError4("invalid_params", "id is the request's message id");
|
|
2186
|
+
return awaitReply(s, me, params.id, waitSeconds(params.wait_s));
|
|
2187
|
+
}
|
|
2188
|
+
case "inbox": {
|
|
2189
|
+
const query = compact({ after: params.after, limit: params.limit });
|
|
2190
|
+
const read = () => params.offer === true ? core.offer(me, query) : { messages: core.inbox(me, query) };
|
|
2191
|
+
const deadline = Date.now() + waitSeconds(params.wait_s);
|
|
2192
|
+
let page = read();
|
|
2193
|
+
while (!page.messages.length && !s.data.closed && Date.now() < deadline) {
|
|
2194
|
+
await waitFor(me, deadline - Date.now(), s);
|
|
2195
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2196
|
+
page = read();
|
|
2197
|
+
}
|
|
2198
|
+
const { messages } = page;
|
|
2199
|
+
return { ...page, next: messages.length ? messages[messages.length - 1].seq : query.after ?? 0 };
|
|
2200
|
+
}
|
|
2201
|
+
case "ack":
|
|
2202
|
+
return { acked: core.ack(me, params.seqs) };
|
|
2203
|
+
case "title": {
|
|
2204
|
+
const view = core.title(me, params.text);
|
|
2205
|
+
bridge?.changed(me);
|
|
2206
|
+
return view;
|
|
2207
|
+
}
|
|
2208
|
+
case "status": {
|
|
2209
|
+
core.status(me, params.state, params.note);
|
|
2210
|
+
const view = core.whoami(me);
|
|
2211
|
+
notifyFleet({ type: "status", endpoint: view.address, status: view.status });
|
|
2212
|
+
bridge?.changed(me);
|
|
2213
|
+
return view;
|
|
2214
|
+
}
|
|
2215
|
+
case "event": {
|
|
2216
|
+
const row = core.event(me, params, hookOf(s));
|
|
2217
|
+
notifyFleet({ type: "activity", ...row });
|
|
2218
|
+
bridge?.activity(me, row);
|
|
2219
|
+
const after = core.whoami(me);
|
|
2220
|
+
if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
|
|
2221
|
+
return row;
|
|
2222
|
+
}
|
|
2223
|
+
case "inject":
|
|
2224
|
+
return core.inject(me, {
|
|
2225
|
+
stop: params.stop === true,
|
|
2226
|
+
count: params.count === true,
|
|
2227
|
+
holdUnread: params.hold_unread === true,
|
|
2228
|
+
directives: params.directives === true,
|
|
2229
|
+
...hookOf(s)
|
|
2230
|
+
});
|
|
2231
|
+
case "stop_wait": {
|
|
2232
|
+
const waitMs = waitSeconds(params.wait_s);
|
|
2233
|
+
const deadline = Date.now() + waitMs;
|
|
2234
|
+
const options2 = { ...hookOf(s), ...typeof params.prompts === "number" ? { prompts: params.prompts } : {} };
|
|
2235
|
+
const peek = { ...options2, peek: true };
|
|
2236
|
+
let result = core.stopWait(me, peek);
|
|
2237
|
+
const idle = (r) => !r.messages.length && !r.more && r.workers.length + r.starting > 0 && r.pushes_left > 0 && !r.prompted;
|
|
2238
|
+
while (idle(result) && !s.data.closed && Date.now() < deadline) {
|
|
2239
|
+
await waitFor(me, Math.min(STOP_RECHECK_MS, deadline - Date.now()), s);
|
|
2240
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2241
|
+
result = core.stopWait(me, peek);
|
|
2242
|
+
}
|
|
2243
|
+
if (waitMs > 0) await gather(me, result, peek, s);
|
|
2244
|
+
return core.stopWait(me, options2);
|
|
2245
|
+
}
|
|
2246
|
+
case "prompted": {
|
|
2247
|
+
const prompted = core.prompted(me, hookOf(s));
|
|
2248
|
+
if (prompted) wake(me);
|
|
2249
|
+
return { prompted };
|
|
2250
|
+
}
|
|
2251
|
+
case "injected":
|
|
2252
|
+
return {
|
|
2253
|
+
injected: core.injected(params.seqs, params.hold, { endpoint: me, read: params.read, deferred: params.deferred === true }),
|
|
2254
|
+
...params.stop === true ? { held: core.stopHeld(me, hookOf(s)) } : {},
|
|
2255
|
+
...params.push === true ? { pushed: core.pushed(me, params.read, hookOf(s)) } : {},
|
|
2256
|
+
...params.nudged !== void 0 ? { nudged: core.nudged(me, params.nudged, hookOf(s)) } : {},
|
|
2257
|
+
...params.restated !== void 0 ? { restated: core.restated(me, params.restated, params.read, hookOf(s)) } : {},
|
|
2258
|
+
...params.preamble === true ? { briefed: core.briefed(me, hookOf(s)) } : {},
|
|
2259
|
+
...params.intro === true ? { introduced: core.introduced(me, hookOf(s)) } : {}
|
|
2260
|
+
};
|
|
2261
|
+
case "message":
|
|
2262
|
+
return { message: core.delivered(me, params.id) ?? (params.member === true ? core.threadMessage(me, params.id) : null) };
|
|
2263
|
+
case "receipts":
|
|
2264
|
+
return core.receipts(me, params.id);
|
|
2265
|
+
}
|
|
2266
|
+
throw new RelayError4("method_not_found", `no method ${method}`);
|
|
2267
|
+
}
|
|
2268
|
+
const deliver = async (from, send, origin) => {
|
|
2269
|
+
let result;
|
|
2270
|
+
if (remoteSend(core, send.to)) {
|
|
2271
|
+
if (!bridge)
|
|
2272
|
+
throw new RelayError4("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`);
|
|
2273
|
+
result = await bridge.forward(from, send, origin);
|
|
2274
|
+
} else {
|
|
2275
|
+
result = core.send(from, send, origin);
|
|
2276
|
+
}
|
|
2277
|
+
if (!result.duplicate) {
|
|
2278
|
+
for (const delivery of result.deliveries) wake(delivery.endpoint);
|
|
2279
|
+
notifyFleet({
|
|
2280
|
+
type: "message",
|
|
2281
|
+
id: result.id,
|
|
2282
|
+
thread: result.thread,
|
|
2283
|
+
kind: send.kind ?? "message",
|
|
2284
|
+
from: core.addressOf(from),
|
|
2285
|
+
to: result.deliveries.map((d) => d.address),
|
|
2286
|
+
hop: result.hop
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
return result;
|
|
2290
|
+
};
|
|
2291
|
+
function reviewer(s, params) {
|
|
2292
|
+
if (!atTerminal(s)) throw new RelayError4("unauthorized", "only your human at a terminal reviews held messages");
|
|
2293
|
+
return params;
|
|
2294
|
+
}
|
|
2295
|
+
function observe(s, method, params) {
|
|
2296
|
+
const viewer = viewerOf(s);
|
|
2297
|
+
switch (method) {
|
|
2298
|
+
case "list":
|
|
2299
|
+
if (params.contacts === true) {
|
|
2300
|
+
if (!bridge) throw new RelayError4("unavailable", `this broker is not linked to a hub (${CLI_NAME} cloud enable), so it reaches no contacts`);
|
|
2301
|
+
return bridge.contacts().then((contacts) => ({ contacts }));
|
|
2302
|
+
}
|
|
2303
|
+
return { endpoints: core.list({ ...params, viewer, peekable: viewer === void 0 }) };
|
|
2304
|
+
case "snapshot":
|
|
2305
|
+
return {
|
|
2306
|
+
...core.snapshot({ all: params.all === true, activity: typeof params.activity === "number" ? params.activity : void 0, viewer }),
|
|
2307
|
+
broker: brokerInfo()
|
|
2308
|
+
};
|
|
2309
|
+
case "watch":
|
|
2310
|
+
s.data.watching = true;
|
|
2311
|
+
return { ...core.snapshot({ activity: 20, viewer }), broker: brokerInfo() };
|
|
2312
|
+
case "log": {
|
|
2313
|
+
return {
|
|
2314
|
+
rows: core.log({
|
|
2315
|
+
since: params.since,
|
|
2316
|
+
limit: params.limit,
|
|
2317
|
+
scope: params.scope === true,
|
|
2318
|
+
judge: params.judge === true,
|
|
2319
|
+
peek: params.peek === true,
|
|
2320
|
+
viewer
|
|
2321
|
+
})
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
case "policy": {
|
|
2325
|
+
const info = policyInfo();
|
|
2326
|
+
const text = actorOf(s).kind === "human" && info.state === "loaded" ? policyText : null;
|
|
2327
|
+
return { ...info, policy: typeof text === "string" ? JSON.parse(text) : null };
|
|
2328
|
+
}
|
|
2329
|
+
case "policy_check": {
|
|
2330
|
+
if (viewer !== void 0) core.why(actorOf(s), String(params.from), String(params.to));
|
|
2331
|
+
const info = policyInfo();
|
|
2332
|
+
if (info.error) return { allowed: false, reason: info.error, file: policyFile };
|
|
2333
|
+
const reason = core.checkPolicy(String(params.from), String(params.to));
|
|
2334
|
+
return { allowed: reason === null, reason, file: policyFile };
|
|
2335
|
+
}
|
|
2336
|
+
case "cloud_status":
|
|
2337
|
+
return bridge?.status() ?? { enabled: false };
|
|
2338
|
+
}
|
|
2339
|
+
throw new RelayError4("method_not_found", `no method ${method}`);
|
|
2340
|
+
}
|
|
2341
|
+
function scope(s, method, params) {
|
|
2342
|
+
const result = scopeCall(s, method, params);
|
|
2343
|
+
if (CHANGES_SCOPE.has(method) && params.action !== "ls" && params.action !== "show") bridge?.resync();
|
|
2344
|
+
return result;
|
|
2345
|
+
}
|
|
2346
|
+
function scopeCall(s, method, params) {
|
|
2347
|
+
const actor = actorOf(s);
|
|
2348
|
+
switch (method) {
|
|
2349
|
+
case "rules":
|
|
2350
|
+
return core.scopeView(actor, optional(params, "endpoint"));
|
|
2351
|
+
case "rules_set":
|
|
2352
|
+
return core.rulesSet(
|
|
2353
|
+
actor,
|
|
2354
|
+
compact({
|
|
2355
|
+
endpoint: optional(params, "endpoint"),
|
|
2356
|
+
in: selectors(params, "in"),
|
|
2357
|
+
out: selectors(params, "out"),
|
|
2358
|
+
scope: optional(params, "scope"),
|
|
2359
|
+
allow: selectors(params, "allow")
|
|
2360
|
+
})
|
|
2361
|
+
);
|
|
2362
|
+
case "rules_widen":
|
|
2363
|
+
return core.rulesWiden(actor, compact({ endpoint: required(params, "endpoint"), in: selectors(params, "in"), out: selectors(params, "out") }));
|
|
2364
|
+
case "rules_lift":
|
|
2365
|
+
return core.rulesLift(actor, { endpoint: required(params, "endpoint"), cap: required(params, "cap") });
|
|
2366
|
+
case "grant":
|
|
2367
|
+
return core.grant(actor, params);
|
|
2368
|
+
case "group":
|
|
2369
|
+
return core.group(actor, params);
|
|
2370
|
+
case "admin":
|
|
2371
|
+
return core.admin(actor, params);
|
|
2372
|
+
case "peers":
|
|
2373
|
+
return { peers: core.peers(viewerOf(s) ?? s.data.endpoint, { unverified: !atTerminal(s) }) };
|
|
2374
|
+
case "why":
|
|
2375
|
+
return core.why(actor, required(params, "from"), required(params, "to"));
|
|
2376
|
+
case "peek":
|
|
2377
|
+
return core.peek(actor, { endpoint: params.endpoint, turns: params.turns, full: params.full });
|
|
2378
|
+
case "peek_set":
|
|
2379
|
+
return core.peekSet(actor, { ...compact({ endpoint: optional(params, "endpoint") }), level: params.level });
|
|
2380
|
+
case "peek_who":
|
|
2381
|
+
return core.peekWho(actor, compact({ endpoint: optional(params, "endpoint") }));
|
|
2382
|
+
case "spawn_steers":
|
|
2383
|
+
return core.spawnSteers(actor, { grant: params.grant, take: params.take === true });
|
|
2384
|
+
case "spawn_exit": {
|
|
2385
|
+
const { spawner, ...result } = core.spawnExit(
|
|
2386
|
+
actor,
|
|
2387
|
+
compact({
|
|
2388
|
+
grant: params.grant,
|
|
2389
|
+
code: params.code,
|
|
2390
|
+
output: params.output,
|
|
2391
|
+
stderr: params.stderr,
|
|
2392
|
+
unapplied: params.unapplied,
|
|
2393
|
+
exits: params.exits,
|
|
2394
|
+
started: params.started
|
|
2395
|
+
})
|
|
2396
|
+
);
|
|
2397
|
+
if (result.handback) {
|
|
2398
|
+
const message = core.delivered(spawner, result.handback);
|
|
2399
|
+
wake(spawner);
|
|
2400
|
+
notifyFleet({
|
|
2401
|
+
type: "message",
|
|
2402
|
+
id: message.id,
|
|
2403
|
+
thread: message.thread,
|
|
2404
|
+
kind: message.kind,
|
|
2405
|
+
from: message.from.address,
|
|
2406
|
+
to: [message.to],
|
|
2407
|
+
hop: message.hop
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
if (result.endpoint) {
|
|
2411
|
+
notifyFleet({ type: "status", endpoint: result.endpoint.address, status: result.endpoint.status });
|
|
2412
|
+
if ((known.get(result.endpoint.id) ?? "offline") !== result.endpoint.presence) presenceChanged(result.endpoint);
|
|
2413
|
+
else bridge?.changed(result.endpoint.id);
|
|
2414
|
+
}
|
|
2415
|
+
return result;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
throw new RelayError4("method_not_found", `no method ${method}`);
|
|
2419
|
+
}
|
|
2420
|
+
function realOrSelf(path) {
|
|
2421
|
+
try {
|
|
2422
|
+
return realpathSync(path);
|
|
2423
|
+
} catch {
|
|
2424
|
+
return path;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
async function awaitReply(s, me, request, ms, sent) {
|
|
2428
|
+
const deadline = Date.now() + ms;
|
|
2429
|
+
const present = sent?.deliveries.filter((d) => !d.offline).map((d) => d.endpoint);
|
|
2430
|
+
for (; ; ) {
|
|
2431
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2432
|
+
const reply = core.replyTo(me, request);
|
|
2433
|
+
if (reply) {
|
|
2434
|
+
core.ack(me, [reply.seq]);
|
|
2435
|
+
return { ...sent, id: request, reply };
|
|
2436
|
+
}
|
|
2437
|
+
if (core.requestExpired(request)) return { ...sent, id: request, expired: true };
|
|
2438
|
+
const ended = core.requestEnded(me, request, present);
|
|
2439
|
+
if (ended) return { ...sent, id: request, pending: true, ended };
|
|
2440
|
+
if (s.data.closed || Date.now() >= deadline) return { ...sent, id: request, pending: true };
|
|
2441
|
+
await waitFor(me, Math.min(ASK_RECHECK_MS, deadline - Date.now()), s);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
async function stop() {
|
|
2445
|
+
if (stopping) return stopping;
|
|
2446
|
+
stopping = (async () => {
|
|
2447
|
+
closing = true;
|
|
2448
|
+
clearInterval(sweeper);
|
|
2449
|
+
clearInterval(presenceTimer);
|
|
2450
|
+
await bridge?.stop();
|
|
2451
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2452
|
+
for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
|
|
2453
|
+
const deadline = Date.now() + 2e3;
|
|
2454
|
+
while (pending > 0 && Date.now() < deadline) await sleep2(5);
|
|
2455
|
+
await Promise.race([judge.settled(), sleep2(Math.max(0, deadline - Date.now()))]);
|
|
2456
|
+
for (const s of connections) s.end();
|
|
2457
|
+
listener.close();
|
|
2458
|
+
try {
|
|
2459
|
+
unlinkSync2(socket);
|
|
2460
|
+
} catch {
|
|
2461
|
+
}
|
|
2462
|
+
if (socket !== join3(dir, "relay.sock")) {
|
|
2463
|
+
try {
|
|
2464
|
+
rmdirSync(dirname2(socket));
|
|
2465
|
+
} catch {
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
db.close();
|
|
2469
|
+
releaseBrokerLock(lock);
|
|
2470
|
+
resolveStopped();
|
|
2471
|
+
})();
|
|
2472
|
+
return stopping;
|
|
2473
|
+
}
|
|
2474
|
+
activity();
|
|
2475
|
+
return { socket, core, bridge, judge, policyFile, stopped, stop };
|
|
2476
|
+
}
|
|
2477
|
+
function compact(fields) {
|
|
2478
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== void 0));
|
|
2479
|
+
}
|
|
2480
|
+
function optional(params, key) {
|
|
2481
|
+
const value = params[key];
|
|
2482
|
+
if (value === void 0 || value === null) return void 0;
|
|
2483
|
+
if (typeof value !== "string") throw new RelayError4("invalid_params", `${key} must be a string`);
|
|
2484
|
+
return value;
|
|
2485
|
+
}
|
|
2486
|
+
function required(params, key) {
|
|
2487
|
+
const value = optional(params, key);
|
|
2488
|
+
if (!value) throw new RelayError4("invalid_params", `${key} is required`);
|
|
2489
|
+
return value;
|
|
2490
|
+
}
|
|
2491
|
+
function selectors(params, key) {
|
|
2492
|
+
const value = params[key];
|
|
2493
|
+
if (value === void 0 || value === null) return void 0;
|
|
2494
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new RelayError4("invalid_params", `${key} must be a list of selectors`);
|
|
2495
|
+
return value;
|
|
2496
|
+
}
|
|
2497
|
+
export {
|
|
2498
|
+
BrokerRunning,
|
|
2499
|
+
BrokerUnavailable,
|
|
2500
|
+
MAX_LINE,
|
|
2501
|
+
RelayClient,
|
|
2502
|
+
accountHome,
|
|
2503
|
+
connectBroker,
|
|
2504
|
+
localHostName,
|
|
2505
|
+
policyPath,
|
|
2506
|
+
privateDir,
|
|
2507
|
+
relayDir,
|
|
2508
|
+
relaySocketUrl,
|
|
2509
|
+
shortHostname,
|
|
2510
|
+
socketPath,
|
|
2511
|
+
startBroker,
|
|
2512
|
+
systemHostname,
|
|
2513
|
+
thisHostname
|
|
2514
|
+
};
|