@scp-dev/broker 0.1.1 → 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 +23 -1
- package/dist/index.js +136 -22
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +8 -1
- package/package.json +3 -3
- package/src/bridge.ts +36 -4
- package/src/client.ts +2 -0
- package/src/index.ts +1 -0
- package/src/server.ts +78 -9
- 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,11 +221,15 @@ 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 */
|
|
223
229
|
contacts(): Promise<XListed[]>;
|
|
224
230
|
status(): BridgeStatus;
|
|
231
|
+
/** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
|
|
232
|
+
relink(): void;
|
|
225
233
|
stop(): Promise<void>;
|
|
226
234
|
}
|
|
227
235
|
/** a WebSocket constructor that takes request headers, as Bun's and Node's do */
|
|
@@ -401,6 +409,8 @@ interface BrokerOptions {
|
|
|
401
409
|
machine?: string | undefined;
|
|
402
410
|
/** the link to the hub; without it the broker never opens a network connection */
|
|
403
411
|
bridge?: BridgeConfig | undefined;
|
|
412
|
+
/** reads the bridge configuration again for bridge_relink; one that appeared or names another hub or machine restarts the broker */
|
|
413
|
+
bridgeConfig?: (() => Promise<BridgeConfig | undefined>) | undefined;
|
|
404
414
|
/** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
|
|
405
415
|
presenceEveryMs?: number | undefined;
|
|
406
416
|
/** where a judge model's key_env is read (default the broker's environment) */
|
|
@@ -419,4 +429,16 @@ interface Broker {
|
|
|
419
429
|
}
|
|
420
430
|
declare function startBroker(options: BrokerOptions): Promise<Broker>;
|
|
421
431
|
|
|
422
|
-
|
|
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
|
@@ -563,6 +563,7 @@ function startBridge(options) {
|
|
|
563
563
|
let connected = false;
|
|
564
564
|
let ready = false;
|
|
565
565
|
let stopped = false;
|
|
566
|
+
let shutDown = false;
|
|
566
567
|
let lastError = options.token ? null : options.tokenError ?? "no hub credential; the broker runs local-only";
|
|
567
568
|
let since = (/* @__PURE__ */ new Date()).toISOString();
|
|
568
569
|
let attempt = 0;
|
|
@@ -603,7 +604,7 @@ function startBridge(options) {
|
|
|
603
604
|
});
|
|
604
605
|
}
|
|
605
606
|
function connect() {
|
|
606
|
-
if (stopped || !token) return;
|
|
607
|
+
if (stopped || !token || ws) return;
|
|
607
608
|
let socket;
|
|
608
609
|
try {
|
|
609
610
|
socket = new Socket(relaySocketUrl(options.url), { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -680,6 +681,7 @@ function startBridge(options) {
|
|
|
680
681
|
const hello = await request("hello", { protocol: PROTOCOL2, machine, bridge: true });
|
|
681
682
|
account = hello.account;
|
|
682
683
|
core.setAliases([account]);
|
|
684
|
+
core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name) => typeof name === "string") : []);
|
|
683
685
|
ready = true;
|
|
684
686
|
applyRemote(hello.endpoints, true);
|
|
685
687
|
await Promise.all(fullMirror().map((params) => request("mirror", params)));
|
|
@@ -762,7 +764,7 @@ function startBridge(options) {
|
|
|
762
764
|
function localAddress(address) {
|
|
763
765
|
if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address;
|
|
764
766
|
const rest = address.slice(account.length + 1);
|
|
765
|
-
return rest.includes("/") ?
|
|
767
|
+
return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`;
|
|
766
768
|
}
|
|
767
769
|
function cursor() {
|
|
768
770
|
return inflight.size ? Math.min(...inflight) - 1 : ackedMax;
|
|
@@ -795,6 +797,7 @@ function startBridge(options) {
|
|
|
795
797
|
}
|
|
796
798
|
case "endpoints": {
|
|
797
799
|
if (typeof params.machine !== "string" || params.machine === machine) return;
|
|
800
|
+
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
|
|
798
801
|
try {
|
|
799
802
|
core.mirror(params.machine, params.endpoints, { full: params.full === true });
|
|
800
803
|
} catch (error) {
|
|
@@ -986,6 +989,17 @@ function startBridge(options) {
|
|
|
986
989
|
}
|
|
987
990
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
|
|
988
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
|
+
}
|
|
989
1003
|
function resync() {
|
|
990
1004
|
if (!ready) return;
|
|
991
1005
|
let frames;
|
|
@@ -1009,8 +1023,19 @@ function startBridge(options) {
|
|
|
1009
1023
|
function status() {
|
|
1010
1024
|
return { enabled: true, machine, url: options.url, connected, last_error: lastError, since };
|
|
1011
1025
|
}
|
|
1026
|
+
function relink() {
|
|
1027
|
+
if (shutDown) return;
|
|
1028
|
+
stopped = false;
|
|
1029
|
+
attempt = 0;
|
|
1030
|
+
clearTimeout(retryTimer);
|
|
1031
|
+
if (ws) return;
|
|
1032
|
+
void reconnect().then(() => {
|
|
1033
|
+
if (!token && !ws) setState(false, "no hub credential; log in again");
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1012
1036
|
async function stop() {
|
|
1013
1037
|
stopped = true;
|
|
1038
|
+
shutDown = true;
|
|
1014
1039
|
clearTimeout(retryTimer);
|
|
1015
1040
|
for (const fn of waitingForLink.splice(0)) fn();
|
|
1016
1041
|
const socket = ws;
|
|
@@ -1022,15 +1047,15 @@ function startBridge(options) {
|
|
|
1022
1047
|
if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
|
|
1023
1048
|
}
|
|
1024
1049
|
connect();
|
|
1025
|
-
return { forward, changed, resync, activity, contacts, status, stop };
|
|
1050
|
+
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
|
|
1026
1051
|
}
|
|
1027
1052
|
|
|
1028
1053
|
// src/server.ts
|
|
1029
|
-
import { createHash as
|
|
1030
|
-
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";
|
|
1031
1056
|
import { createServer } from "net";
|
|
1032
1057
|
import { homedir as homedir2 } from "os";
|
|
1033
|
-
import { dirname as dirname2, isAbsolute, join as
|
|
1058
|
+
import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
|
|
1034
1059
|
import { setTimeout as sleep2 } from "timers/promises";
|
|
1035
1060
|
import {
|
|
1036
1061
|
BROKER_CAPABILITIES,
|
|
@@ -1502,6 +1527,34 @@ function heldResult(id, params) {
|
|
|
1502
1527
|
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
|
|
1503
1528
|
}
|
|
1504
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
|
+
|
|
1505
1558
|
// src/sqlite.ts
|
|
1506
1559
|
import { createRequire } from "module";
|
|
1507
1560
|
var STATEMENT_CACHE = 512;
|
|
@@ -1577,7 +1630,7 @@ function openDatabase(path) {
|
|
|
1577
1630
|
// src/server.ts
|
|
1578
1631
|
var SWEEP_MS = 10 * 6e4;
|
|
1579
1632
|
var PRESENCE_MS = 6e4;
|
|
1580
|
-
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"]);
|
|
1633
|
+
var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
|
|
1581
1634
|
var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
1582
1635
|
"rules",
|
|
1583
1636
|
"rules_set",
|
|
@@ -1597,7 +1650,7 @@ var SCOPE_METHODS = /* @__PURE__ */ new Set([
|
|
|
1597
1650
|
var CHANGES_SCOPE = /* @__PURE__ */ new Set(["rules_set", "rules_widen", "rules_lift", "group"]);
|
|
1598
1651
|
var ASK_RECHECK_MS = 1e3;
|
|
1599
1652
|
var STOP_RECHECK_MS = 1e3;
|
|
1600
|
-
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"]);
|
|
1601
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)`;
|
|
1602
1655
|
function workerResults(result) {
|
|
1603
1656
|
return result.messages.length - (result.others_shown ?? 0) + result.more;
|
|
@@ -1619,7 +1672,7 @@ function hookOf(s) {
|
|
|
1619
1672
|
}
|
|
1620
1673
|
async function startBroker(options) {
|
|
1621
1674
|
const dir = privateDir(options.dir);
|
|
1622
|
-
const lock = takeBrokerLock(
|
|
1675
|
+
const lock = takeBrokerLock(join4(dir, "broker.lock"));
|
|
1623
1676
|
const socket = socketPath(dir);
|
|
1624
1677
|
const connections = /* @__PURE__ */ new Set();
|
|
1625
1678
|
const waiters = /* @__PURE__ */ new Map();
|
|
@@ -1647,16 +1700,16 @@ async function startBroker(options) {
|
|
|
1647
1700
|
let listener;
|
|
1648
1701
|
try {
|
|
1649
1702
|
privateDir(dirname2(socket));
|
|
1650
|
-
db = openDatabase(
|
|
1703
|
+
db = openDatabase(join4(dir, "relay.db"));
|
|
1651
1704
|
db.exec("PRAGMA journal_mode = WAL");
|
|
1652
1705
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
1653
|
-
chmodSync2(
|
|
1706
|
+
chmodSync2(join4(dir, "relay.db"), 384);
|
|
1654
1707
|
const sql = db;
|
|
1655
1708
|
const machine = options.machine ?? options.bridge?.machine;
|
|
1656
1709
|
core = new FederatedCore(sql, {
|
|
1657
1710
|
...compact({ user: options.user, machine, now: options.now }),
|
|
1658
1711
|
hostname: shortHostname(),
|
|
1659
|
-
hash: (value) =>
|
|
1712
|
+
hash: (value) => createHash3("sha256").update(value).digest("hex"),
|
|
1660
1713
|
transaction: (fn) => db.transaction(fn),
|
|
1661
1714
|
alive: running
|
|
1662
1715
|
});
|
|
@@ -1712,6 +1765,7 @@ async function startBroker(options) {
|
|
|
1712
1765
|
const known = /* @__PURE__ */ new Map();
|
|
1713
1766
|
for (const view of localViews()) known.set(view.id, view.presence);
|
|
1714
1767
|
const presenceTimer = setInterval(() => {
|
|
1768
|
+
core.endExited();
|
|
1715
1769
|
const live = /* @__PURE__ */ new Set();
|
|
1716
1770
|
for (const view of localViews()) {
|
|
1717
1771
|
live.add(view.id);
|
|
@@ -1734,6 +1788,15 @@ async function startBroker(options) {
|
|
|
1734
1788
|
}
|
|
1735
1789
|
});
|
|
1736
1790
|
}
|
|
1791
|
+
async function relink() {
|
|
1792
|
+
const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge;
|
|
1793
|
+
if (!config || bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine) {
|
|
1794
|
+
bridge?.relink();
|
|
1795
|
+
return bridge?.status() ?? { enabled: false };
|
|
1796
|
+
}
|
|
1797
|
+
setImmediate(() => void stop());
|
|
1798
|
+
return { enabled: false, restarting: true };
|
|
1799
|
+
}
|
|
1737
1800
|
function activity() {
|
|
1738
1801
|
if (idleTimer) clearTimeout(idleTimer);
|
|
1739
1802
|
idleTimer = void 0;
|
|
@@ -1742,7 +1805,7 @@ async function startBroker(options) {
|
|
|
1742
1805
|
function loadPolicy() {
|
|
1743
1806
|
let text;
|
|
1744
1807
|
try {
|
|
1745
|
-
text =
|
|
1808
|
+
text = readFileSync6(policyFile, "utf8");
|
|
1746
1809
|
} catch (error) {
|
|
1747
1810
|
const code = error.code;
|
|
1748
1811
|
const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
|
|
@@ -1807,6 +1870,20 @@ async function startBroker(options) {
|
|
|
1807
1870
|
notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
|
|
1808
1871
|
bridge?.changed(view.id);
|
|
1809
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
|
+
}
|
|
1810
1887
|
function notifyFleet(event) {
|
|
1811
1888
|
const at = new Date((options.now ?? Date.now)()).toISOString();
|
|
1812
1889
|
let line;
|
|
@@ -2060,13 +2137,18 @@ async function startBroker(options) {
|
|
|
2060
2137
|
if (params.human === true || params.observe === true || params.resume !== void 0) {
|
|
2061
2138
|
throw new RelayError4("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume");
|
|
2062
2139
|
}
|
|
2063
|
-
const
|
|
2064
|
-
|
|
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 };
|
|
2065
2145
|
s.data.endpoint = endpoint2.id;
|
|
2066
2146
|
s.data.transient = true;
|
|
2067
2147
|
const key = params.claim?.process;
|
|
2068
2148
|
if (typeof key === "string") s.data.process = key;
|
|
2069
|
-
|
|
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);
|
|
2070
2152
|
return {
|
|
2071
2153
|
endpoint: endpoint2,
|
|
2072
2154
|
...subagent ? { subagent, ...created ? { created } : {} } : {},
|
|
@@ -2205,6 +2287,22 @@ async function startBroker(options) {
|
|
|
2205
2287
|
bridge?.changed(me);
|
|
2206
2288
|
return view;
|
|
2207
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
|
+
}
|
|
2208
2306
|
case "status": {
|
|
2209
2307
|
core.status(me, params.state, params.note);
|
|
2210
2308
|
const view = core.whoami(me);
|
|
@@ -2220,14 +2318,25 @@ async function startBroker(options) {
|
|
|
2220
2318
|
if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
|
|
2221
2319
|
return row;
|
|
2222
2320
|
}
|
|
2223
|
-
case "inject":
|
|
2224
|
-
|
|
2321
|
+
case "inject": {
|
|
2322
|
+
const waitMs = waitSeconds(params.wait_s);
|
|
2323
|
+
const options2 = {
|
|
2225
2324
|
stop: params.stop === true,
|
|
2226
2325
|
count: params.count === true,
|
|
2227
2326
|
holdUnread: params.hold_unread === true,
|
|
2228
2327
|
directives: params.directives === true,
|
|
2328
|
+
wake: waitMs > 0 && params.stop !== true && params.count !== true,
|
|
2229
2329
|
...hookOf(s)
|
|
2230
|
-
}
|
|
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
|
+
}
|
|
2231
2340
|
case "stop_wait": {
|
|
2232
2341
|
const waitMs = waitSeconds(params.wait_s);
|
|
2233
2342
|
const deadline = Date.now() + waitMs;
|
|
@@ -2246,7 +2355,8 @@ async function startBroker(options) {
|
|
|
2246
2355
|
case "prompted": {
|
|
2247
2356
|
const prompted = core.prompted(me, hookOf(s));
|
|
2248
2357
|
if (prompted) wake(me);
|
|
2249
|
-
|
|
2358
|
+
const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every);
|
|
2359
|
+
return { prompted, ...tldr ? { tldr } : {} };
|
|
2250
2360
|
}
|
|
2251
2361
|
case "injected":
|
|
2252
2362
|
return {
|
|
@@ -2335,6 +2445,8 @@ async function startBroker(options) {
|
|
|
2335
2445
|
}
|
|
2336
2446
|
case "cloud_status":
|
|
2337
2447
|
return bridge?.status() ?? { enabled: false };
|
|
2448
|
+
case "bridge_relink":
|
|
2449
|
+
return relink();
|
|
2338
2450
|
}
|
|
2339
2451
|
throw new RelayError4("method_not_found", `no method ${method}`);
|
|
2340
2452
|
}
|
|
@@ -2459,7 +2571,7 @@ async function startBroker(options) {
|
|
|
2459
2571
|
unlinkSync2(socket);
|
|
2460
2572
|
} catch {
|
|
2461
2573
|
}
|
|
2462
|
-
if (socket !==
|
|
2574
|
+
if (socket !== join4(dir, "relay.sock")) {
|
|
2463
2575
|
try {
|
|
2464
2576
|
rmdirSync(dirname2(socket));
|
|
2465
2577
|
} catch {
|
|
@@ -2504,11 +2616,13 @@ export {
|
|
|
2504
2616
|
localHostName,
|
|
2505
2617
|
policyPath,
|
|
2506
2618
|
privateDir,
|
|
2619
|
+
readSessionMarker,
|
|
2507
2620
|
relayDir,
|
|
2508
2621
|
relaySocketUrl,
|
|
2509
2622
|
shortHostname,
|
|
2510
2623
|
socketPath,
|
|
2511
2624
|
startBroker,
|
|
2512
2625
|
systemHostname,
|
|
2513
|
-
thisHostname
|
|
2626
|
+
thisHostname,
|
|
2627
|
+
writeSessionMarker
|
|
2514
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,11 +73,15 @@ 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 */
|
|
79
81
|
contacts(): Promise<XListed[]>
|
|
80
82
|
status(): BridgeStatus
|
|
83
|
+
/** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
|
|
84
|
+
relink(): void
|
|
81
85
|
stop(): Promise<void>
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -132,7 +136,9 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
132
136
|
let connected = false
|
|
133
137
|
/** hello went through on the current socket: deltas may go out */
|
|
134
138
|
let ready = false
|
|
139
|
+
/** the hub turned this bridge away, or the broker stopped it: no more retries until relink */
|
|
135
140
|
let stopped = false
|
|
141
|
+
let shutDown = false
|
|
136
142
|
let lastError: string | null = options.token ? null : (options.tokenError ?? "no hub credential; the broker runs local-only")
|
|
137
143
|
let since = new Date().toISOString()
|
|
138
144
|
let attempt = 0
|
|
@@ -177,7 +183,7 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
function connect() {
|
|
180
|
-
if (stopped || !token) return
|
|
186
|
+
if (stopped || !token || ws) return
|
|
181
187
|
let socket: WebSocket
|
|
182
188
|
try {
|
|
183
189
|
socket = new Socket(relaySocketUrl(options.url), { headers: { Authorization: `Bearer ${token}` } })
|
|
@@ -253,9 +259,10 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
253
259
|
}
|
|
254
260
|
|
|
255
261
|
async function handshake(socket: WebSocket) {
|
|
256
|
-
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 })
|
|
257
263
|
account = hello.account
|
|
258
264
|
core.setAliases([account])
|
|
265
|
+
core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name): name is string => typeof name === "string") : [])
|
|
259
266
|
ready = true
|
|
260
267
|
applyRemote(hello.endpoints, true)
|
|
261
268
|
await Promise.all(fullMirror().map((params) => request("mirror", params)))
|
|
@@ -350,7 +357,7 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
350
357
|
function localAddress(address: unknown): unknown {
|
|
351
358
|
if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address
|
|
352
359
|
const rest = address.slice(account.length + 1)
|
|
353
|
-
return rest.includes("/") ?
|
|
360
|
+
return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`
|
|
354
361
|
}
|
|
355
362
|
|
|
356
363
|
/** the lowest seq a fresh subscribe must replay from: every delivery at or below it was acked */
|
|
@@ -387,6 +394,7 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
387
394
|
}
|
|
388
395
|
case "endpoints": {
|
|
389
396
|
if (typeof params.machine !== "string" || params.machine === machine) return
|
|
397
|
+
if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
|
|
390
398
|
try {
|
|
391
399
|
core.mirror(params.machine, params.endpoints, { full: params.full === true })
|
|
392
400
|
} catch (error) {
|
|
@@ -597,6 +605,18 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
597
605
|
if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
|
|
598
606
|
}
|
|
599
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
|
+
|
|
600
620
|
function resync() {
|
|
601
621
|
if (!ready) return
|
|
602
622
|
let frames: ReturnType<typeof fullMirror>
|
|
@@ -624,8 +644,20 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
624
644
|
return { enabled: true, machine, url: options.url, connected, last_error: lastError, since }
|
|
625
645
|
}
|
|
626
646
|
|
|
647
|
+
function relink() {
|
|
648
|
+
if (shutDown) return
|
|
649
|
+
stopped = false
|
|
650
|
+
attempt = 0
|
|
651
|
+
clearTimeout(retryTimer)
|
|
652
|
+
if (ws) return
|
|
653
|
+
void reconnect().then(() => {
|
|
654
|
+
if (!token && !ws) setState(false, "no hub credential; log in again")
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
|
|
627
658
|
async function stop() {
|
|
628
659
|
stopped = true
|
|
660
|
+
shutDown = true
|
|
629
661
|
clearTimeout(retryTimer)
|
|
630
662
|
for (const fn of waitingForLink.splice(0)) fn()
|
|
631
663
|
const socket = ws
|
|
@@ -638,5 +670,5 @@ export function startBridge(options: BridgeOptions): Bridge {
|
|
|
638
670
|
}
|
|
639
671
|
|
|
640
672
|
connect()
|
|
641
|
-
return { forward, changed, resync, activity, contacts, status, stop }
|
|
673
|
+
return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
|
|
642
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
|
|
|
@@ -57,6 +58,8 @@ export interface BrokerOptions {
|
|
|
57
58
|
machine?: string | undefined
|
|
58
59
|
/** the link to the hub; without it the broker never opens a network connection */
|
|
59
60
|
bridge?: BridgeConfig | undefined
|
|
61
|
+
/** reads the bridge configuration again for bridge_relink; one that appeared or names another hub or machine restarts the broker */
|
|
62
|
+
bridgeConfig?: (() => Promise<BridgeConfig | undefined>) | undefined
|
|
60
63
|
/** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
|
|
61
64
|
presenceEveryMs?: number | undefined
|
|
62
65
|
/** where a judge model's key_env is read (default the broker's environment) */
|
|
@@ -110,7 +113,7 @@ interface Connection {
|
|
|
110
113
|
|
|
111
114
|
const SWEEP_MS = 10 * 60_000
|
|
112
115
|
const PRESENCE_MS = 60_000
|
|
113
|
-
const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"])
|
|
116
|
+
const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
|
|
114
117
|
const SCOPE_METHODS = new Set([
|
|
115
118
|
"rules",
|
|
116
119
|
"rules_set",
|
|
@@ -132,7 +135,7 @@ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"
|
|
|
132
135
|
const ASK_RECHECK_MS = 1000
|
|
133
136
|
/** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
|
|
134
137
|
const STOP_RECHECK_MS = 1000
|
|
135
|
-
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"])
|
|
136
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)`
|
|
137
140
|
|
|
138
141
|
/** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
|
|
@@ -258,6 +261,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
258
261
|
const known = new Map<string, Presence>()
|
|
259
262
|
for (const view of localViews()) known.set(view.id, view.presence)
|
|
260
263
|
const presenceTimer = setInterval(() => {
|
|
264
|
+
core.endExited()
|
|
261
265
|
const live = new Set<string>()
|
|
262
266
|
for (const view of localViews()) {
|
|
263
267
|
live.add(view.id)
|
|
@@ -281,6 +285,17 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
281
285
|
})
|
|
282
286
|
}
|
|
283
287
|
|
|
288
|
+
/** links the bridge again with the credential on disk; a configuration this broker cannot take without a restart stops it */
|
|
289
|
+
async function relink(): Promise<object> {
|
|
290
|
+
const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge
|
|
291
|
+
if (!config || (bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine)) {
|
|
292
|
+
bridge?.relink()
|
|
293
|
+
return bridge?.status() ?? { enabled: false }
|
|
294
|
+
}
|
|
295
|
+
setImmediate(() => void stop())
|
|
296
|
+
return { enabled: false, restarting: true }
|
|
297
|
+
}
|
|
298
|
+
|
|
284
299
|
function activity() {
|
|
285
300
|
if (idleTimer) clearTimeout(idleTimer)
|
|
286
301
|
idleTimer = undefined
|
|
@@ -370,6 +385,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
370
385
|
bridge?.changed(view.id)
|
|
371
386
|
}
|
|
372
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
|
+
|
|
373
404
|
function notifyFleet(event: Record<string, unknown>) {
|
|
374
405
|
const at = new Date((options.now ?? Date.now)()).toISOString()
|
|
375
406
|
let line: Uint8Array | undefined
|
|
@@ -664,13 +695,21 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
664
695
|
if (params.human === true || params.observe === true || params.resume !== undefined) {
|
|
665
696
|
throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
|
|
666
697
|
}
|
|
667
|
-
const
|
|
668
|
-
|
|
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 }
|
|
669
706
|
s.data.endpoint = endpoint.id
|
|
670
707
|
s.data.transient = true
|
|
671
708
|
const key = (params.claim as Claim | undefined)?.process
|
|
672
709
|
if (typeof key === "string") s.data.process = key
|
|
673
|
-
|
|
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)
|
|
674
713
|
return {
|
|
675
714
|
endpoint,
|
|
676
715
|
...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
|
|
@@ -809,6 +848,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
809
848
|
bridge?.changed(me)
|
|
810
849
|
return view
|
|
811
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
|
+
}
|
|
812
867
|
case "status": {
|
|
813
868
|
core.status(me, params.state, params.note)
|
|
814
869
|
const view = core.whoami(me)
|
|
@@ -824,14 +879,25 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
824
879
|
if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
|
|
825
880
|
return row
|
|
826
881
|
}
|
|
827
|
-
case "inject":
|
|
828
|
-
|
|
882
|
+
case "inject": {
|
|
883
|
+
const waitMs = waitSeconds(params.wait_s)
|
|
884
|
+
const options = {
|
|
829
885
|
stop: params.stop === true,
|
|
830
886
|
count: params.count === true,
|
|
831
887
|
holdUnread: params.hold_unread === true,
|
|
832
888
|
directives: params.directives === true,
|
|
889
|
+
wake: waitMs > 0 && params.stop !== true && params.count !== true,
|
|
833
890
|
...hookOf(s),
|
|
834
|
-
}
|
|
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
|
+
}
|
|
835
901
|
case "stop_wait": {
|
|
836
902
|
const waitMs = waitSeconds(params.wait_s)
|
|
837
903
|
const deadline = Date.now() + waitMs
|
|
@@ -850,7 +916,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
850
916
|
case "prompted": {
|
|
851
917
|
const prompted = core.prompted(me, hookOf(s))
|
|
852
918
|
if (prompted) wake(me)
|
|
853
|
-
|
|
919
|
+
const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every)
|
|
920
|
+
return { prompted, ...(tldr ? { tldr } : {}) }
|
|
854
921
|
}
|
|
855
922
|
case "injected":
|
|
856
923
|
return {
|
|
@@ -948,6 +1015,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
|
|
|
948
1015
|
}
|
|
949
1016
|
case "cloud_status":
|
|
950
1017
|
return bridge?.status() ?? { enabled: false }
|
|
1018
|
+
case "bridge_relink":
|
|
1019
|
+
return relink()
|
|
951
1020
|
}
|
|
952
1021
|
throw new RelayError("method_not_found", `no method ${method}`)
|
|
953
1022
|
}
|
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
|
|