@scp-dev/broker 0.1.2 → 0.1.3
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/dist/index.d.ts +19 -1
- package/dist/index.js +111 -20
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +8 -1
- package/package.json +3 -3
- package/src/bridge.ts +19 -3
- package/src/client.ts +2 -0
- package/src/index.ts +1 -0
- package/src/server.ts +62 -8
- package/src/sessions.ts +39 -0
- package/src/testing.ts +10 -1
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ interface Hello {
|
|
|
10
10
|
tty?: boolean;
|
|
11
11
|
/** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
|
|
12
12
|
transient?: boolean;
|
|
13
|
+
/** with `transient`: the session joins the relay on demand, taking `handle` when given */
|
|
14
|
+
join?: {
|
|
15
|
+
handle?: string;
|
|
16
|
+
};
|
|
13
17
|
/** a one-shot command: the broker announces no presence change when it connects or goes */
|
|
14
18
|
brief?: boolean;
|
|
15
19
|
/** no identity: list, status, watch, log and policy only */
|
|
@@ -217,6 +221,8 @@ interface Bridge {
|
|
|
217
221
|
changed(endpoint: string): void;
|
|
218
222
|
/** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
|
|
219
223
|
resync(): void;
|
|
224
|
+
/** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
|
|
225
|
+
renamed(endpoint: string, was: string): void;
|
|
220
226
|
/** a local endpoint's activity, posted to the hub */
|
|
221
227
|
activity(endpoint: string, row: ActivityRow): void;
|
|
222
228
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
@@ -423,4 +429,16 @@ interface Broker {
|
|
|
423
429
|
}
|
|
424
430
|
declare function startBroker(options: BrokerOptions): Promise<Broker>;
|
|
425
431
|
|
|
426
|
-
|
|
432
|
+
/**
|
|
433
|
+
* What the broker last decided about a harness session, by its session key: whether it joined or left, and the handle it goes by. Hooks and
|
|
434
|
+
* the MCP shim read it before they claim, so a session that never joined (manual join mode) or left stays off the relay without a broker
|
|
435
|
+
* round trip, and a renamed session claims its own name. The broker writes it; the endpoint rows stay the source of truth.
|
|
436
|
+
*/
|
|
437
|
+
interface SessionMarker {
|
|
438
|
+
joined: boolean;
|
|
439
|
+
handle?: string;
|
|
440
|
+
}
|
|
441
|
+
declare function readSessionMarker(dir: string, session: string): SessionMarker | undefined;
|
|
442
|
+
declare function writeSessionMarker(dir: string, session: string, marker: SessionMarker): void;
|
|
443
|
+
|
|
444
|
+
export { type Bridge, type BridgeConfig, type BridgeStatus, type Broker, type BrokerInfo, type BrokerOptions, BrokerRunning, BrokerUnavailable, type GrantParams, type GroupParams, type Hello, type HelloResult, MAX_LINE, RelayClient, type SessionMarker, accountHome, connectBroker, localHostName, policyPath, privateDir, readSessionMarker, relayDir, relaySocketUrl, shortHostname, socketPath, startBroker, systemHostname, thisHostname, writeSessionMarker };
|
package/dist/index.js
CHANGED
|
@@ -681,6 +681,7 @@ function startBridge(options) {
|
|
|
681
681
|
const hello = await request("hello", { protocol: PROTOCOL2, machine, bridge: true });
|
|
682
682
|
account = hello.account;
|
|
683
683
|
core.setAliases([account]);
|
|
684
|
+
core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name) => typeof name === "string") : []);
|
|
684
685
|
ready = true;
|
|
685
686
|
applyRemote(hello.endpoints, true);
|
|
686
687
|
await Promise.all(fullMirror().map((params) => request("mirror", params)));
|
|
@@ -763,7 +764,7 @@ function startBridge(options) {
|
|
|
763
764
|
function localAddress(address) {
|
|
764
765
|
if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address;
|
|
765
766
|
const rest = address.slice(account.length + 1);
|
|
766
|
-
return rest.includes("/") ?
|
|
767
|
+
return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`;
|
|
767
768
|
}
|
|
768
769
|
function cursor() {
|
|
769
770
|
return inflight.size ? Math.min(...inflight) - 1 : ackedMax;
|
|
@@ -796,6 +797,7 @@ function startBridge(options) {
|
|
|
796
797
|
}
|
|
797
798
|
case "endpoints": {
|
|
798
799
|
if (typeof params.machine !== "string" || params.machine === machine) return;
|
|
800
|
+
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
|
|
799
801
|
try {
|
|
800
802
|
core.mirror(params.machine, params.endpoints, { full: params.full === true });
|
|
801
803
|
} catch (error) {
|
|
@@ -987,6 +989,17 @@ function startBridge(options) {
|
|
|
987
989
|
}
|
|
988
990
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
|
|
989
991
|
}
|
|
992
|
+
function renamed(endpoint, was) {
|
|
993
|
+
if (!ready) return;
|
|
994
|
+
let view;
|
|
995
|
+
try {
|
|
996
|
+
view = core.whoami(endpoint);
|
|
997
|
+
} catch {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (mirrored(view)) fireAndForget("mirror", { endpoints: [{ ...outward(view), was }], full: false });
|
|
1001
|
+
else resync();
|
|
1002
|
+
}
|
|
990
1003
|
function resync() {
|
|
991
1004
|
if (!ready) return;
|
|
992
1005
|
let frames;
|
|
@@ -1034,15 +1047,15 @@ function startBridge(options) {
|
|
|
1034
1047
|
if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
|
|
1035
1048
|
}
|
|
1036
1049
|
connect();
|
|
1037
|
-
return { forward, changed, resync, activity, contacts, status, relink, stop };
|
|
1050
|
+
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
|
|
1038
1051
|
}
|
|
1039
1052
|
|
|
1040
1053
|
// src/server.ts
|
|
1041
|
-
import { createHash as
|
|
1042
|
-
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as
|
|
1054
|
+
import { createHash as createHash3 } from "crypto";
|
|
1055
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
1043
1056
|
import { createServer } from "net";
|
|
1044
1057
|
import { homedir as homedir2 } from "os";
|
|
1045
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
1058
|
+
import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
|
|
1046
1059
|
import { setTimeout as sleep2 } from "timers/promises";
|
|
1047
1060
|
import {
|
|
1048
1061
|
BROKER_CAPABILITIES,
|
|
@@ -1514,6 +1527,34 @@ function heldResult(id, params) {
|
|
|
1514
1527
|
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
|
|
1515
1528
|
}
|
|
1516
1529
|
|
|
1530
|
+
// src/sessions.ts
|
|
1531
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
1532
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
|
|
1533
|
+
import { join as join3 } from "path";
|
|
1534
|
+
function markerPath(dir, session) {
|
|
1535
|
+
return join3(dir, "sessions", `${createHash2("sha256").update(session).digest("hex").slice(0, 32)}.json`);
|
|
1536
|
+
}
|
|
1537
|
+
function readSessionMarker(dir, session) {
|
|
1538
|
+
try {
|
|
1539
|
+
const value = JSON.parse(readFileSync5(markerPath(dir, session), "utf8"));
|
|
1540
|
+
if (typeof value?.joined !== "boolean") return void 0;
|
|
1541
|
+
return { joined: value.joined, ...typeof value.handle === "string" ? { handle: value.handle } : {} };
|
|
1542
|
+
} catch {
|
|
1543
|
+
return void 0;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function writeSessionMarker(dir, session, marker) {
|
|
1547
|
+
const path = markerPath(dir, session);
|
|
1548
|
+
mkdirSync2(join3(dir, "sessions"), { recursive: true, mode: 448 });
|
|
1549
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
1550
|
+
try {
|
|
1551
|
+
writeFileSync(temp, JSON.stringify(marker), { mode: 384 });
|
|
1552
|
+
renameSync2(temp, path);
|
|
1553
|
+
} finally {
|
|
1554
|
+
rmSync(temp, { force: true });
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1517
1558
|
// src/sqlite.ts
|
|
1518
1559
|
import { createRequire } from "module";
|
|
1519
1560
|
var STATEMENT_CACHE = 512;
|
|
@@ -1609,7 +1650,7 @@ var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
|
1609
1650
|
var CHANGES_SCOPE = /* @__PURE__ */ new Set(["rules_set", "rules_widen", "rules_lift", "group"]);
|
|
1610
1651
|
var ASK_RECHECK_MS = 1e3;
|
|
1611
1652
|
var STOP_RECHECK_MS = 1e3;
|
|
1612
|
-
var TRANSIENT_METHODS = /* @__PURE__ */ new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"]);
|
|
1653
|
+
var TRANSIENT_METHODS = /* @__PURE__ */ new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted", "title_infer"]);
|
|
1613
1654
|
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)`;
|
|
1614
1655
|
function workerResults(result) {
|
|
1615
1656
|
return result.messages.length - (result.others_shown ?? 0) + result.more;
|
|
@@ -1631,7 +1672,7 @@ function hookOf(s) {
|
|
|
1631
1672
|
}
|
|
1632
1673
|
async function startBroker(options) {
|
|
1633
1674
|
const dir = privateDir(options.dir);
|
|
1634
|
-
const lock = takeBrokerLock(
|
|
1675
|
+
const lock = takeBrokerLock(join4(dir, "broker.lock"));
|
|
1635
1676
|
const socket = socketPath(dir);
|
|
1636
1677
|
const connections = /* @__PURE__ */ new Set();
|
|
1637
1678
|
const waiters = /* @__PURE__ */ new Map();
|
|
@@ -1659,16 +1700,16 @@ async function startBroker(options) {
|
|
|
1659
1700
|
let listener;
|
|
1660
1701
|
try {
|
|
1661
1702
|
privateDir(dirname2(socket));
|
|
1662
|
-
db = openDatabase(
|
|
1703
|
+
db = openDatabase(join4(dir, "relay.db"));
|
|
1663
1704
|
db.exec("PRAGMA journal_mode = WAL");
|
|
1664
1705
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
1665
|
-
chmodSync2(
|
|
1706
|
+
chmodSync2(join4(dir, "relay.db"), 384);
|
|
1666
1707
|
const sql = db;
|
|
1667
1708
|
const machine = options.machine ?? options.bridge?.machine;
|
|
1668
1709
|
core = new FederatedCore(sql, {
|
|
1669
1710
|
...compact({ user: options.user, machine, now: options.now }),
|
|
1670
1711
|
hostname: shortHostname(),
|
|
1671
|
-
hash: (value) =>
|
|
1712
|
+
hash: (value) => createHash3("sha256").update(value).digest("hex"),
|
|
1672
1713
|
transaction: (fn) => db.transaction(fn),
|
|
1673
1714
|
alive: running
|
|
1674
1715
|
});
|
|
@@ -1724,6 +1765,7 @@ async function startBroker(options) {
|
|
|
1724
1765
|
const known = /* @__PURE__ */ new Map();
|
|
1725
1766
|
for (const view of localViews()) known.set(view.id, view.presence);
|
|
1726
1767
|
const presenceTimer = setInterval(() => {
|
|
1768
|
+
core.endExited();
|
|
1727
1769
|
const live = /* @__PURE__ */ new Set();
|
|
1728
1770
|
for (const view of localViews()) {
|
|
1729
1771
|
live.add(view.id);
|
|
@@ -1763,7 +1805,7 @@ async function startBroker(options) {
|
|
|
1763
1805
|
function loadPolicy() {
|
|
1764
1806
|
let text;
|
|
1765
1807
|
try {
|
|
1766
|
-
text =
|
|
1808
|
+
text = readFileSync6(policyFile, "utf8");
|
|
1767
1809
|
} catch (error) {
|
|
1768
1810
|
const code = error.code;
|
|
1769
1811
|
const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
|
|
@@ -1828,6 +1870,20 @@ async function startBroker(options) {
|
|
|
1828
1870
|
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
1829
1871
|
bridge?.changed(view.id);
|
|
1830
1872
|
}
|
|
1873
|
+
function membership(view, session, joined, was) {
|
|
1874
|
+
const handle = view.address.slice(view.address.lastIndexOf("/") + 1);
|
|
1875
|
+
if (session && !session.startsWith("sub:")) {
|
|
1876
|
+
try {
|
|
1877
|
+
writeSessionMarker(dir, session, { joined, handle });
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
log(`relay broker: the session marker was not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
known.set(view.id, view.presence);
|
|
1883
|
+
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
1884
|
+
if (was !== void 0 && was !== handle) bridge?.renamed(view.id, was);
|
|
1885
|
+
else bridge?.changed(view.id);
|
|
1886
|
+
}
|
|
1831
1887
|
function notifyFleet(event) {
|
|
1832
1888
|
const at = new Date((options.now ?? Date.now)()).toISOString();
|
|
1833
1889
|
let line;
|
|
@@ -2081,13 +2137,18 @@ async function startBroker(options) {
|
|
|
2081
2137
|
if (params.human === true || params.observe === true || params.resume !== void 0) {
|
|
2082
2138
|
throw new RelayError4("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume");
|
|
2083
2139
|
}
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2140
|
+
const join5 = params.join;
|
|
2141
|
+
if (join5 !== void 0 && (typeof join5 !== "object" || join5 === null || Array.isArray(join5)))
|
|
2142
|
+
throw new RelayError4("invalid_params", "join is {handle?}");
|
|
2143
|
+
const before2 = new Map(core.list({ all: true }).map((view) => [view.id, view]));
|
|
2144
|
+
const { endpoint: endpoint2, subagent, created } = join5 === void 0 ? core.claim(params.claim ?? {}, { transient: true }) : { endpoint: core.joinSession(params.claim ?? {}, join5.handle), subagent: void 0, created: void 0 };
|
|
2086
2145
|
s.data.endpoint = endpoint2.id;
|
|
2087
2146
|
s.data.transient = true;
|
|
2088
2147
|
const key = params.claim?.process;
|
|
2089
2148
|
if (typeof key === "string") s.data.process = key;
|
|
2090
|
-
|
|
2149
|
+
const was2 = before2.get(endpoint2.id)?.address;
|
|
2150
|
+
if (join5 !== void 0) membership(endpoint2, core.sessionOf(endpoint2.id), true, was2?.slice(was2.lastIndexOf("/") + 1));
|
|
2151
|
+
else if ((before2.get(endpoint2.id)?.presence ?? "offline") !== endpoint2.presence) presenceChanged(endpoint2);
|
|
2091
2152
|
return {
|
|
2092
2153
|
endpoint: endpoint2,
|
|
2093
2154
|
...subagent ? { subagent, ...created ? { created } : {} } : {},
|
|
@@ -2226,6 +2287,22 @@ async function startBroker(options) {
|
|
|
2226
2287
|
bridge?.changed(me);
|
|
2227
2288
|
return view;
|
|
2228
2289
|
}
|
|
2290
|
+
case "title_infer": {
|
|
2291
|
+
const changed = core.inferTitle(me, params, hookOf(s));
|
|
2292
|
+
if (changed) bridge?.changed(me);
|
|
2293
|
+
return { changed };
|
|
2294
|
+
}
|
|
2295
|
+
case "rename": {
|
|
2296
|
+
const renamed = core.rename(actorOf(s), { ...params.endpoint !== void 0 ? { endpoint: params.endpoint } : {}, handle: params.handle });
|
|
2297
|
+
membership(renamed.endpoint, renamed.session, true, renamed.from);
|
|
2298
|
+
return renamed.endpoint;
|
|
2299
|
+
}
|
|
2300
|
+
case "leave": {
|
|
2301
|
+
const left = core.leaveSession(actorOf(s), params.endpoint !== void 0 ? { endpoint: params.endpoint } : {});
|
|
2302
|
+
for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end();
|
|
2303
|
+
membership(left.endpoint, left.session, false);
|
|
2304
|
+
return left.endpoint;
|
|
2305
|
+
}
|
|
2229
2306
|
case "status": {
|
|
2230
2307
|
core.status(me, params.state, params.note);
|
|
2231
2308
|
const view = core.whoami(me);
|
|
@@ -2241,14 +2318,25 @@ async function startBroker(options) {
|
|
|
2241
2318
|
if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
|
|
2242
2319
|
return row;
|
|
2243
2320
|
}
|
|
2244
|
-
case "inject":
|
|
2245
|
-
|
|
2321
|
+
case "inject": {
|
|
2322
|
+
const waitMs = waitSeconds(params.wait_s);
|
|
2323
|
+
const options2 = {
|
|
2246
2324
|
stop: params.stop === true,
|
|
2247
2325
|
count: params.count === true,
|
|
2248
2326
|
holdUnread: params.hold_unread === true,
|
|
2249
2327
|
directives: params.directives === true,
|
|
2328
|
+
wake: waitMs > 0 && params.stop !== true && params.count !== true,
|
|
2250
2329
|
...hookOf(s)
|
|
2251
|
-
}
|
|
2330
|
+
};
|
|
2331
|
+
const deadline = Date.now() + (options2.wake ? waitMs : 0);
|
|
2332
|
+
let result = core.inject(me, options2);
|
|
2333
|
+
while (!result.offered.length && !s.data.closed && Date.now() < deadline) {
|
|
2334
|
+
await waitFor(me, deadline - Date.now(), s);
|
|
2335
|
+
if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
|
|
2336
|
+
result = core.inject(me, options2);
|
|
2337
|
+
}
|
|
2338
|
+
return result;
|
|
2339
|
+
}
|
|
2252
2340
|
case "stop_wait": {
|
|
2253
2341
|
const waitMs = waitSeconds(params.wait_s);
|
|
2254
2342
|
const deadline = Date.now() + waitMs;
|
|
@@ -2267,7 +2355,8 @@ async function startBroker(options) {
|
|
|
2267
2355
|
case "prompted": {
|
|
2268
2356
|
const prompted = core.prompted(me, hookOf(s));
|
|
2269
2357
|
if (prompted) wake(me);
|
|
2270
|
-
|
|
2358
|
+
const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every);
|
|
2359
|
+
return { prompted, ...tldr ? { tldr } : {} };
|
|
2271
2360
|
}
|
|
2272
2361
|
case "injected":
|
|
2273
2362
|
return {
|
|
@@ -2482,7 +2571,7 @@ async function startBroker(options) {
|
|
|
2482
2571
|
unlinkSync2(socket);
|
|
2483
2572
|
} catch {
|
|
2484
2573
|
}
|
|
2485
|
-
if (socket !==
|
|
2574
|
+
if (socket !== join4(dir, "relay.sock")) {
|
|
2486
2575
|
try {
|
|
2487
2576
|
rmdirSync(dirname2(socket));
|
|
2488
2577
|
} catch {
|
|
@@ -2527,11 +2616,13 @@ export {
|
|
|
2527
2616
|
localHostName,
|
|
2528
2617
|
policyPath,
|
|
2529
2618
|
privateDir,
|
|
2619
|
+
readSessionMarker,
|
|
2530
2620
|
relayDir,
|
|
2531
2621
|
relaySocketUrl,
|
|
2532
2622
|
shortHostname,
|
|
2533
2623
|
socketPath,
|
|
2534
2624
|
startBroker,
|
|
2535
2625
|
systemHostname,
|
|
2536
|
-
thisHostname
|
|
2626
|
+
thisHostname,
|
|
2627
|
+
writeSessionMarker
|
|
2537
2628
|
};
|
package/dist/testing.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ interface FakeToken {
|
|
|
4
4
|
tokenId: string;
|
|
5
5
|
/** the account handle the token acts as */
|
|
6
6
|
user: string;
|
|
7
|
+
/** the org member the token belongs to; none is the personal account's owner */
|
|
8
|
+
actor?: string;
|
|
7
9
|
}
|
|
8
10
|
interface FakeHubServer {
|
|
9
11
|
/** the http origin, as a bridge's url takes it */
|
package/dist/testing.js
CHANGED
|
@@ -50,7 +50,14 @@ function startFakeHubServer(options) {
|
|
|
50
50
|
if (!token) return null;
|
|
51
51
|
return {
|
|
52
52
|
account: token.user,
|
|
53
|
-
grant: {
|
|
53
|
+
grant: {
|
|
54
|
+
tokenId: token.tokenId,
|
|
55
|
+
userId: `usr_${token.user}`,
|
|
56
|
+
actorId: token.actor ?? null,
|
|
57
|
+
expiresAt: Date.now() + 36e5,
|
|
58
|
+
scoped: false,
|
|
59
|
+
role: token.actor ? "member" : null
|
|
60
|
+
}
|
|
54
61
|
};
|
|
55
62
|
}
|
|
56
63
|
const server = Bun.serve({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/broker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"typecheck": "tsc -p ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@scp-dev/core": "0.1.
|
|
41
|
-
"@scp-dev/hub": "0.
|
|
40
|
+
"@scp-dev/core": "0.1.2",
|
|
41
|
+
"@scp-dev/hub": "0.2.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.1"
|
package/src/bridge.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface Bridge {
|
|
|
73
73
|
changed(endpoint: string): void
|
|
74
74
|
/** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
|
|
75
75
|
resync(): void
|
|
76
|
+
/** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
|
|
77
|
+
renamed(endpoint: string, was: string): void
|
|
76
78
|
/** a local endpoint's activity, posted to the hub */
|
|
77
79
|
activity(endpoint: string, row: ActivityRow): void
|
|
78
80
|
/** the endpoints of other accounts this account's contacts let it reach */
|
|
@@ -257,9 +259,10 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
async function handshake(socket: WebSocket) {
|
|
260
|
-
const hello = await request<{ account: string; endpoints: RemoteView[] }>("hello", { protocol: PROTOCOL, machine, bridge: true })
|
|
262
|
+
const hello = await request<{ account: string; endpoints: RemoteView[]; others?: string[] }>("hello", { protocol: PROTOCOL, machine, bridge: true })
|
|
261
263
|
account = hello.account
|
|
262
264
|
core.setAliases([account])
|
|
265
|
+
core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name): name is string => typeof name === "string") : [])
|
|
263
266
|
ready = true
|
|
264
267
|
applyRemote(hello.endpoints, true)
|
|
265
268
|
await Promise.all(fullMirror().map((params) => request("mirror", params)))
|
|
@@ -354,7 +357,7 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
354
357
|
function localAddress(address: unknown): unknown {
|
|
355
358
|
if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address
|
|
356
359
|
const rest = address.slice(account.length + 1)
|
|
357
|
-
return rest.includes("/") ?
|
|
360
|
+
return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`
|
|
358
361
|
}
|
|
359
362
|
|
|
360
363
|
/** the lowest seq a fresh subscribe must replay from: every delivery at or below it was acked */
|
|
@@ -391,6 +394,7 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
391
394
|
}
|
|
392
395
|
case "endpoints": {
|
|
393
396
|
if (typeof params.machine !== "string" || params.machine === machine) return
|
|
397
|
+
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
|
|
394
398
|
try {
|
|
395
399
|
core.mirror(params.machine, params.endpoints, { full: params.full === true })
|
|
396
400
|
} catch (error) {
|
|
@@ -601,6 +605,18 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
601
605
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
|
|
602
606
|
}
|
|
603
607
|
|
|
608
|
+
function renamed(endpoint: string, was: string) {
|
|
609
|
+
if (!ready) return
|
|
610
|
+
let view: EndpointView
|
|
611
|
+
try {
|
|
612
|
+
view = core.whoami(endpoint)
|
|
613
|
+
} catch {
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
if (mirrored(view)) fireAndForget("mirror", { endpoints: [{ ...outward(view), was }], full: false })
|
|
617
|
+
else resync()
|
|
618
|
+
}
|
|
619
|
+
|
|
604
620
|
function resync() {
|
|
605
621
|
if (!ready) return
|
|
606
622
|
let frames: ReturnType<typeof fullMirror>
|
|
@@ -654,5 +670,5 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
654
670
|
}
|
|
655
671
|
|
|
656
672
|
connect()
|
|
657
|
-
return { forward, changed, resync, activity, contacts, status, relink, stop }
|
|
673
|
+
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
|
|
658
674
|
}
|
package/src/client.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface Hello {
|
|
|
31
31
|
tty?: boolean
|
|
32
32
|
/** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
|
|
33
33
|
transient?: boolean
|
|
34
|
+
/** with `transient`: the session joins the relay on demand, taking `handle` when given */
|
|
35
|
+
join?: { handle?: string }
|
|
34
36
|
/** a one-shot command: the broker announces no presence change when it connects or goes */
|
|
35
37
|
brief?: boolean
|
|
36
38
|
/** no identity: list, status, watch, log and policy only */
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { BrokerRunning } from "./lock"
|
|
|
4
4
|
export { MAX_LINE } from "./ndjson"
|
|
5
5
|
export { accountHome, localHostName, policyPath, privateDir, relayDir, shortHostname, socketPath, systemHostname, thisHostname } from "./paths"
|
|
6
6
|
export { startBroker, type Broker, type BrokerOptions } from "./server"
|
|
7
|
+
export { readSessionMarker, type SessionMarker, writeSessionMarker } from "./sessions"
|
package/src/server.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { type Deliver, type JudgeClientFactory, JudgeGate } from "./judge-gate"
|
|
|
31
31
|
import { releaseBrokerLock, takeBrokerLock } from "./lock"
|
|
32
32
|
import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
|
|
33
33
|
import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, privateDir, shortHostname, socketPath } from "./paths"
|
|
34
|
+
import { writeSessionMarker } from "./sessions"
|
|
34
35
|
import { Conn } from "./socket"
|
|
35
36
|
import { type Database, openDatabase } from "./sqlite"
|
|
36
37
|
|
|
@@ -134,7 +135,7 @@ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"
|
|
|
134
135
|
const ASK_RECHECK_MS = 1000
|
|
135
136
|
/** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
|
|
136
137
|
const STOP_RECHECK_MS = 1000
|
|
137
|
-
const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"])
|
|
138
|
+
const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted", "title_infer"])
|
|
138
139
|
const 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)`
|
|
139
140
|
|
|
140
141
|
/** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
|
|
@@ -260,6 +261,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
260
261
|
const known = new Map<string, Presence>()
|
|
261
262
|
for (const view of localViews()) known.set(view.id, view.presence)
|
|
262
263
|
const presenceTimer = setInterval(() => {
|
|
264
|
+
core.endExited()
|
|
263
265
|
const live = new Set<string>()
|
|
264
266
|
for (const view of localViews()) {
|
|
265
267
|
live.add(view.id)
|
|
@@ -383,6 +385,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
383
385
|
bridge?.changed(view.id)
|
|
384
386
|
}
|
|
385
387
|
|
|
388
|
+
/** after a join, rename or leave: the session's marker, the fleet and the hub's mirror learn it; `was` is the handle before */
|
|
389
|
+
function membership(view: EndpointView, session: string | null, joined: boolean, was?: string) {
|
|
390
|
+
const handle = view.address.slice(view.address.lastIndexOf("/") + 1)
|
|
391
|
+
if (session && !session.startsWith("sub:")) {
|
|
392
|
+
try {
|
|
393
|
+
writeSessionMarker(dir, session, { joined, handle })
|
|
394
|
+
} catch (error) {
|
|
395
|
+
log(`relay broker: the session marker was not written: ${error instanceof Error ? error.message : String(error)}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
known.set(view.id, view.presence)
|
|
399
|
+
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
|
|
400
|
+
if (was !== undefined && was !== handle) bridge?.renamed(view.id, was)
|
|
401
|
+
else bridge?.changed(view.id)
|
|
402
|
+
}
|
|
403
|
+
|
|
386
404
|
function notifyFleet(event: Record<string, unknown>) {
|
|
387
405
|
const at = new Date((options.now ?? Date.now)()).toISOString()
|
|
388
406
|
let line: Uint8Array | undefined
|
|
@@ -677,13 +695,21 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
677
695
|
if (params.human === true || params.observe === true || params.resume !== undefined) {
|
|
678
696
|
throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
|
|
679
697
|
}
|
|
680
|
-
const
|
|
681
|
-
|
|
698
|
+
const join = params.join
|
|
699
|
+
if (join !== undefined && (typeof join !== "object" || join === null || Array.isArray(join)))
|
|
700
|
+
throw new RelayError("invalid_params", "join is {handle?}")
|
|
701
|
+
const before = new Map(core.list({ all: true }).map((view) => [view.id, view]))
|
|
702
|
+
const { endpoint, subagent, created } =
|
|
703
|
+
join === undefined
|
|
704
|
+
? core.claim((params.claim ?? {}) as Claim, { transient: true })
|
|
705
|
+
: { endpoint: core.joinSession((params.claim ?? {}) as Claim, (join as { handle?: unknown }).handle), subagent: undefined, created: undefined }
|
|
682
706
|
s.data.endpoint = endpoint.id
|
|
683
707
|
s.data.transient = true
|
|
684
708
|
const key = (params.claim as Claim | undefined)?.process
|
|
685
709
|
if (typeof key === "string") s.data.process = key
|
|
686
|
-
|
|
710
|
+
const was = before.get(endpoint.id)?.address
|
|
711
|
+
if (join !== undefined) membership(endpoint, core.sessionOf(endpoint.id), true, was?.slice(was.lastIndexOf("/") + 1))
|
|
712
|
+
else if ((before.get(endpoint.id)?.presence ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
|
|
687
713
|
return {
|
|
688
714
|
endpoint,
|
|
689
715
|
...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
|
|
@@ -822,6 +848,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
822
848
|
bridge?.changed(me)
|
|
823
849
|
return view
|
|
824
850
|
}
|
|
851
|
+
case "title_infer": {
|
|
852
|
+
const changed = core.inferTitle(me, params, hookOf(s))
|
|
853
|
+
if (changed) bridge?.changed(me)
|
|
854
|
+
return { changed }
|
|
855
|
+
}
|
|
856
|
+
case "rename": {
|
|
857
|
+
const renamed = core.rename(actorOf(s), { ...(params.endpoint !== undefined ? { endpoint: params.endpoint } : {}), handle: params.handle })
|
|
858
|
+
membership(renamed.endpoint, renamed.session, true, renamed.from)
|
|
859
|
+
return renamed.endpoint
|
|
860
|
+
}
|
|
861
|
+
case "leave": {
|
|
862
|
+
const left = core.leaveSession(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
|
|
863
|
+
for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end()
|
|
864
|
+
membership(left.endpoint, left.session, false)
|
|
865
|
+
return left.endpoint
|
|
866
|
+
}
|
|
825
867
|
case "status": {
|
|
826
868
|
core.status(me, params.state, params.note)
|
|
827
869
|
const view = core.whoami(me)
|
|
@@ -837,14 +879,25 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
837
879
|
if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
|
|
838
880
|
return row
|
|
839
881
|
}
|
|
840
|
-
case "inject":
|
|
841
|
-
|
|
882
|
+
case "inject": {
|
|
883
|
+
const waitMs = waitSeconds(params.wait_s)
|
|
884
|
+
const options = {
|
|
842
885
|
stop: params.stop === true,
|
|
843
886
|
count: params.count === true,
|
|
844
887
|
holdUnread: params.hold_unread === true,
|
|
845
888
|
directives: params.directives === true,
|
|
889
|
+
wake: waitMs > 0 && params.stop !== true && params.count !== true,
|
|
846
890
|
...hookOf(s),
|
|
847
|
-
}
|
|
891
|
+
}
|
|
892
|
+
const deadline = Date.now() + (options.wake ? waitMs : 0)
|
|
893
|
+
let result = core.inject(me, options)
|
|
894
|
+
while (!result.offered.length && !s.data.closed && Date.now() < deadline) {
|
|
895
|
+
await waitFor(me, deadline - Date.now(), s)
|
|
896
|
+
if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
|
|
897
|
+
result = core.inject(me, options)
|
|
898
|
+
}
|
|
899
|
+
return result
|
|
900
|
+
}
|
|
848
901
|
case "stop_wait": {
|
|
849
902
|
const waitMs = waitSeconds(params.wait_s)
|
|
850
903
|
const deadline = Date.now() + waitMs
|
|
@@ -863,7 +916,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
863
916
|
case "prompted": {
|
|
864
917
|
const prompted = core.prompted(me, hookOf(s))
|
|
865
918
|
if (prompted) wake(me)
|
|
866
|
-
|
|
919
|
+
const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every)
|
|
920
|
+
return { prompted, ...(tldr ? { tldr } : {}) }
|
|
867
921
|
}
|
|
868
922
|
case "injected":
|
|
869
923
|
return {
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the broker last decided about a harness session, by its session key: whether it joined or left, and the handle it goes by. Hooks and
|
|
7
|
+
* the MCP shim read it before they claim, so a session that never joined (manual join mode) or left stays off the relay without a broker
|
|
8
|
+
* round trip, and a renamed session claims its own name. The broker writes it; the endpoint rows stay the source of truth.
|
|
9
|
+
*/
|
|
10
|
+
export interface SessionMarker {
|
|
11
|
+
joined: boolean
|
|
12
|
+
handle?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function markerPath(dir: string, session: string): string {
|
|
16
|
+
return join(dir, "sessions", `${createHash("sha256").update(session).digest("hex").slice(0, 32)}.json`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readSessionMarker(dir: string, session: string): SessionMarker | undefined {
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(readFileSync(markerPath(dir, session), "utf8")) as Partial<SessionMarker>
|
|
22
|
+
if (typeof value?.joined !== "boolean") return undefined
|
|
23
|
+
return { joined: value.joined, ...(typeof value.handle === "string" ? { handle: value.handle } : {}) }
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeSessionMarker(dir: string, session: string, marker: SessionMarker): void {
|
|
30
|
+
const path = markerPath(dir, session)
|
|
31
|
+
mkdirSync(join(dir, "sessions"), { recursive: true, mode: 0o700 })
|
|
32
|
+
const temp = `${path}.${randomUUID()}.tmp`
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(temp, JSON.stringify(marker), { mode: 0o600 })
|
|
35
|
+
renameSync(temp, path)
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(temp, { force: true })
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/testing.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface FakeToken {
|
|
|
7
7
|
tokenId: string
|
|
8
8
|
/** the account handle the token acts as */
|
|
9
9
|
user: string
|
|
10
|
+
/** the org member the token belongs to; none is the personal account's owner */
|
|
11
|
+
actor?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface FakeHubServer {
|
|
@@ -88,7 +90,14 @@ export function startFakeHubServer(options: {
|
|
|
88
90
|
if (!token) return null
|
|
89
91
|
return {
|
|
90
92
|
account: token.user,
|
|
91
|
-
grant: {
|
|
93
|
+
grant: {
|
|
94
|
+
tokenId: token.tokenId,
|
|
95
|
+
userId: `usr_${token.user}`,
|
|
96
|
+
actorId: token.actor ?? null,
|
|
97
|
+
expiresAt: Date.now() + 3_600_000,
|
|
98
|
+
scoped: false,
|
|
99
|
+
role: token.actor ? "member" : null,
|
|
100
|
+
},
|
|
92
101
|
}
|
|
93
102
|
}
|
|
94
103
|
|