@securecoms/agent-bridge 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/connectors/openclaw/gate.js +41 -0
- package/connectors/openclaw/gate.test.ts +54 -0
- package/connectors/openclaw/openclaw-entry.gate.test.ts +321 -0
- package/connectors/openclaw/openclaw-entry.js +602 -0
- package/connectors/openclaw/openclaw-entry.smoke.test.ts +30 -0
- package/connectors/openclaw/openclaw.plugin.json +25 -0
- package/connectors/openclaw/package.json +18 -0
- package/dist/bin.d.ts +4 -1
- package/dist/bin.js +1216 -76
- package/dist/bridge-agent.d.ts +178 -5
- package/dist/config.d.ts +1 -0
- package/dist/index.js +478 -41
- package/dist/preflight-upgrade.d.cts +2 -0
- package/dist/preflight.cjs +31 -8
- package/dist/service/config.d.ts +1 -0
- package/dist/service/connection-file.d.ts +33 -0
- package/dist/service/fleet.d.ts +27 -0
- package/dist/service/install-prep.d.ts +69 -0
- package/dist/service/resolve-node.d.ts +13 -0
- package/dist/service/status.d.ts +25 -0
- package/dist/service/wire.d.ts +76 -0
- package/dist/ws-server.d.ts +1 -1
- package/package.json +5 -4
package/dist/bin.js
CHANGED
|
@@ -93,6 +93,15 @@ function validateBind(bind, tls) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ../connect-core/dist/cadence.js
|
|
97
|
+
var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
98
|
+
var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
|
|
99
|
+
var REQUEST_WELCOME_COOLDOWN_MS = 12e4;
|
|
100
|
+
var WORST_CASE_RESIGNAL_MS = Math.ceil(REQUEST_WELCOME_COOLDOWN_MS / SELF_HEAL_SWEEP_INTERVAL_MS) * (SELF_HEAL_SWEEP_INTERVAL_MS + SELF_HEAL_SWEEP_JITTER_MS);
|
|
101
|
+
function isResignalDue(now, lastAt, cooldownMs = REQUEST_WELCOME_COOLDOWN_MS) {
|
|
102
|
+
return now - lastAt >= cooldownMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
// ../connect-core/dist/envelope/types.js
|
|
97
106
|
var MESSAGE_TYPES = [
|
|
98
107
|
"text",
|
|
@@ -1421,6 +1430,23 @@ var MlsGroupKeyProvider = class {
|
|
|
1421
1430
|
* server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
|
|
1422
1431
|
* from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
|
|
1423
1432
|
publishLock = new MlsOperationQueue();
|
|
1433
|
+
/**
|
|
1434
|
+
* M6 (ADR-0013 Decision H): wall-clock ms of the last SUCCESSFUL decrypt per
|
|
1435
|
+
* channel. This is the only evidence-based liveness signal the member has —
|
|
1436
|
+
* every other membership indicator in the daemon (`joinState`, a persisted
|
|
1437
|
+
* store row, a leaf in the tree) is a structural artifact that a forked
|
|
1438
|
+
* channel keeps asserting long after it stopped being able to read anything.
|
|
1439
|
+
*
|
|
1440
|
+
* ⚠️ Deliberately written OUTSIDE the `errors` map, and written BEFORE
|
|
1441
|
+
* {@link recordDecryptSuccess}'s `if (!e) return` early-exit. That guard is
|
|
1442
|
+
* correct for its own purpose (there is no error run to clear for a channel
|
|
1443
|
+
* that never errored) but it means a healthy channel decrypting normally
|
|
1444
|
+
* never touches `errors` at all. Keying this timestamp off that map would
|
|
1445
|
+
* leave it null for exactly the channels that are working — a field that
|
|
1446
|
+
* reports "no successful decrypt" for a healthy channel is worse than no
|
|
1447
|
+
* field, because it inverts the signal it exists to give.
|
|
1448
|
+
*/
|
|
1449
|
+
lastDecryptAt = /* @__PURE__ */ new Map();
|
|
1424
1450
|
/**
|
|
1425
1451
|
* Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
|
|
1426
1452
|
* of channels this device touches (channels are bounded; an entry is created
|
|
@@ -1485,6 +1511,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1485
1511
|
* re-wedges (the onWedged guard is reset so the hook can fire again).
|
|
1486
1512
|
*/
|
|
1487
1513
|
recordDecryptSuccess(channelId) {
|
|
1514
|
+
this.lastDecryptAt.set(channelId, Date.now());
|
|
1488
1515
|
const e = this.errors.get(channelId);
|
|
1489
1516
|
if (!e)
|
|
1490
1517
|
return;
|
|
@@ -1632,7 +1659,9 @@ var MlsGroupKeyProvider = class {
|
|
|
1632
1659
|
* equals newDeviceId (the same device re-admitted).
|
|
1633
1660
|
*/
|
|
1634
1661
|
async admitGuestDevice(channelId, g) {
|
|
1635
|
-
await this.addMemberToLiveGroup(channelId, g.newDeviceId
|
|
1662
|
+
await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
|
|
1663
|
+
knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
|
|
1664
|
+
});
|
|
1636
1665
|
if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1637
1666
|
const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
|
|
1638
1667
|
await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
|
|
@@ -1657,6 +1686,85 @@ var MlsGroupKeyProvider = class {
|
|
|
1657
1686
|
async currentEpoch(channelId) {
|
|
1658
1687
|
return Number((await this.manager(channelId)).epoch);
|
|
1659
1688
|
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Converge a BEHIND member in place by replaying the commits it missed,
|
|
1691
|
+
* instead of tearing down its session and being re-admitted.
|
|
1692
|
+
*
|
|
1693
|
+
* Ported from gen2 (#440). This is the primitive SecureComs was missing, and
|
|
1694
|
+
* its absence is why divergence here has been effectively unrecoverable: the
|
|
1695
|
+
* only client-side route back was Remove+Add, which gen2 treats as the
|
|
1696
|
+
* LAST RESORT and rate-limits hard, because they measured it as a divergence
|
|
1697
|
+
* SOURCE (#360 — epoch 1204 in ~6 minutes; "the re-admitter's churn IS the
|
|
1698
|
+
* bug, not the member").
|
|
1699
|
+
*
|
|
1700
|
+
* Replay is strictly cheaper and safer than re-admission:
|
|
1701
|
+
* - no re-key, no roster change, no epoch movement at all
|
|
1702
|
+
* - it cannot storm the group, because it only READS
|
|
1703
|
+
* - it preserves the member's leaf, so it cannot create a duplicate
|
|
1704
|
+
*
|
|
1705
|
+
* The server already implements the whole contract
|
|
1706
|
+
* (`GET /mls/groups/{id}/sync?last_epoch=N` → `{action, messages}`) and has
|
|
1707
|
+
* since before this change. Nothing consumed it. That is the entire defect.
|
|
1708
|
+
*
|
|
1709
|
+
* Returns true when the member converged to the server's epoch. Returns false
|
|
1710
|
+
* when the caller should fall back to re-admission — the gap exceeded the
|
|
1711
|
+
* server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
|
|
1712
|
+
* not apply (which means the state is genuinely FORKED, not merely behind,
|
|
1713
|
+
* and replay cannot fix a fork).
|
|
1714
|
+
*/
|
|
1715
|
+
async resyncByReplay(channelId, groupId) {
|
|
1716
|
+
if (!await this.hasPersistedChannelState(channelId))
|
|
1717
|
+
return false;
|
|
1718
|
+
let from;
|
|
1719
|
+
try {
|
|
1720
|
+
from = await this.currentEpoch(channelId);
|
|
1721
|
+
} catch {
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
const resp = await this.o.backend.syncGroup(groupId, from, this.o.deviceId);
|
|
1725
|
+
if (resp.action !== "replay")
|
|
1726
|
+
return false;
|
|
1727
|
+
const commits = resp.messages.filter((m) => m.message_type === "commit").sort((a, b) => a.epoch - b.epoch);
|
|
1728
|
+
if (commits.length === 0)
|
|
1729
|
+
return false;
|
|
1730
|
+
let applied = 0;
|
|
1731
|
+
try {
|
|
1732
|
+
await this.locks.run(channelId, async () => {
|
|
1733
|
+
const mgr = await this.manager(channelId);
|
|
1734
|
+
for (const c of commits) {
|
|
1735
|
+
if (c.epoch <= Number(mgr.epoch))
|
|
1736
|
+
continue;
|
|
1737
|
+
await mgr.decrypt(hexToBytes(c.payload));
|
|
1738
|
+
applied++;
|
|
1739
|
+
}
|
|
1740
|
+
if (applied > 0)
|
|
1741
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1742
|
+
});
|
|
1743
|
+
} catch (e) {
|
|
1744
|
+
console.warn(`[mls] resyncByReplay ${channelId.slice(0, 8)}: commit failed to apply after ${applied} \u2014 state is forked, not behind:`, e?.message);
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
if (applied > 0) {
|
|
1748
|
+
this.recordDecryptSuccess(channelId);
|
|
1749
|
+
}
|
|
1750
|
+
return applied > 0;
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* M6 (ADR-0013 Decision H): wall-clock ms of the last successful decrypt for
|
|
1754
|
+
* `channelId`, or `undefined` if this process has never decrypted anything on
|
|
1755
|
+
* it. Synchronous and allocation-free — it is read from the `/healthz` path,
|
|
1756
|
+
* which must never fan out work, because the conditions it exists to report
|
|
1757
|
+
* are exactly the conditions under which fanned-out work hangs.
|
|
1758
|
+
*
|
|
1759
|
+
* `undefined` is genuinely ambiguous and callers MUST NOT read it as
|
|
1760
|
+
* "broken": it also covers a freshly-restarted daemon and a healthy channel
|
|
1761
|
+
* that has simply been quiet. It is evidence of liveness when present, never
|
|
1762
|
+
* evidence of divergence when absent. Divergence is decided by the held-vs-
|
|
1763
|
+
* server epoch comparison, not by this field.
|
|
1764
|
+
*/
|
|
1765
|
+
lastDecryptAtFor(channelId) {
|
|
1766
|
+
return this.lastDecryptAt.get(channelId);
|
|
1767
|
+
}
|
|
1660
1768
|
/**
|
|
1661
1769
|
* Run a committer op and, on ANY throw that escapes it, evict the channel's
|
|
1662
1770
|
* cached MLSGroupManager.
|
|
@@ -1880,7 +1988,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1880
1988
|
if (!bundle)
|
|
1881
1989
|
throw new Error("ensureSession: call publishKeyPackage() first");
|
|
1882
1990
|
if (opts.forceReinit) {
|
|
1883
|
-
const others = opts.otherDeviceIds ?? [];
|
|
1991
|
+
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
1884
1992
|
const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
|
|
1885
1993
|
const mgr = new MLSGroupManager();
|
|
1886
1994
|
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
@@ -1910,7 +2018,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1910
2018
|
throw new BootstrapRaceLostError();
|
|
1911
2019
|
const mgr = new MLSGroupManager();
|
|
1912
2020
|
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
1913
|
-
const others = opts.otherDeviceIds ?? [];
|
|
2021
|
+
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
1914
2022
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1915
2023
|
const pkgs = others.map((d) => {
|
|
1916
2024
|
const hex = kps[d];
|
|
@@ -1961,17 +2069,38 @@ var MlsGroupKeyProvider = class {
|
|
|
1961
2069
|
* full member roster (unioning in any device ids we pass). That avoids a
|
|
1962
2070
|
* parallel group_id-by-channel resolver.
|
|
1963
2071
|
*/
|
|
1964
|
-
async addMemberToLiveGroup(channelId, newDeviceId) {
|
|
1965
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
|
|
2072
|
+
async addMemberToLiveGroup(channelId, newDeviceId, opts) {
|
|
2073
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts)));
|
|
1966
2074
|
}
|
|
1967
2075
|
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
1968
2076
|
const mgr = await this.manager(channelId);
|
|
1969
|
-
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1970
2077
|
const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
|
|
1971
2078
|
const hex = kps[newDeviceId];
|
|
1972
2079
|
if (!hex)
|
|
1973
2080
|
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
1974
2081
|
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
2082
|
+
if (_attempt === 0) {
|
|
2083
|
+
let adds;
|
|
2084
|
+
try {
|
|
2085
|
+
adds = (await this.o.backend.getCommitterWork(channelId)).adds;
|
|
2086
|
+
} catch (e) {
|
|
2087
|
+
throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} \u2014 refusing plain Add for device ${newDeviceId} (fail closed): ` + (e instanceof Error ? e.message : String(e)));
|
|
2088
|
+
}
|
|
2089
|
+
if (!adds.includes(newDeviceId)) {
|
|
2090
|
+
const cred = pkg.leafNode.credential;
|
|
2091
|
+
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
|
|
2092
|
+
const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
|
|
2093
|
+
if (leaves === null || leaves.length > 0) {
|
|
2094
|
+
const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
|
|
2095
|
+
const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
|
|
2096
|
+
if (!swapSafe) {
|
|
2097
|
+
console.warn(`addMemberToLiveGroup: device ${newDeviceId.slice(0, 8)} is outside the committer-work add-delta and its identity already holds a leaf \u2014 terminal skip (no commit)`);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1975
2104
|
if (opts?.expectedIdentityId) {
|
|
1976
2105
|
const cred = pkg.leafNode.credential;
|
|
1977
2106
|
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
@@ -2310,6 +2439,7 @@ var MlsGroupKeyProvider = class {
|
|
|
2310
2439
|
await this.o.store.delete(channelId);
|
|
2311
2440
|
await this.o.store.discardStaging(channelId);
|
|
2312
2441
|
this.errors.delete(channelId);
|
|
2442
|
+
this.lastDecryptAt.delete(channelId);
|
|
2313
2443
|
});
|
|
2314
2444
|
}
|
|
2315
2445
|
/** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
|
|
@@ -2375,6 +2505,25 @@ var MlsGroupKeyProvider = class {
|
|
|
2375
2505
|
return true;
|
|
2376
2506
|
return await this.o.store.load(channelId) !== null;
|
|
2377
2507
|
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Returns the group id our persisted (or live in-memory) MLS state holds for
|
|
2510
|
+
* `channelId`, or null when there is no state to read, or when the state
|
|
2511
|
+
* exists but a loaded manager can't be constructed from it. Read-only —
|
|
2512
|
+
* `manager()` only loads from the store and caches in memory; it makes no
|
|
2513
|
+
* backend call and writes nothing to the store. Mirrors the same
|
|
2514
|
+
* held-vs-row comparison the Welcome receive path already does inline
|
|
2515
|
+
* (see the `heldGroupId` decode in the `message_type === "welcome"` branch
|
|
2516
|
+
* of `receiveOnce`) so callers can ask "is my held state for THIS group?"
|
|
2517
|
+
* without duplicating that decode logic.
|
|
2518
|
+
*/
|
|
2519
|
+
async heldGroupIdFor(channelId) {
|
|
2520
|
+
try {
|
|
2521
|
+
const mgr = await this.manager(channelId);
|
|
2522
|
+
return new TextDecoder().decode(mgr.groupId);
|
|
2523
|
+
} catch {
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2378
2527
|
/**
|
|
2379
2528
|
* Return the number of leaves the given identity holds in the live tree for
|
|
2380
2529
|
* `channelId`. Loads the manager from the store if not already cached.
|
|
@@ -2725,6 +2874,15 @@ function parseServerFrame(frame) {
|
|
|
2725
2874
|
}
|
|
2726
2875
|
|
|
2727
2876
|
// ../connect-sdk-ts/dist/transport/ws-pings.js
|
|
2877
|
+
function base64UrlNoPad(s) {
|
|
2878
|
+
const bytes = new TextEncoder().encode(s);
|
|
2879
|
+
let bin = "";
|
|
2880
|
+
for (const b of bytes)
|
|
2881
|
+
bin += String.fromCharCode(b);
|
|
2882
|
+
const b64 = typeof btoa === "function" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
2883
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
2884
|
+
}
|
|
2885
|
+
var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
|
|
2728
2886
|
function openDeliveryPings(o) {
|
|
2729
2887
|
const base = o.backoffBaseMs ?? 500;
|
|
2730
2888
|
const cap = o.backoffCapMs ?? 8e3;
|
|
@@ -2873,8 +3031,7 @@ function openDeliveryPings(o) {
|
|
|
2873
3031
|
}
|
|
2874
3032
|
if (closed || gen !== connectGen)
|
|
2875
3033
|
return;
|
|
2876
|
-
const
|
|
2877
|
-
const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
|
|
3034
|
+
const ws = new o.wsImpl(o.url, [BEARER_SUBPROTOCOL, base64UrlNoPad(token)]);
|
|
2878
3035
|
sock = ws;
|
|
2879
3036
|
ws.onopen = () => {
|
|
2880
3037
|
push({ reconnected: true });
|
|
@@ -2974,8 +3131,8 @@ function openDeliveryPings(o) {
|
|
|
2974
3131
|
return Promise.resolve({ value: void 0, done: true });
|
|
2975
3132
|
if (queue.length)
|
|
2976
3133
|
return Promise.resolve({ value: queue.shift(), done: false });
|
|
2977
|
-
return new Promise((
|
|
2978
|
-
waiter =
|
|
3134
|
+
return new Promise((resolve3) => {
|
|
3135
|
+
waiter = resolve3;
|
|
2979
3136
|
});
|
|
2980
3137
|
}
|
|
2981
3138
|
};
|
|
@@ -3184,6 +3341,22 @@ var RestMlsBackend = class {
|
|
|
3184
3341
|
});
|
|
3185
3342
|
return String(j.id);
|
|
3186
3343
|
}
|
|
3344
|
+
async syncGroup(groupId, lastEpoch, deviceId) {
|
|
3345
|
+
const deviceParam = deviceId ? `&device_id=${encodeURIComponent(deviceId)}` : "";
|
|
3346
|
+
const j = await this.get(`/api/v1/mls/groups/${groupId}/sync?last_epoch=${encodeURIComponent(String(lastEpoch))}${deviceParam}`);
|
|
3347
|
+
const action = j?.action === "rejoin" ? "rejoin" : "replay";
|
|
3348
|
+
const messages = Array.isArray(j?.messages) ? j.messages : [];
|
|
3349
|
+
return {
|
|
3350
|
+
action,
|
|
3351
|
+
messages: messages.map((m) => ({
|
|
3352
|
+
id: String(m.id),
|
|
3353
|
+
epoch: Number(m.epoch),
|
|
3354
|
+
message_type: String(m.message_type),
|
|
3355
|
+
sender_device_id: String(m.sender_device_id ?? ""),
|
|
3356
|
+
payload: String(m.payload ?? "")
|
|
3357
|
+
}))
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3187
3360
|
async getConnectChannelGroup(channelId) {
|
|
3188
3361
|
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
|
|
3189
3362
|
return {
|
|
@@ -4414,6 +4587,22 @@ var InvalidPathError = class extends Error {
|
|
|
4414
4587
|
if (cause !== void 0) this.cause = cause;
|
|
4415
4588
|
}
|
|
4416
4589
|
};
|
|
4590
|
+
var InvalidMessageIdError = class extends Error {
|
|
4591
|
+
constructor(messageId) {
|
|
4592
|
+
super(`message_id must be a UUID, got: ${messageId}`);
|
|
4593
|
+
this.messageId = messageId;
|
|
4594
|
+
this.name = "InvalidMessageIdError";
|
|
4595
|
+
}
|
|
4596
|
+
};
|
|
4597
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4598
|
+
function isUuid(s) {
|
|
4599
|
+
return UUID_RE.test(s);
|
|
4600
|
+
}
|
|
4601
|
+
function resolveMessageId(messageId) {
|
|
4602
|
+
if (messageId === void 0) return crypto.randomUUID();
|
|
4603
|
+
if (!isUuid(messageId)) throw new InvalidMessageIdError(messageId);
|
|
4604
|
+
return messageId;
|
|
4605
|
+
}
|
|
4417
4606
|
var AgentPausedSendError = class extends Error {
|
|
4418
4607
|
constructor(channelId) {
|
|
4419
4608
|
super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
|
|
@@ -4614,10 +4803,75 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4614
4803
|
* counter is incremented. Surfaced on health()/metrics().
|
|
4615
4804
|
*/
|
|
4616
4805
|
failedAttachments = 0;
|
|
4806
|
+
/**
|
|
4807
|
+
* Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
|
|
4808
|
+
* verb can tell "bridge process up" (the app's green dot, which only
|
|
4809
|
+
* proves bridge<->backend) apart from "a harness is actually attached"
|
|
4810
|
+
* (a real consumer holding an authenticated session). Only ws-server.ts
|
|
4811
|
+
* mutates this, and only after authedUpgrade() passes — an unauthenticated
|
|
4812
|
+
* socket, or the HTTP /healthz probe, must never move this counter.
|
|
4813
|
+
* Surfaced on health()/metrics().
|
|
4814
|
+
*/
|
|
4815
|
+
authSessions = 0;
|
|
4816
|
+
/**
|
|
4817
|
+
* Task 3 (audit M3): count of newest-wins evictions of a prior /v1/ws
|
|
4818
|
+
* session. The daemon's WS is single-consumer newest-wins, so
|
|
4819
|
+
* `agent_sessions` alone can stay >=1 while two connectors flap the same
|
|
4820
|
+
* identity in and out — this counter makes that split-brain visible.
|
|
4821
|
+
* Only ws-server.ts mutates this, via noteSessionEvicted(). Surfaced on
|
|
4822
|
+
* health()/status.
|
|
4823
|
+
*/
|
|
4824
|
+
evictions = 0;
|
|
4617
4825
|
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
4618
4826
|
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
4619
4827
|
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
4620
|
-
|
|
4828
|
+
/**
|
|
4829
|
+
* M6 (ADR-0013 Decision H): the sweep's per-channel observation cache, and the
|
|
4830
|
+
* only thing {@link health} reports divergence from.
|
|
4831
|
+
*
|
|
4832
|
+
* Written by `selfHealSweep` in a `finally`, read synchronously by `health()`.
|
|
4833
|
+
* `health()` must NEVER fan out network calls of its own: a health endpoint
|
|
4834
|
+
* that does I/O hangs under exactly the conditions it exists to report, and
|
|
4835
|
+
* the conditions here are "the delivery service is unreachable or lying".
|
|
4836
|
+
*/
|
|
4837
|
+
observations = /* @__PURE__ */ new Map();
|
|
4838
|
+
/**
|
|
4839
|
+
* A3 (dr-evil must-fix #3): true when the sweep found the provider missing a
|
|
4840
|
+
* required capability (stale SDK dist under a newer bridge dist) and ABORTED.
|
|
4841
|
+
* Folded into `observations_stale` (and therefore `degraded`) on health():
|
|
4842
|
+
* a bridge that cannot heal must never present trustworthy-looking data.
|
|
4843
|
+
*/
|
|
4844
|
+
providerCapabilityMissing = false;
|
|
4845
|
+
/**
|
|
4846
|
+
* ADR-0013 Decision D: the wipe's OWN anti-thrash bound, deliberately
|
|
4847
|
+
* separate from {@link REQUEST_WELCOME_COOLDOWN_MS} and separately keyed.
|
|
4848
|
+
*
|
|
4849
|
+
* Two bounds exist because they protect different things and must never
|
|
4850
|
+
* share a gate. The request cooldown protects the single committer from
|
|
4851
|
+
* churn; deferring an ask costs a little latency. The wipe bound only stops
|
|
4852
|
+
* the member thrashing its own session; skipping a wipe costs the member its
|
|
4853
|
+
* ONLY route back, permanently. Sharing one map is what starved the repair.
|
|
4854
|
+
*/
|
|
4855
|
+
static WIPE_ANTI_THRASH_MS = 6e5;
|
|
4856
|
+
/**
|
|
4857
|
+
* ADR-0013 Decision A (wipe-first): the SHORTER re-wipe floor used only when
|
|
4858
|
+
* the fork is PROVEN (the self-heal sweep's resyncByReplay returned false).
|
|
4859
|
+
*
|
|
4860
|
+
* The 600s WIPE_ANTI_THRASH_MS is the right duty cycle for the reactive
|
|
4861
|
+
* onWedged path, but it is a TRAP for a proven-forked member: while the member
|
|
4862
|
+
* holds forked state the owner's re-Welcome is stray-acked (mls.ts, rowGroup ==
|
|
4863
|
+
* heldGroup) and destroyed, so the member never returns to no-state and the
|
|
4864
|
+
* Welcome never lands via the first-join path. Suppressing the re-wipe for a
|
|
4865
|
+
* full 10 minutes means every owner Welcome in that window is destroyed — the
|
|
4866
|
+
* observed hermes flap. This floor lets a proven-forked member re-wipe promptly
|
|
4867
|
+
* (return to no-state so the next Welcome opens) while still bounding the loop:
|
|
4868
|
+
* ~one 25s member ingest poll + margin, which preserves Decision D ("cannot
|
|
4869
|
+
* loop") because two wipes still cannot fire inside a single poll interval.
|
|
4870
|
+
*/
|
|
4871
|
+
static PROVEN_FORK_REWIPE_FLOOR_MS = 3e4;
|
|
4872
|
+
/** Per-channel timestamp of the last state repudiation (Decision D). */
|
|
4873
|
+
lastWipe = /* @__PURE__ */ new Map();
|
|
4874
|
+
static REQUEST_WELCOME_COOLDOWN_MS = REQUEST_WELCOME_COOLDOWN_MS;
|
|
4621
4875
|
// H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
|
|
4622
4876
|
static GRACEFUL_DRAIN_MS = 5e3;
|
|
4623
4877
|
/**
|
|
@@ -4639,13 +4893,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4639
4893
|
*/
|
|
4640
4894
|
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
4641
4895
|
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
4642
|
-
static SELF_HEAL_SWEEP_INTERVAL_MS =
|
|
4896
|
+
static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
|
|
4643
4897
|
// C3: add up to this much random jitter to BOTH the boot sweep delay and each
|
|
4644
4898
|
// periodic interval, so a fleet of daemons restarting/sweeping together does not
|
|
4645
4899
|
// fire re-Welcome signals at the SAME instant and thundering-herd the single
|
|
4646
4900
|
// committer (the observed 409 commit-lease saturation). Decorrelation only —
|
|
4647
4901
|
// C1 already removed the restart KP-rotation storm that caused the herd.
|
|
4648
|
-
static SELF_HEAL_SWEEP_JITTER_MS =
|
|
4902
|
+
static SELF_HEAL_SWEEP_JITTER_MS = SELF_HEAL_SWEEP_JITTER_MS;
|
|
4649
4903
|
selfHealTimer;
|
|
4650
4904
|
bootSweepTimer;
|
|
4651
4905
|
rng;
|
|
@@ -4784,7 +5038,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4784
5038
|
await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
|
|
4785
5039
|
this.joinState.set(channelId, "JOINED");
|
|
4786
5040
|
return true;
|
|
4787
|
-
} catch {
|
|
5041
|
+
} catch (e) {
|
|
5042
|
+
console.warn(`[bridge] reconcileJoin ${channelId} failed:`, e);
|
|
4788
5043
|
this.joinState.set(channelId, "PENDING");
|
|
4789
5044
|
return false;
|
|
4790
5045
|
}
|
|
@@ -4842,20 +5097,40 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4842
5097
|
* SDK's onWedged caller. Single-shot per wedge episode — full rejoin
|
|
4843
5098
|
* reliability is P1, not this hook's job.
|
|
4844
5099
|
*/
|
|
4845
|
-
async healWedgedChannel(channelId) {
|
|
5100
|
+
async healWedgedChannel(channelId, provenForked = false) {
|
|
4846
5101
|
try {
|
|
4847
5102
|
const now = performance.now();
|
|
4848
|
-
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4849
|
-
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
|
|
4850
5103
|
const g = await this.backend.getConnectChannelGroup(channelId);
|
|
4851
|
-
if (!g?.group_id) return;
|
|
4852
|
-
this.
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
5104
|
+
if (!g?.group_id) return false;
|
|
5105
|
+
const lastWipe = this.lastWipe.get(channelId) ?? -Infinity;
|
|
5106
|
+
const floor = provenForked ? _BridgeAgent.PROVEN_FORK_REWIPE_FLOOR_MS : _BridgeAgent.WIPE_ANTI_THRASH_MS;
|
|
5107
|
+
let repudiated = false;
|
|
5108
|
+
if (now - lastWipe >= floor) {
|
|
5109
|
+
if (await this.provider.hasPersistedChannelState(channelId)) {
|
|
5110
|
+
await this.provider.resetChannelForRejoin(channelId);
|
|
5111
|
+
repudiated = true;
|
|
5112
|
+
}
|
|
5113
|
+
this.lastWipe.set(channelId, now);
|
|
5114
|
+
} else {
|
|
5115
|
+
console.warn(
|
|
5116
|
+
`[bridge] healWedgedChannel ${channelId}: wipe suppressed by ${provenForked ? "proven-fork re-wipe floor" : "anti-thrash bound"} (${Math.round((floor - (now - lastWipe)) / 1e3)}s remaining) \u2014 NOT repudiated`
|
|
5117
|
+
);
|
|
5118
|
+
}
|
|
5119
|
+
this.joinState.delete(channelId);
|
|
5120
|
+
if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
|
|
5121
|
+
console.warn(
|
|
5122
|
+
`[bridge] healWedgedChannel ${channelId}: wipe suppressed and state still held \u2014 NOT requesting (the answer would be stray-acked and would cost the group two commits)`
|
|
5123
|
+
);
|
|
5124
|
+
return false;
|
|
4856
5125
|
}
|
|
5126
|
+
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
5127
|
+
if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) return repudiated;
|
|
5128
|
+
this.lastWelcomeRequest.set(channelId, now);
|
|
4857
5129
|
await this.signalHeldKpWelcome(g.group_id);
|
|
4858
|
-
|
|
5130
|
+
return repudiated;
|
|
5131
|
+
} catch (e) {
|
|
5132
|
+
console.warn(`[bridge] healWedgedChannel ${channelId} failed:`, e);
|
|
5133
|
+
return false;
|
|
4859
5134
|
}
|
|
4860
5135
|
}
|
|
4861
5136
|
/**
|
|
@@ -4877,7 +5152,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4877
5152
|
try {
|
|
4878
5153
|
const now = performance.now();
|
|
4879
5154
|
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4880
|
-
if (now
|
|
5155
|
+
if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) return false;
|
|
4881
5156
|
this.lastWelcomeRequest.set(channelId, now);
|
|
4882
5157
|
await this.signalHeldKpWelcome(groupId);
|
|
4883
5158
|
return true;
|
|
@@ -4885,6 +5160,19 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4885
5160
|
return false;
|
|
4886
5161
|
}
|
|
4887
5162
|
}
|
|
5163
|
+
/**
|
|
5164
|
+
* Task 2 (MLS convergence slice): true when we hold persisted state for
|
|
5165
|
+
* `channelId` whose group id does NOT match `serverGroupId` — the group
|
|
5166
|
+
* the server currently says is live for that channel. Holding SOME state
|
|
5167
|
+
* is not proof of health; it is proof of health only for the group the
|
|
5168
|
+
* server currently attests. A null held group id (state present but the
|
|
5169
|
+
* manager can't be loaded from it) is treated as diverged too — we can't
|
|
5170
|
+
* confirm health, so we ask rather than silently trusting stale state.
|
|
5171
|
+
*/
|
|
5172
|
+
async isChannelDiverged(channelId, serverGroupId) {
|
|
5173
|
+
const held = await this.provider.heldGroupIdFor(channelId);
|
|
5174
|
+
return held !== serverGroupId;
|
|
5175
|
+
}
|
|
4888
5176
|
/**
|
|
4889
5177
|
* Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
|
|
4890
5178
|
* branch only fires reactively — off a delivery ROW that already arrived
|
|
@@ -4893,11 +5181,22 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4893
5181
|
* first thing lost. This sweep walks every member channel — rooms AND DMs
|
|
4894
5182
|
* (H2: a wiped, idle DM with no traffic since the wipe otherwise never
|
|
4895
5183
|
* proactively re-Welcomes, so it stays dark until the owner happens to send)
|
|
4896
|
-
* — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS
|
|
4897
|
-
*
|
|
4898
|
-
*
|
|
4899
|
-
*
|
|
4900
|
-
* the
|
|
5184
|
+
* — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS.
|
|
5185
|
+
*
|
|
5186
|
+
* Task 2: holding persisted state is NOT proof of health by itself — it is
|
|
5187
|
+
* proof of health only for the group the server currently says is live for
|
|
5188
|
+
* the channel. The prior rule ("I have persisted state ⇒ healthy ⇒ don't
|
|
5189
|
+
* ask") silently stopped filing welcome-requests for any channel that had
|
|
5190
|
+
* been superseded (e.g. a server-side reset/rotation) while the member
|
|
5191
|
+
* still held state for the OLD group: it could hold state, hold the WRONG
|
|
5192
|
+
* state, and never say anything. So the server's current group_id is
|
|
5193
|
+
* fetched FIRST via the READ-ONLY getConnectChannelGroup probe (never
|
|
5194
|
+
* initConnectChannelGroup, which would side-effect a member_device_ids
|
|
5195
|
+
* union + commit), then the branch is: persisted state whose group id
|
|
5196
|
+
* matches the server's current group ⇒ healthy, skip; persisted state for
|
|
5197
|
+
* a STALE group id ⇒ diverged, signal; no persisted state at all ⇒
|
|
5198
|
+
* reconcile-first (consume an already-queued Welcome) before concluding
|
|
5199
|
+
* divergence. All three paths go through the same per-channel cooldown.
|
|
4901
5200
|
*
|
|
4902
5201
|
* Each channel is wrapped in its own try/catch: `listChannels` filters at
|
|
4903
5202
|
* the identity level while `requestWelcome` (via request_welcome) gates at
|
|
@@ -4906,25 +5205,114 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4906
5205
|
* must not abort the sweep for every other channel.
|
|
4907
5206
|
*/
|
|
4908
5207
|
async selfHealSweep() {
|
|
5208
|
+
if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
|
|
5209
|
+
this.providerCapabilityMissing = true;
|
|
5210
|
+
console.error(
|
|
5211
|
+
"[bridge] selfHealSweep ABORTED: provider is missing required capabilities (resyncByReplay/heldGroupIdFor) \u2014 stale SDK dist? Rebuild SDK dist THEN bridge dist. Healing is NOT running; observations are reported STALE."
|
|
5212
|
+
);
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
this.providerCapabilityMissing = false;
|
|
4909
5216
|
let chans;
|
|
4910
5217
|
try {
|
|
4911
5218
|
chans = await this.listChannels();
|
|
4912
|
-
} catch {
|
|
5219
|
+
} catch (e) {
|
|
5220
|
+
console.warn("[bridge] selfHealSweep aborted: listChannels failed:", e);
|
|
4913
5221
|
return;
|
|
4914
5222
|
}
|
|
4915
5223
|
for (const c of chans) {
|
|
5224
|
+
const obs = {
|
|
5225
|
+
channel_id: c.channel_id,
|
|
5226
|
+
state: "probe_failed",
|
|
5227
|
+
held_group_id: null,
|
|
5228
|
+
server_group_id: null,
|
|
5229
|
+
held_epoch: null,
|
|
5230
|
+
server_epoch: null,
|
|
5231
|
+
diverged: null,
|
|
5232
|
+
last_decrypt_at: this.provider.lastDecryptAtFor?.(c.channel_id) ?? null,
|
|
5233
|
+
observed_at: Date.now()
|
|
5234
|
+
};
|
|
4916
5235
|
try {
|
|
4917
|
-
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4918
|
-
await this.reconcileJoin(c.channel_id);
|
|
4919
|
-
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4920
5236
|
const g = await this.backend.getConnectChannelGroup(c.channel_id);
|
|
4921
|
-
|
|
5237
|
+
obs.server_group_id = g?.group_id ?? null;
|
|
5238
|
+
obs.server_epoch = g?.current_epoch ?? null;
|
|
5239
|
+
if (!g?.group_id) {
|
|
5240
|
+
obs.state = "group_unresolvable";
|
|
5241
|
+
continue;
|
|
5242
|
+
}
|
|
5243
|
+
const hasState = await this.provider.hasPersistedChannelState(c.channel_id);
|
|
5244
|
+
if (hasState) {
|
|
5245
|
+
obs.held_group_id = await this.provider.heldGroupIdFor(c.channel_id).catch(() => null);
|
|
5246
|
+
obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
|
|
5247
|
+
if (!await this.isChannelDiverged(c.channel_id, g.group_id)) {
|
|
5248
|
+
if (obs.held_epoch === null) {
|
|
5249
|
+
obs.state = "probe_failed";
|
|
5250
|
+
obs.diverged = null;
|
|
5251
|
+
continue;
|
|
5252
|
+
}
|
|
5253
|
+
const behind = typeof obs.held_epoch === "number" && typeof obs.server_epoch === "number" && obs.held_epoch < obs.server_epoch;
|
|
5254
|
+
obs.state = behind ? "behind" : "healthy";
|
|
5255
|
+
obs.diverged = behind;
|
|
5256
|
+
if (behind) {
|
|
5257
|
+
const prev = this.observations.get(c.channel_id);
|
|
5258
|
+
const notAdvancing = prev != null && prev.held_epoch === obs.held_epoch;
|
|
5259
|
+
if (notAdvancing) {
|
|
5260
|
+
obs.state = "stuck";
|
|
5261
|
+
if (await this.provider.resyncByReplay(c.channel_id, g.group_id)) {
|
|
5262
|
+
obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
|
|
5263
|
+
obs.last_decrypt_at = this.provider.lastDecryptAtFor?.(c.channel_id) ?? null;
|
|
5264
|
+
if (obs.held_epoch === null) {
|
|
5265
|
+
obs.state = "probe_failed";
|
|
5266
|
+
obs.diverged = null;
|
|
5267
|
+
continue;
|
|
5268
|
+
}
|
|
5269
|
+
obs.state = "healthy";
|
|
5270
|
+
obs.diverged = false;
|
|
5271
|
+
console.warn(
|
|
5272
|
+
`[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
|
|
5273
|
+
);
|
|
5274
|
+
continue;
|
|
5275
|
+
}
|
|
5276
|
+
const repudiated = await this.healWedgedChannel(c.channel_id, true);
|
|
5277
|
+
console.warn(
|
|
5278
|
+
`[bridge] selfHealSweep: ${c.channel_id} STUCK at epoch ${obs.held_epoch} (server=${obs.server_epoch}) across two sweeps \u2014 ${repudiated ? "repudiated and re-requested" : "repair SUPPRESSED (anti-thrash); state unchanged"}`
|
|
5279
|
+
);
|
|
5280
|
+
}
|
|
5281
|
+
continue;
|
|
5282
|
+
}
|
|
5283
|
+
console.debug(
|
|
5284
|
+
`[bridge] selfHealSweep: ${c.channel_id} ${behind ? `BEHIND (held=${obs.held_epoch} server=${obs.server_epoch}) \u2014 reporting only, action predicate is group-id` : "healthy, held group matches server"} \u2014 skipping`
|
|
5285
|
+
);
|
|
5286
|
+
continue;
|
|
5287
|
+
}
|
|
5288
|
+
obs.state = "diverged";
|
|
5289
|
+
obs.diverged = true;
|
|
5290
|
+
console.warn(`[bridge] selfHealSweep: ${c.channel_id} holds STALE group state \u2014 signalling re-Welcome`);
|
|
5291
|
+
} else {
|
|
5292
|
+
obs.state = "no_local_state";
|
|
5293
|
+
obs.diverged = false;
|
|
5294
|
+
await this.reconcileJoin(c.channel_id);
|
|
5295
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) {
|
|
5296
|
+
obs.held_group_id = await this.provider.heldGroupIdFor(c.channel_id).catch(() => null);
|
|
5297
|
+
obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
|
|
5298
|
+
if (obs.held_epoch === null) {
|
|
5299
|
+
obs.state = "probe_failed";
|
|
5300
|
+
obs.diverged = null;
|
|
5301
|
+
continue;
|
|
5302
|
+
}
|
|
5303
|
+
obs.state = "healthy";
|
|
5304
|
+
continue;
|
|
5305
|
+
}
|
|
5306
|
+
}
|
|
4922
5307
|
const now = performance.now();
|
|
4923
5308
|
const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
|
|
4924
|
-
if (now
|
|
5309
|
+
if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) continue;
|
|
4925
5310
|
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
4926
5311
|
await this.signalHeldKpWelcome(g.group_id);
|
|
4927
|
-
} catch {
|
|
5312
|
+
} catch (e) {
|
|
5313
|
+
console.warn(`[bridge] selfHealSweep: channel ${c.channel_id} failed:`, e);
|
|
5314
|
+
} finally {
|
|
5315
|
+
this.observations.set(c.channel_id, obs);
|
|
4928
5316
|
}
|
|
4929
5317
|
}
|
|
4930
5318
|
}
|
|
@@ -4979,7 +5367,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
4979
5367
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
4980
5368
|
const joined = await this.reconcileJoin(channelId);
|
|
4981
5369
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
4982
|
-
const id = messageId
|
|
5370
|
+
const id = resolveMessageId(messageId);
|
|
4983
5371
|
const sendStart = performance.now();
|
|
4984
5372
|
await this.sendWithSelfHealTerminal(
|
|
4985
5373
|
this.provider,
|
|
@@ -5016,7 +5404,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5016
5404
|
this.enforceRateFloor("a2a", channelId);
|
|
5017
5405
|
const joined = await this.reconcileJoin(channelId);
|
|
5018
5406
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
5019
|
-
const id = messageId
|
|
5407
|
+
const id = resolveMessageId(messageId);
|
|
5020
5408
|
const sendStart = performance.now();
|
|
5021
5409
|
await this.sendWithSelfHealTerminal(
|
|
5022
5410
|
this.provider,
|
|
@@ -5557,11 +5945,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5557
5945
|
if (await this.provider.hasPersistedChannelState(cid)) continue;
|
|
5558
5946
|
const now = performance.now();
|
|
5559
5947
|
const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
|
|
5560
|
-
if (now
|
|
5948
|
+
if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) continue;
|
|
5561
5949
|
this.lastWelcomeRequest.set(cid, now);
|
|
5562
5950
|
try {
|
|
5563
5951
|
await this.signalHeldKpWelcome(gid);
|
|
5564
|
-
} catch {
|
|
5952
|
+
} catch (e) {
|
|
5953
|
+
console.warn(`[bridge] ingestOnce: signalHeldKpWelcome for ${cid} failed:`, e);
|
|
5565
5954
|
}
|
|
5566
5955
|
}
|
|
5567
5956
|
} catch {
|
|
@@ -5693,7 +6082,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5693
6082
|
let got = this.takeBuffered(channelId);
|
|
5694
6083
|
if (got.length || waitSeconds <= 0) return got;
|
|
5695
6084
|
const cap = Math.min(waitSeconds, 50) * 1e3;
|
|
5696
|
-
await new Promise((
|
|
6085
|
+
await new Promise((resolve3) => {
|
|
5697
6086
|
let fired = false;
|
|
5698
6087
|
let timer;
|
|
5699
6088
|
const wake = () => {
|
|
@@ -5702,7 +6091,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5702
6091
|
clearTimeout(timer);
|
|
5703
6092
|
const i = this.waiters.indexOf(wake);
|
|
5704
6093
|
if (i >= 0) this.waiters.splice(i, 1);
|
|
5705
|
-
|
|
6094
|
+
resolve3();
|
|
5706
6095
|
};
|
|
5707
6096
|
timer = setTimeout(wake, cap);
|
|
5708
6097
|
this.waiters.push(wake);
|
|
@@ -5750,11 +6139,55 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5750
6139
|
const w = this.waiters.splice(0);
|
|
5751
6140
|
for (const r of w) r();
|
|
5752
6141
|
}
|
|
6142
|
+
/**
|
|
6143
|
+
* Task 8: record a newly authenticated /v1/ws session. Called by
|
|
6144
|
+
* ws-server.ts exactly once per connection, AFTER authedUpgrade() passes
|
|
6145
|
+
* (never for a socket closed 1008 for bad auth, and never for the HTTP
|
|
6146
|
+
* /healthz probe, which is not a /v1/ws session at all).
|
|
6147
|
+
*/
|
|
6148
|
+
noteSessionOpen() {
|
|
6149
|
+
this.authSessions++;
|
|
6150
|
+
}
|
|
6151
|
+
/**
|
|
6152
|
+
* Task 8: record an authenticated /v1/ws session ending. Clamped at 0 so a
|
|
6153
|
+
* stray extra close (e.g. a double-fired cleanup) can't go negative and
|
|
6154
|
+
* poison the next open/close cycle.
|
|
6155
|
+
*/
|
|
6156
|
+
noteSessionClose() {
|
|
6157
|
+
this.authSessions = Math.max(0, this.authSessions - 1);
|
|
6158
|
+
}
|
|
6159
|
+
/**
|
|
6160
|
+
* Task 3 (audit M3): record a prior /v1/ws session being evicted by a
|
|
6161
|
+
* newer connection for the same identity (newest-wins). Called by
|
|
6162
|
+
* ws-server.ts right after prior.cleanup() in the eviction path.
|
|
6163
|
+
*/
|
|
6164
|
+
noteSessionEvicted() {
|
|
6165
|
+
this.evictions++;
|
|
6166
|
+
}
|
|
5753
6167
|
health() {
|
|
5754
6168
|
let joined = 0;
|
|
5755
6169
|
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
6170
|
+
const channels = [...this.observations.values()];
|
|
6171
|
+
const newest = channels.reduce((m, o) => Math.max(m, o.observed_at), 0);
|
|
6172
|
+
const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
|
|
6173
|
+
const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
|
|
6174
|
+
const divergedChannels = channels.filter((o) => o.diverged === true).length;
|
|
6175
|
+
const liveChannels = channels.map((o) => ({
|
|
6176
|
+
...o,
|
|
6177
|
+
last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
|
|
6178
|
+
}));
|
|
5756
6179
|
return {
|
|
6180
|
+
// Deliberately still unconditional. `agent-bridge status` and any external
|
|
6181
|
+
// monitor read this field, and a monitor that restarts non-ok bridges would
|
|
6182
|
+
// loop forever on a diverged agent. The additive fields below land first so
|
|
6183
|
+
// the data can be trusted; flipping this to "degraded" is its own change.
|
|
5757
6184
|
status: "ok",
|
|
6185
|
+
sweep_enabled: this.selfHealSweepEnabled,
|
|
6186
|
+
observations_stale: observationsStale,
|
|
6187
|
+
diverged_channels: divergedChannels,
|
|
6188
|
+
// A2: the truthful roll-up gates read instead of `status` (D-3).
|
|
6189
|
+
degraded: divergedChannels > 0 || observationsStale,
|
|
6190
|
+
channels: liveChannels,
|
|
5758
6191
|
identity_id: this.cfg.identityId,
|
|
5759
6192
|
joined_channels: joined,
|
|
5760
6193
|
buffered: this.buffer.length,
|
|
@@ -5764,6 +6197,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
5764
6197
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
5765
6198
|
failed_attachments: this.failedAttachments,
|
|
5766
6199
|
delivery_pings: this.pingsReceived,
|
|
6200
|
+
agent_sessions: this.authSessions,
|
|
6201
|
+
evictions: this.evictions,
|
|
5767
6202
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
5768
6203
|
version: BRIDGE_VERSION,
|
|
5769
6204
|
...this.memory.stats?.() ?? {}
|
|
@@ -5961,7 +6396,7 @@ function jsonError(res, code, detail) {
|
|
|
5961
6396
|
json(res, httpStatus(code), errorBody(code, detail));
|
|
5962
6397
|
}
|
|
5963
6398
|
function readBody(req, cap) {
|
|
5964
|
-
return new Promise((
|
|
6399
|
+
return new Promise((resolve3, reject) => {
|
|
5965
6400
|
let size = 0;
|
|
5966
6401
|
let done = false;
|
|
5967
6402
|
const chunks = [];
|
|
@@ -5981,7 +6416,7 @@ function readBody(req, cap) {
|
|
|
5981
6416
|
req.on("end", () => {
|
|
5982
6417
|
if (!done) {
|
|
5983
6418
|
done = true;
|
|
5984
|
-
|
|
6419
|
+
resolve3(Buffer.concat(chunks).toString("utf8"));
|
|
5985
6420
|
}
|
|
5986
6421
|
});
|
|
5987
6422
|
req.on("error", (e) => {
|
|
@@ -6030,7 +6465,7 @@ function createHttpServer(agent, opts) {
|
|
|
6030
6465
|
if (method === "POST" && path === "/v1/send") {
|
|
6031
6466
|
const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
|
|
6032
6467
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
6033
|
-
const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a
|
|
6468
|
+
const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a) : await agent.send(parsed.data.channel_id, parsed.data.text);
|
|
6034
6469
|
return json(res, 200, r);
|
|
6035
6470
|
}
|
|
6036
6471
|
if (method === "POST" && path === "/v1/send-attachment") {
|
|
@@ -6161,12 +6596,14 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
6161
6596
|
sock.close(1008, "unauthorized");
|
|
6162
6597
|
return;
|
|
6163
6598
|
}
|
|
6599
|
+
agent.noteSessionOpen();
|
|
6164
6600
|
if (current && current.sock !== sock) {
|
|
6165
6601
|
const prior = current;
|
|
6166
6602
|
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
6167
6603
|
prior.sock.close(1e3, "replaced by newer connection");
|
|
6168
6604
|
}
|
|
6169
6605
|
prior.cleanup();
|
|
6606
|
+
agent.noteSessionEvicted();
|
|
6170
6607
|
}
|
|
6171
6608
|
sock.isAlive = true;
|
|
6172
6609
|
sock.on("pong", () => {
|
|
@@ -6196,6 +6633,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
6196
6633
|
const cleanup = () => {
|
|
6197
6634
|
if (cleaned) return;
|
|
6198
6635
|
cleaned = true;
|
|
6636
|
+
agent.noteSessionClose();
|
|
6199
6637
|
unsubscribe();
|
|
6200
6638
|
if (inflight.size) {
|
|
6201
6639
|
agent.requeueFront([...inflight.values()]);
|
|
@@ -6282,9 +6720,8 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
6282
6720
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
6283
6721
|
return;
|
|
6284
6722
|
}
|
|
6285
|
-
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : void 0;
|
|
6286
6723
|
try {
|
|
6287
|
-
const r = await agent.send(m.channel_id, m.text
|
|
6724
|
+
const r = await agent.send(m.channel_id, m.text);
|
|
6288
6725
|
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
6289
6726
|
} catch (e) {
|
|
6290
6727
|
if (e instanceof ChannelPendingError) {
|
|
@@ -6395,9 +6832,9 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
6395
6832
|
}
|
|
6396
6833
|
|
|
6397
6834
|
// src/bin.ts
|
|
6398
|
-
import { readFileSync as
|
|
6399
|
-
import { join as
|
|
6400
|
-
import { fileURLToPath } from "node:url";
|
|
6835
|
+
import { readFileSync as readFileSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, rmSync, existsSync as existsSync2, realpathSync, readdirSync, statSync as statSync2 } from "node:fs";
|
|
6836
|
+
import { join as join11, dirname as dirname7, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
|
|
6837
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6401
6838
|
import { homedir as homedir2, userInfo } from "node:os";
|
|
6402
6839
|
import { spawnSync } from "node:child_process";
|
|
6403
6840
|
|
|
@@ -6406,10 +6843,83 @@ import { dirname as dirname4 } from "node:path";
|
|
|
6406
6843
|
|
|
6407
6844
|
// src/service/config.ts
|
|
6408
6845
|
import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
|
|
6846
|
+
|
|
6847
|
+
// src/service/resolve-node.ts
|
|
6848
|
+
var REQUIRED_NODE_MAJOR = 24;
|
|
6849
|
+
var VERSION_EMBEDDED = [
|
|
6850
|
+
/[\\/]Cellar[\\/]/,
|
|
6851
|
+
// Homebrew keg
|
|
6852
|
+
/[\\/]node[\\/]v?\d+\.\d+/,
|
|
6853
|
+
// nvm, generic .../node/v24.4.1/...
|
|
6854
|
+
/[\\/]nodejs[\\/]\d/,
|
|
6855
|
+
// asdf .../installs/nodejs/24.4.1/...
|
|
6856
|
+
/[\\/]node-versions[\\/]/,
|
|
6857
|
+
// fnm
|
|
6858
|
+
/[\\/]\.volta[\\/]/,
|
|
6859
|
+
// volta
|
|
6860
|
+
/[\\/]\.fnm[\\/]/,
|
|
6861
|
+
// fnm (alt layout)
|
|
6862
|
+
/[\\/]\.nvm[\\/]/,
|
|
6863
|
+
// nvm home
|
|
6864
|
+
/[\\/]\.asdf[\\/]/,
|
|
6865
|
+
// asdf home
|
|
6866
|
+
/[\\/]n[\\/]versions[\\/]/
|
|
6867
|
+
// n
|
|
6868
|
+
];
|
|
6869
|
+
function isVersionEmbeddedNodePath(p) {
|
|
6870
|
+
return VERSION_EMBEDDED.some((re) => re.test(p));
|
|
6871
|
+
}
|
|
6872
|
+
var STABLE_LOCATIONS = [
|
|
6873
|
+
"/usr/local/bin/node",
|
|
6874
|
+
// nodejs.org pkg (mac + linux)
|
|
6875
|
+
"/opt/homebrew/bin/node",
|
|
6876
|
+
// Homebrew symlink (arm mac)
|
|
6877
|
+
"/opt/homebrew/opt/node/bin/node",
|
|
6878
|
+
"/usr/bin/node"
|
|
6879
|
+
// system (linux)
|
|
6880
|
+
];
|
|
6881
|
+
function usable(path, deps) {
|
|
6882
|
+
if (!deps.fileExists(path)) return false;
|
|
6883
|
+
if (isVersionEmbeddedNodePath(path)) return false;
|
|
6884
|
+
const major = deps.nodeMajorOf(path);
|
|
6885
|
+
if (major == null || major < REQUIRED_NODE_MAJOR) return false;
|
|
6886
|
+
const a = deps.archOf(path);
|
|
6887
|
+
if (a != null && a !== deps.arch()) return false;
|
|
6888
|
+
return true;
|
|
6889
|
+
}
|
|
6890
|
+
function resolveStableNodePath(runningExecPath, deps) {
|
|
6891
|
+
let runningReal;
|
|
6892
|
+
try {
|
|
6893
|
+
runningReal = deps.realpath(runningExecPath);
|
|
6894
|
+
} catch {
|
|
6895
|
+
runningReal = runningExecPath;
|
|
6896
|
+
}
|
|
6897
|
+
for (const loc of STABLE_LOCATIONS) {
|
|
6898
|
+
if (!deps.fileExists(loc)) continue;
|
|
6899
|
+
let locReal;
|
|
6900
|
+
try {
|
|
6901
|
+
locReal = deps.realpath(loc);
|
|
6902
|
+
} catch {
|
|
6903
|
+
continue;
|
|
6904
|
+
}
|
|
6905
|
+
if (locReal === runningReal && usable(loc, deps)) return { path: loc, stable: true };
|
|
6906
|
+
}
|
|
6907
|
+
for (const loc of STABLE_LOCATIONS) {
|
|
6908
|
+
if (usable(loc, deps)) return { path: loc, stable: true };
|
|
6909
|
+
}
|
|
6910
|
+
return { path: runningExecPath, stable: false };
|
|
6911
|
+
}
|
|
6912
|
+
|
|
6913
|
+
// src/service/config.ts
|
|
6409
6914
|
function slugify(s) {
|
|
6410
6915
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
6411
6916
|
}
|
|
6412
6917
|
function deriveServiceConfig(input) {
|
|
6918
|
+
if (isVersionEmbeddedNodePath(input.execPath)) {
|
|
6919
|
+
throw new Error(
|
|
6920
|
+
`install: refusing to bake a version-embedded Node path into the service definition: ${input.execPath}. Re-run install under a system/Homebrew Node, or pass --exec-path <stable-path>.`
|
|
6921
|
+
);
|
|
6922
|
+
}
|
|
6413
6923
|
const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
|
|
6414
6924
|
const slug = slugify(stem);
|
|
6415
6925
|
const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
|
|
@@ -6531,6 +7041,417 @@ function uninstallService(input, deps) {
|
|
|
6531
7041
|
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
6532
7042
|
}
|
|
6533
7043
|
|
|
7044
|
+
// src/service/install-prep.ts
|
|
7045
|
+
import { join as join8 } from "node:path";
|
|
7046
|
+
function assertReadableCredential(absPath, deps) {
|
|
7047
|
+
let raw;
|
|
7048
|
+
try {
|
|
7049
|
+
raw = deps.readFile(absPath);
|
|
7050
|
+
} catch {
|
|
7051
|
+
throw new Error(`install: credential file not found or unreadable: ${absPath}`);
|
|
7052
|
+
}
|
|
7053
|
+
let json2;
|
|
7054
|
+
try {
|
|
7055
|
+
json2 = JSON.parse(raw);
|
|
7056
|
+
} catch {
|
|
7057
|
+
throw new Error(`install: credential file is not valid JSON: ${absPath}`);
|
|
7058
|
+
}
|
|
7059
|
+
for (const k of REQUIRED) {
|
|
7060
|
+
if (typeof json2[k] !== "string" || !json2[k]) {
|
|
7061
|
+
throw new Error(`install: credential file missing required field '${k}': ${absPath}`);
|
|
7062
|
+
}
|
|
7063
|
+
}
|
|
7064
|
+
const issuedAt = typeof json2.issued_at === "string" && json2.issued_at ? json2.issued_at : null;
|
|
7065
|
+
return { deviceId: json2.device_id, identityId: json2.identity_id, issuedAt };
|
|
7066
|
+
}
|
|
7067
|
+
function durableDir(home, slug) {
|
|
7068
|
+
return join8(home, `securecoms-agent-${slug}`);
|
|
7069
|
+
}
|
|
7070
|
+
function durableCredentialPath(home, slug) {
|
|
7071
|
+
return join8(durableDir(home, slug), `${slug}.json`);
|
|
7072
|
+
}
|
|
7073
|
+
function takeCustody(args2, deps) {
|
|
7074
|
+
const dir = durableDir(args2.home, args2.slug);
|
|
7075
|
+
const dest = durableCredentialPath(args2.home, args2.slug);
|
|
7076
|
+
try {
|
|
7077
|
+
if (deps.fileExists(dest) && deps.realpath(args2.sourceAbsPath) === deps.realpath(dest)) return dest;
|
|
7078
|
+
} catch {
|
|
7079
|
+
}
|
|
7080
|
+
if (deps.fileExists(dest)) {
|
|
7081
|
+
try {
|
|
7082
|
+
const existing = JSON.parse(deps.readFile(dest));
|
|
7083
|
+
if (existing.device_id && existing.device_id !== args2.parsed.deviceId) {
|
|
7084
|
+
throw new Error(
|
|
7085
|
+
`install: ${dir} already belongs to a different agent (device ${existing.device_id}). Pass a distinct --label to install this agent in its own directory.`
|
|
7086
|
+
);
|
|
7087
|
+
}
|
|
7088
|
+
} catch (e) {
|
|
7089
|
+
if (e instanceof Error && e.message.startsWith("install:")) throw e;
|
|
7090
|
+
}
|
|
7091
|
+
}
|
|
7092
|
+
deps.mkdirp(dir, 448);
|
|
7093
|
+
deps.writeFileMode(dest, deps.readFile(args2.sourceAbsPath), 384);
|
|
7094
|
+
return dest;
|
|
7095
|
+
}
|
|
7096
|
+
var PORT_MIN = 7077;
|
|
7097
|
+
var PORT_MAX = 7276;
|
|
7098
|
+
function parseClaimedPorts(fileContents) {
|
|
7099
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
7100
|
+
const re = /--port(?:<\/string>\s*<string>|\s+)(\d+)/g;
|
|
7101
|
+
for (const text of fileContents) {
|
|
7102
|
+
let m;
|
|
7103
|
+
while ((m = re.exec(text)) !== null) claimed.add(Number(m[1]));
|
|
7104
|
+
}
|
|
7105
|
+
return claimed;
|
|
7106
|
+
}
|
|
7107
|
+
function readSiblingDefs(dir, prefix, deps) {
|
|
7108
|
+
let names;
|
|
7109
|
+
try {
|
|
7110
|
+
names = deps.listDir(dir);
|
|
7111
|
+
} catch {
|
|
7112
|
+
return [];
|
|
7113
|
+
}
|
|
7114
|
+
const out = [];
|
|
7115
|
+
for (const n of names) {
|
|
7116
|
+
if (!n.startsWith(prefix)) continue;
|
|
7117
|
+
try {
|
|
7118
|
+
out.push(deps.readFile(join8(dir, n)));
|
|
7119
|
+
} catch {
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
return out;
|
|
7123
|
+
}
|
|
7124
|
+
function selectPort(args2, deps) {
|
|
7125
|
+
const defs = [
|
|
7126
|
+
...readSiblingDefs(args2.launchAgentsDir, "com.securecoms.bridge.", deps),
|
|
7127
|
+
...readSiblingDefs(args2.systemdDir, "securecoms-bridge-", deps)
|
|
7128
|
+
];
|
|
7129
|
+
const claimed = parseClaimedPorts(defs);
|
|
7130
|
+
for (let port = PORT_MIN; port <= PORT_MAX; port++) {
|
|
7131
|
+
if (claimed.has(port)) continue;
|
|
7132
|
+
if (!deps.probeBindFree(port)) continue;
|
|
7133
|
+
return port;
|
|
7134
|
+
}
|
|
7135
|
+
throw new Error(`install: no free port in ${PORT_MIN}-${PORT_MAX} \u2014 stop an unused bridge or pass --port`);
|
|
7136
|
+
}
|
|
7137
|
+
async function verifyDaemonUp(args2, deps) {
|
|
7138
|
+
const attempts = args2.attempts ?? 20;
|
|
7139
|
+
const delayMs = args2.delayMs ?? 250;
|
|
7140
|
+
for (let i = 0; i < attempts; i++) {
|
|
7141
|
+
if (deps.probeConnect(args2.port)) return true;
|
|
7142
|
+
try {
|
|
7143
|
+
if (deps.readFile(args2.logPath).includes("[bridge] listening on")) return true;
|
|
7144
|
+
} catch {
|
|
7145
|
+
}
|
|
7146
|
+
await deps.sleep(delayMs);
|
|
7147
|
+
}
|
|
7148
|
+
return false;
|
|
7149
|
+
}
|
|
7150
|
+
function harnessSnippet(args2) {
|
|
7151
|
+
const label = args2.harness === "hermes" ? "Hermes" : args2.harness === "openclaw" ? "OpenClaw" : "your harness";
|
|
7152
|
+
return [
|
|
7153
|
+
`# ${label}: optional daemon-supervised path. Prefer 'agent-bridge wire' + the connection file.`,
|
|
7154
|
+
`SECURECOMS_BRIDGE_URL=ws://127.0.0.1:${args2.port}/v1/ws`,
|
|
7155
|
+
`SECURECOMS_CONNECTION_FILE=~/.securecoms/connections/${args2.identityId}.json`,
|
|
7156
|
+
`SECURECOMS_BRIDGE_TOKEN_FILE=${args2.tokenFilePath}`
|
|
7157
|
+
].join("\n");
|
|
7158
|
+
}
|
|
7159
|
+
async function orchestrateInstall(ctx) {
|
|
7160
|
+
const parsed = assertReadableCredential(ctx.credentialAbsPath, ctx.prepDeps);
|
|
7161
|
+
const stemForSlug = ctx.label ?? ctx.credentialAbsPath.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, "");
|
|
7162
|
+
const slug = slugify(stemForSlug);
|
|
7163
|
+
const durablePath = takeCustody(
|
|
7164
|
+
{ home: ctx.home, slug, sourceAbsPath: ctx.credentialAbsPath, parsed },
|
|
7165
|
+
ctx.prepDeps
|
|
7166
|
+
);
|
|
7167
|
+
const port = ctx.explicitPort ?? selectPort(
|
|
7168
|
+
{ launchAgentsDir: ctx.launchAgentsDir ?? `${ctx.home}/Library/LaunchAgents`, systemdDir: ctx.systemdDir ?? `${ctx.home}/.config/systemd/user` },
|
|
7169
|
+
ctx.prepDeps
|
|
7170
|
+
);
|
|
7171
|
+
const input = ctx.buildServiceInput(durablePath, port, slug);
|
|
7172
|
+
const result = ctx.installService(input);
|
|
7173
|
+
const up = await verifyDaemonUp(
|
|
7174
|
+
{ port, logPath: result.logHint, attempts: ctx.verifyAttempts, delayMs: ctx.verifyDelayMs },
|
|
7175
|
+
ctx.prepDeps
|
|
7176
|
+
);
|
|
7177
|
+
if (!up) {
|
|
7178
|
+
throw new Error(
|
|
7179
|
+
`install: the daemon did not come up \u2014 check its log: ${result.logHint}`
|
|
7180
|
+
);
|
|
7181
|
+
}
|
|
7182
|
+
const apiKey = JSON.parse(ctx.prepDeps.readFile(durablePath)).api_key;
|
|
7183
|
+
const lines = [
|
|
7184
|
+
`Installed service ${result.label}`,
|
|
7185
|
+
` device: ${parsed.deviceId}`,
|
|
7186
|
+
parsed.issuedAt ? ` issued: ${parsed.issuedAt}` : ` issued: (unknown \u2014 older credential)`,
|
|
7187
|
+
` api key: ${tokenFingerprint(apiKey)}`,
|
|
7188
|
+
` port: ${port}`,
|
|
7189
|
+
` token: ${result.tokenHint}`,
|
|
7190
|
+
` (Re-running install is the only way to update a live credential \u2014 replacing the download alone does nothing.)`
|
|
7191
|
+
];
|
|
7192
|
+
let harnessSnippetShown = false;
|
|
7193
|
+
if (ctx.harness && ctx.harness !== "none") {
|
|
7194
|
+
lines.push(
|
|
7195
|
+
"",
|
|
7196
|
+
`Primary: run 'agent-bridge wire --identity ${slug}' to attach a harness; the daemon writes ~/.securecoms/connections/${parsed.identityId}.json on startup.`,
|
|
7197
|
+
"",
|
|
7198
|
+
"Optional (daemon-supervised):",
|
|
7199
|
+
harnessSnippet({ slug, port, tokenFilePath: result.tokenHint, harness: ctx.harness, identityId: parsed.identityId })
|
|
7200
|
+
);
|
|
7201
|
+
harnessSnippetShown = true;
|
|
7202
|
+
}
|
|
7203
|
+
return { echo: lines.join("\n") + "\n", harnessSnippetShown };
|
|
7204
|
+
}
|
|
7205
|
+
|
|
7206
|
+
// src/service/fleet.ts
|
|
7207
|
+
import { dirname as dirname5 } from "node:path";
|
|
7208
|
+
function parseArgFromDef(def, flag) {
|
|
7209
|
+
const esc = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7210
|
+
const re = new RegExp(`${esc}(?:<\\/string>\\s*<string>|\\s+)([^<\\s]+)`);
|
|
7211
|
+
const m = re.exec(def);
|
|
7212
|
+
return m ? m[1] : null;
|
|
7213
|
+
}
|
|
7214
|
+
function parseExecPathFromDef(def) {
|
|
7215
|
+
const plist = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/.exec(def);
|
|
7216
|
+
if (plist) return plist[1];
|
|
7217
|
+
const unit = /^ExecStart=(\S+)/m.exec(def);
|
|
7218
|
+
return unit ? unit[1] : null;
|
|
7219
|
+
}
|
|
7220
|
+
function envOf(apiBase) {
|
|
7221
|
+
if (!apiBase) return "unknown";
|
|
7222
|
+
if (apiBase.includes("app.securecoms.ai")) return "prod";
|
|
7223
|
+
return "preprod";
|
|
7224
|
+
}
|
|
7225
|
+
var SLUG_FROM_PLIST = /com\.securecoms\.bridge\.(.+)\.plist$/;
|
|
7226
|
+
var SLUG_FROM_UNIT = /securecoms-bridge-(.+)\.service$/;
|
|
7227
|
+
function readFleet(deps) {
|
|
7228
|
+
const sources = [
|
|
7229
|
+
{ dir: `${deps.home}/Library/LaunchAgents`, prefix: "com.securecoms.bridge.", slugRe: SLUG_FROM_PLIST },
|
|
7230
|
+
{ dir: `${deps.home}/.config/systemd/user`, prefix: "securecoms-bridge-", slugRe: SLUG_FROM_UNIT }
|
|
7231
|
+
];
|
|
7232
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
7233
|
+
for (const src of sources) {
|
|
7234
|
+
for (const name of deps.listDir(src.dir)) {
|
|
7235
|
+
if (!name.startsWith(src.prefix)) continue;
|
|
7236
|
+
const slugMatch = src.slugRe.exec(name);
|
|
7237
|
+
if (!slugMatch) continue;
|
|
7238
|
+
const slug = slugMatch[1];
|
|
7239
|
+
let def;
|
|
7240
|
+
try {
|
|
7241
|
+
def = deps.readFile(`${src.dir}/${name}`);
|
|
7242
|
+
} catch {
|
|
7243
|
+
continue;
|
|
7244
|
+
}
|
|
7245
|
+
const credPath = parseArgFromDef(def, "--credential");
|
|
7246
|
+
const portStr = parseArgFromDef(def, "--port");
|
|
7247
|
+
const interp = parseExecPathFromDef(def);
|
|
7248
|
+
let identityId = `unknown:${slug}`, deviceId = "", label = slug, env = "unknown";
|
|
7249
|
+
let credDir = null;
|
|
7250
|
+
if (credPath) {
|
|
7251
|
+
credDir = dirname5(credPath);
|
|
7252
|
+
try {
|
|
7253
|
+
const c = JSON.parse(deps.readFile(credPath));
|
|
7254
|
+
if (c.identity_id) identityId = c.identity_id;
|
|
7255
|
+
if (c.device_id) deviceId = c.device_id;
|
|
7256
|
+
env = envOf(c.api_base);
|
|
7257
|
+
} catch {
|
|
7258
|
+
}
|
|
7259
|
+
}
|
|
7260
|
+
byIdentity.set(identityId, {
|
|
7261
|
+
identityId,
|
|
7262
|
+
deviceId,
|
|
7263
|
+
slug,
|
|
7264
|
+
label,
|
|
7265
|
+
environment: env,
|
|
7266
|
+
port: portStr ? Number(portStr) : null,
|
|
7267
|
+
interpreterPath: interp,
|
|
7268
|
+
// Fail-loud (Part A posture): a def whose interpreter can't be parsed
|
|
7269
|
+
// is NOT a clean bill of health — it's an unknown we can't vouch for,
|
|
7270
|
+
// so it must surface as a reboot risk for triage, not pass silently.
|
|
7271
|
+
interpreterStable: interp ? !isVersionEmbeddedNodePath(interp) : false,
|
|
7272
|
+
credentialDir: credDir
|
|
7273
|
+
});
|
|
7274
|
+
}
|
|
7275
|
+
}
|
|
7276
|
+
return [...byIdentity.values()];
|
|
7277
|
+
}
|
|
7278
|
+
|
|
7279
|
+
// src/service/status.ts
|
|
7280
|
+
function readFleetStatus(deps) {
|
|
7281
|
+
return deps.readFleet().map((e) => {
|
|
7282
|
+
const up = e.port != null && deps.probeConnect(e.port);
|
|
7283
|
+
let harness = "-";
|
|
7284
|
+
let evictions = 0;
|
|
7285
|
+
if (up && e.port != null) {
|
|
7286
|
+
const h = deps.getHealth(e.port);
|
|
7287
|
+
harness = (h?.agent_sessions ?? 0) >= 1 ? "attached" : "no brain";
|
|
7288
|
+
evictions = h?.evictions ?? 0;
|
|
7289
|
+
}
|
|
7290
|
+
return {
|
|
7291
|
+
slug: e.slug,
|
|
7292
|
+
environment: e.environment,
|
|
7293
|
+
port: e.port,
|
|
7294
|
+
bridge: up ? "up" : "down",
|
|
7295
|
+
harness,
|
|
7296
|
+
interpreter: e.interpreterStable ? "stable" : "REBOOT-RISK",
|
|
7297
|
+
evictions
|
|
7298
|
+
};
|
|
7299
|
+
});
|
|
7300
|
+
}
|
|
7301
|
+
function renderStatusTable(rows) {
|
|
7302
|
+
const head = ["AGENT", "ENV", "PORT", "BRIDGE", "HARNESS", "INTERPRETER", "FLAPS"];
|
|
7303
|
+
const body = rows.map((r) => [
|
|
7304
|
+
r.slug,
|
|
7305
|
+
r.environment,
|
|
7306
|
+
String(r.port ?? "-"),
|
|
7307
|
+
r.bridge,
|
|
7308
|
+
r.harness,
|
|
7309
|
+
r.interpreter,
|
|
7310
|
+
String(r.evictions)
|
|
7311
|
+
]);
|
|
7312
|
+
const widths = head.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
|
|
7313
|
+
const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
|
|
7314
|
+
return [fmt(head), ...body.map(fmt)].join("\n") + "\n";
|
|
7315
|
+
}
|
|
7316
|
+
|
|
7317
|
+
// src/service/wire.ts
|
|
7318
|
+
import { join as join9, dirname as dirname6 } from "node:path";
|
|
7319
|
+
import { fileURLToPath } from "node:url";
|
|
7320
|
+
function detectHarness(home, statDir) {
|
|
7321
|
+
if (statDir(`${home}/.openclaw`)) return "openclaw";
|
|
7322
|
+
if (statDir(`${home}/.hermes`)) return "hermes";
|
|
7323
|
+
return null;
|
|
7324
|
+
}
|
|
7325
|
+
function connectorTargetDir(home, harness) {
|
|
7326
|
+
if (harness === "openclaw") return `${home}/.openclaw/local-plugins/securecoms`;
|
|
7327
|
+
return `${home}/.hermes/local-plugins/securecoms`;
|
|
7328
|
+
}
|
|
7329
|
+
function connectorsSrcDir(importMetaUrl) {
|
|
7330
|
+
const pkgRoot = dirname6(dirname6(fileURLToPath(importMetaUrl)));
|
|
7331
|
+
return join9(pkgRoot, "connectors", "openclaw");
|
|
7332
|
+
}
|
|
7333
|
+
function parseArg(argv, flag) {
|
|
7334
|
+
const i = argv.indexOf(flag);
|
|
7335
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
7336
|
+
}
|
|
7337
|
+
function resolveEntry(deps) {
|
|
7338
|
+
const identityArg = parseArg(deps.argv, "--identity");
|
|
7339
|
+
if (!identityArg) {
|
|
7340
|
+
deps.log("wire: missing --identity <id|slug>");
|
|
7341
|
+
return null;
|
|
7342
|
+
}
|
|
7343
|
+
const entry = deps.readFleet().find((e) => e.identityId === identityArg || e.slug === identityArg);
|
|
7344
|
+
if (!entry) {
|
|
7345
|
+
deps.log(`wire: unknown identity "${identityArg}" (not found in the local fleet)`);
|
|
7346
|
+
return null;
|
|
7347
|
+
}
|
|
7348
|
+
const harnessArg = parseArg(deps.argv, "--harness");
|
|
7349
|
+
if (harnessArg !== "openclaw" && harnessArg !== "hermes") {
|
|
7350
|
+
deps.log("wire: missing/invalid --harness <openclaw|hermes> (auto-detection did not resolve one)");
|
|
7351
|
+
return null;
|
|
7352
|
+
}
|
|
7353
|
+
return { entry, harness: harnessArg };
|
|
7354
|
+
}
|
|
7355
|
+
function runWire(deps) {
|
|
7356
|
+
const resolved = resolveEntry(deps);
|
|
7357
|
+
if (!resolved) return 1;
|
|
7358
|
+
const { entry, harness } = resolved;
|
|
7359
|
+
if (harness === "hermes") {
|
|
7360
|
+
deps.log(
|
|
7361
|
+
"[securecoms] Hermes connector is not available yet (fast-follow); detection and target dir only. Nothing installed."
|
|
7362
|
+
);
|
|
7363
|
+
return 1;
|
|
7364
|
+
}
|
|
7365
|
+
const targetDir = connectorTargetDir(deps.home, harness);
|
|
7366
|
+
deps.copyConnector(deps.connectorsSrcDir, targetDir);
|
|
7367
|
+
deps.writeBinding(targetDir, entry.identityId);
|
|
7368
|
+
if (typeof entry.port === "number") {
|
|
7369
|
+
const h = deps.getHealth(entry.port);
|
|
7370
|
+
if (h && (h.agent_sessions ?? 0) >= 1) {
|
|
7371
|
+
deps.log(`wire: ${entry.slug} attached (agent_sessions=${h.agent_sessions})`);
|
|
7372
|
+
} else {
|
|
7373
|
+
deps.log(`wire: connector installed but not yet attached \u2014 restart the harness to pick it up.`);
|
|
7374
|
+
}
|
|
7375
|
+
} else {
|
|
7376
|
+
deps.log(`wire: connector installed (no bridge port known yet) \u2014 restart the harness to pick it up.`);
|
|
7377
|
+
}
|
|
7378
|
+
return 0;
|
|
7379
|
+
}
|
|
7380
|
+
function runUnwire(deps) {
|
|
7381
|
+
const resolved = resolveEntry(deps);
|
|
7382
|
+
if (!resolved) return 1;
|
|
7383
|
+
const { harness } = resolved;
|
|
7384
|
+
if (harness === "hermes") {
|
|
7385
|
+
deps.log("[securecoms] Hermes connector is not available yet (fast-follow); nothing to remove.");
|
|
7386
|
+
return 0;
|
|
7387
|
+
}
|
|
7388
|
+
deps.removeBinding(connectorTargetDir(deps.home, harness));
|
|
7389
|
+
return 0;
|
|
7390
|
+
}
|
|
7391
|
+
|
|
7392
|
+
// src/service/connection-file.ts
|
|
7393
|
+
import {
|
|
7394
|
+
chmodSync,
|
|
7395
|
+
mkdirSync,
|
|
7396
|
+
readFileSync as readFileSync6,
|
|
7397
|
+
renameSync,
|
|
7398
|
+
unlinkSync,
|
|
7399
|
+
writeFileSync as writeFileSync2
|
|
7400
|
+
} from "node:fs";
|
|
7401
|
+
import { randomUUID } from "node:crypto";
|
|
7402
|
+
import { join as join10 } from "node:path";
|
|
7403
|
+
var REAL_FS = {
|
|
7404
|
+
mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
|
|
7405
|
+
chmod: (p, mode) => chmodSync(p, mode),
|
|
7406
|
+
writeFile: (p, data, mode) => writeFileSync2(p, data, { mode }),
|
|
7407
|
+
rename: (a, b) => renameSync(a, b),
|
|
7408
|
+
readFile: (p) => readFileSync6(p, "utf8"),
|
|
7409
|
+
unlink: (p) => unlinkSync(p)
|
|
7410
|
+
};
|
|
7411
|
+
function securecomsDir(home) {
|
|
7412
|
+
return join10(home, ".securecoms");
|
|
7413
|
+
}
|
|
7414
|
+
function connectionsDir(home) {
|
|
7415
|
+
return join10(securecomsDir(home), "connections");
|
|
7416
|
+
}
|
|
7417
|
+
function connectionFilePath(home, identityId) {
|
|
7418
|
+
return join10(connectionsDir(home), `${identityId}.json`);
|
|
7419
|
+
}
|
|
7420
|
+
function newInstanceId() {
|
|
7421
|
+
return randomUUID();
|
|
7422
|
+
}
|
|
7423
|
+
function buildConnectionFile(args2) {
|
|
7424
|
+
return {
|
|
7425
|
+
schema: "securecoms.connection/1",
|
|
7426
|
+
identity_id: args2.identityId,
|
|
7427
|
+
bridge_url: `ws://127.0.0.1:${args2.port}/v1/ws`,
|
|
7428
|
+
token_file: args2.tokenFile,
|
|
7429
|
+
instance_id: args2.instanceId,
|
|
7430
|
+
slug: args2.slug ?? args2.identityId,
|
|
7431
|
+
label: args2.label ?? args2.slug ?? args2.identityId,
|
|
7432
|
+
written_at: args2.now
|
|
7433
|
+
};
|
|
7434
|
+
}
|
|
7435
|
+
function writeConnectionFile(home, doc, deps = REAL_FS) {
|
|
7436
|
+
deps.mkdir(securecomsDir(home), 448);
|
|
7437
|
+
deps.chmod(securecomsDir(home), 448);
|
|
7438
|
+
deps.mkdir(connectionsDir(home), 448);
|
|
7439
|
+
deps.chmod(connectionsDir(home), 448);
|
|
7440
|
+
const path = connectionFilePath(home, doc.identity_id);
|
|
7441
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
7442
|
+
deps.writeFile(tmp, JSON.stringify(doc, null, 2) + "\n", 384);
|
|
7443
|
+
deps.chmod(tmp, 384);
|
|
7444
|
+
deps.rename(tmp, path);
|
|
7445
|
+
}
|
|
7446
|
+
function removeConnectionFileIfOwned(home, identityId, instanceId, deps = REAL_FS) {
|
|
7447
|
+
const path = connectionFilePath(home, identityId);
|
|
7448
|
+
try {
|
|
7449
|
+
const cur = JSON.parse(deps.readFile(path));
|
|
7450
|
+
if (cur.instance_id === instanceId) deps.unlink(path);
|
|
7451
|
+
} catch {
|
|
7452
|
+
}
|
|
7453
|
+
}
|
|
7454
|
+
|
|
6534
7455
|
// src/bin.ts
|
|
6535
7456
|
function nodeMajorOk(version) {
|
|
6536
7457
|
return Number(version.split(".")[0]) >= 24;
|
|
@@ -6546,7 +7467,15 @@ function argOrEnv(flag, env) {
|
|
|
6546
7467
|
}
|
|
6547
7468
|
function parseVerb(argv) {
|
|
6548
7469
|
const v = argv[2];
|
|
6549
|
-
return v === "install" || v === "uninstall" ? v : null;
|
|
7470
|
+
return v === "install" || v === "uninstall" || v === "status" || v === "wire" || v === "unwire" ? v : null;
|
|
7471
|
+
}
|
|
7472
|
+
function slugFromCredentialPath(credentialPath) {
|
|
7473
|
+
const dir = dirname7(credentialPath);
|
|
7474
|
+
const base = dir.split(/[\\/]/).pop() ?? "";
|
|
7475
|
+
const m = base.match(/^securecoms-agent-(.+)$/);
|
|
7476
|
+
if (m) return m[1];
|
|
7477
|
+
const file = credentialPath.split(/[\\/]/).pop() ?? "agent";
|
|
7478
|
+
return file.replace(/\.json$/, "");
|
|
6550
7479
|
}
|
|
6551
7480
|
function realServiceDeps() {
|
|
6552
7481
|
return {
|
|
@@ -6555,13 +7484,41 @@ function realServiceDeps() {
|
|
|
6555
7484
|
const r = spawnSync(cmd, args2, { encoding: "utf8" });
|
|
6556
7485
|
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
6557
7486
|
},
|
|
6558
|
-
writeFile: (p, data) =>
|
|
7487
|
+
writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
|
|
6559
7488
|
removeFile: (p) => rmSync(p, { force: true }),
|
|
6560
|
-
mkdirp: (p) =>
|
|
7489
|
+
mkdirp: (p) => mkdirSync2(p, { recursive: true }),
|
|
6561
7490
|
fileExists: (p) => existsSync2(p),
|
|
6562
7491
|
user: userInfo().username
|
|
6563
7492
|
};
|
|
6564
7493
|
}
|
|
7494
|
+
function chooseInstallExecPath(runningExecPath, override, deps) {
|
|
7495
|
+
if (override) return override;
|
|
7496
|
+
const r = resolveStableNodePath(runningExecPath, deps);
|
|
7497
|
+
if (!r.stable) {
|
|
7498
|
+
throw new Error(
|
|
7499
|
+
"install: no reboot-stable Node found. Version-manager Nodes (nvm/asdf/fnm/volta/n) are removed on upgrade and will break the daemon after a reboot. Install a system Node (nodejs.org pkg \u2192 /usr/local/bin/node) or pass --exec-path <stable-path> to override."
|
|
7500
|
+
);
|
|
7501
|
+
}
|
|
7502
|
+
return r.path;
|
|
7503
|
+
}
|
|
7504
|
+
function realResolveNodeDeps() {
|
|
7505
|
+
const majorOf = (p) => {
|
|
7506
|
+
const r = spawnSync(p, ["--version"], { encoding: "utf8" });
|
|
7507
|
+
if (r.status !== 0) return null;
|
|
7508
|
+
const m = /v?(\d+)\./.exec((r.stdout ?? "").trim());
|
|
7509
|
+
return m ? Number(m[1]) : null;
|
|
7510
|
+
};
|
|
7511
|
+
return {
|
|
7512
|
+
realpath: (p) => realpathSync(p),
|
|
7513
|
+
fileExists: (p) => existsSync2(p),
|
|
7514
|
+
nodeMajorOf: majorOf,
|
|
7515
|
+
arch: () => process.arch,
|
|
7516
|
+
archOf: (p) => {
|
|
7517
|
+
const r = spawnSync(p, ["-p", "process.arch"], { encoding: "utf8" });
|
|
7518
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : null;
|
|
7519
|
+
}
|
|
7520
|
+
};
|
|
7521
|
+
}
|
|
6565
7522
|
function realResolveDeps() {
|
|
6566
7523
|
return {
|
|
6567
7524
|
run: (cmd, args2) => {
|
|
@@ -6571,11 +7528,139 @@ function realResolveDeps() {
|
|
|
6571
7528
|
fileExists: (p) => existsSync2(p)
|
|
6572
7529
|
};
|
|
6573
7530
|
}
|
|
6574
|
-
function
|
|
7531
|
+
function realPrepDeps() {
|
|
7532
|
+
return {
|
|
7533
|
+
readFile: (p) => readFileSync7(p, "utf8"),
|
|
7534
|
+
writeFileMode: (p, data, mode) => writeFileSync3(p, data, { mode }),
|
|
7535
|
+
mkdirp: (p, mode) => {
|
|
7536
|
+
mkdirSync2(p, { recursive: true, mode: mode ?? 493 });
|
|
7537
|
+
},
|
|
7538
|
+
fileExists: (p) => existsSync2(p),
|
|
7539
|
+
realpath: (p) => realpathSync(p),
|
|
7540
|
+
listDir: (p) => {
|
|
7541
|
+
try {
|
|
7542
|
+
return readdirSync(p);
|
|
7543
|
+
} catch {
|
|
7544
|
+
return [];
|
|
7545
|
+
}
|
|
7546
|
+
},
|
|
7547
|
+
// Synchronous spawn-free-loop probes: node's net API is async-only, so each
|
|
7548
|
+
// probe is a tiny `node -e` child that binds/connects and exits 0/1.
|
|
7549
|
+
probeBindFree: (port) => {
|
|
7550
|
+
const r = spawnSync(process.execPath, [
|
|
7551
|
+
"-e",
|
|
7552
|
+
`const s=require('net').createServer();s.once('error',()=>process.exit(1));s.listen(${port},'127.0.0.1',()=>{s.close(()=>process.exit(0))});`
|
|
7553
|
+
]);
|
|
7554
|
+
return r.status === 0;
|
|
7555
|
+
},
|
|
7556
|
+
probeConnect: (port) => {
|
|
7557
|
+
const r = spawnSync(process.execPath, [
|
|
7558
|
+
"-e",
|
|
7559
|
+
`const s=require('net').connect(${port},'127.0.0.1');s.once('connect',()=>{s.destroy();process.exit(0)});s.once('error',()=>process.exit(1));setTimeout(()=>process.exit(1),500);`
|
|
7560
|
+
]);
|
|
7561
|
+
return r.status === 0;
|
|
7562
|
+
},
|
|
7563
|
+
// No unref: an unref'd timer would let the process exit mid-verification.
|
|
7564
|
+
sleep: (ms) => new Promise((res) => setTimeout(res, ms))
|
|
7565
|
+
};
|
|
7566
|
+
}
|
|
7567
|
+
function realGetHealth(port) {
|
|
7568
|
+
const r = spawnSync(process.execPath, [
|
|
7569
|
+
"-e",
|
|
7570
|
+
`const http=require('http');const q=http.get({host:'127.0.0.1',port:${port},path:'/healthz',timeout:800},res=>{let d='';res.on('data',c=>d+=c);res.on('end',()=>{process.stdout.write(d);process.exit(0)})});q.on('error',()=>process.exit(1));q.on('timeout',()=>process.exit(1));`
|
|
7571
|
+
], { encoding: "utf8" });
|
|
7572
|
+
if (r.status !== 0) return null;
|
|
7573
|
+
try {
|
|
7574
|
+
return JSON.parse(r.stdout);
|
|
7575
|
+
} catch {
|
|
7576
|
+
return null;
|
|
7577
|
+
}
|
|
7578
|
+
}
|
|
7579
|
+
function realReadFleet() {
|
|
7580
|
+
return readFleet({
|
|
7581
|
+
home: homedir2(),
|
|
7582
|
+
listDir: (p) => {
|
|
7583
|
+
try {
|
|
7584
|
+
return readdirSync(p);
|
|
7585
|
+
} catch {
|
|
7586
|
+
return [];
|
|
7587
|
+
}
|
|
7588
|
+
},
|
|
7589
|
+
readFile: (p) => readFileSync7(p, "utf8")
|
|
7590
|
+
});
|
|
7591
|
+
}
|
|
7592
|
+
function runStatus() {
|
|
7593
|
+
const deps = {
|
|
7594
|
+
readFleet: realReadFleet,
|
|
7595
|
+
probeConnect: (port) => {
|
|
7596
|
+
const r = spawnSync(process.execPath, [
|
|
7597
|
+
"-e",
|
|
7598
|
+
`const s=require('net').connect(${port},'127.0.0.1');s.once('connect',()=>{s.destroy();process.exit(0)});s.once('error',()=>process.exit(1));setTimeout(()=>process.exit(1),500);`
|
|
7599
|
+
]);
|
|
7600
|
+
return r.status === 0;
|
|
7601
|
+
},
|
|
7602
|
+
getHealth: realGetHealth
|
|
7603
|
+
};
|
|
7604
|
+
const rows = readFleetStatus(deps);
|
|
7605
|
+
if (process.argv.includes("--json")) process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
7606
|
+
else process.stdout.write(renderStatusTable(rows));
|
|
7607
|
+
process.exit(0);
|
|
7608
|
+
}
|
|
7609
|
+
function realCopyConnector(srcDir, dstDir) {
|
|
7610
|
+
mkdirSync2(dstDir, { recursive: true });
|
|
7611
|
+
for (const name of readdirSync(srcDir)) {
|
|
7612
|
+
const srcPath = join11(srcDir, name);
|
|
7613
|
+
if (statSync2(srcPath).isDirectory()) {
|
|
7614
|
+
realCopyConnector(srcPath, join11(dstDir, name));
|
|
7615
|
+
continue;
|
|
7616
|
+
}
|
|
7617
|
+
if (!/\.(js|json)$/.test(name)) continue;
|
|
7618
|
+
writeFileSync3(join11(dstDir, name), readFileSync7(srcPath));
|
|
7619
|
+
}
|
|
7620
|
+
}
|
|
7621
|
+
function realWriteBinding(dstDir, identityId) {
|
|
7622
|
+
mkdirSync2(dstDir, { recursive: true });
|
|
7623
|
+
writeFileSync3(join11(dstDir, "securecoms.binding.json"), JSON.stringify({ identity_id: identityId }) + "\n", { mode: 384 });
|
|
7624
|
+
}
|
|
7625
|
+
function realRemoveBinding(dstDir) {
|
|
7626
|
+
rmSync(join11(dstDir, "securecoms.binding.json"), { force: true });
|
|
7627
|
+
}
|
|
7628
|
+
function realStatDir(p) {
|
|
7629
|
+
try {
|
|
7630
|
+
return statSync2(p).isDirectory();
|
|
7631
|
+
} catch {
|
|
7632
|
+
return false;
|
|
7633
|
+
}
|
|
7634
|
+
}
|
|
7635
|
+
function realWireArgv() {
|
|
7636
|
+
if (process.argv.includes("--harness")) return process.argv;
|
|
7637
|
+
const detected = detectHarness(homedir2(), realStatDir);
|
|
7638
|
+
return detected ? [...process.argv, "--harness", detected] : process.argv;
|
|
7639
|
+
}
|
|
7640
|
+
function realWireDeps() {
|
|
7641
|
+
return {
|
|
7642
|
+
home: homedir2(),
|
|
7643
|
+
argv: realWireArgv(),
|
|
7644
|
+
readFleet: realReadFleet,
|
|
7645
|
+
copyConnector: realCopyConnector,
|
|
7646
|
+
writeBinding: realWriteBinding,
|
|
7647
|
+
removeBinding: realRemoveBinding,
|
|
7648
|
+
getHealth: realGetHealth,
|
|
7649
|
+
log: (s) => process.stdout.write(s + "\n"),
|
|
7650
|
+
// bin.ts's OWN import.meta.url — see connectorsSrcDir's doc comment in
|
|
7651
|
+
// wire.ts for why it must be the entry point's URL, not wire.ts's.
|
|
7652
|
+
connectorsSrcDir: connectorsSrcDir(import.meta.url)
|
|
7653
|
+
};
|
|
7654
|
+
}
|
|
7655
|
+
function runWireVerb(verb) {
|
|
7656
|
+
process.exit(verb === "wire" ? runWire(realWireDeps()) : runUnwire(realWireDeps()));
|
|
7657
|
+
}
|
|
7658
|
+
async function runServiceVerb(verb) {
|
|
6575
7659
|
const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
|
|
6576
7660
|
const label = argOrEnv("--label", "SECURECOMS_LABEL");
|
|
6577
|
-
if (!credentialPath && !label) {
|
|
6578
|
-
|
|
7661
|
+
if (verb === "install" ? !credentialPath : !credentialPath && !label) {
|
|
7662
|
+
const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
|
|
7663
|
+
process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|custom]${execPathUsage}
|
|
6579
7664
|
`);
|
|
6580
7665
|
process.exit(2);
|
|
6581
7666
|
}
|
|
@@ -6585,25 +7670,48 @@ function runServiceVerb(verb) {
|
|
|
6585
7670
|
const meta = loadPackageMeta(import.meta.url);
|
|
6586
7671
|
binScript = resolveStableBinScript({ binScript, packageName: meta.name }, realResolveDeps());
|
|
6587
7672
|
}
|
|
6588
|
-
const input = {
|
|
6589
|
-
credentialPath: credentialPath ?? "",
|
|
6590
|
-
storePath: argOrEnv("--store", "SECURECOMS_STORE"),
|
|
6591
|
-
port: portStr ? Number(portStr) : void 0,
|
|
6592
|
-
label,
|
|
6593
|
-
execPath: process.execPath,
|
|
6594
|
-
binScript,
|
|
6595
|
-
home: homedir2(),
|
|
6596
|
-
uid: userInfo().uid
|
|
6597
|
-
};
|
|
6598
7673
|
try {
|
|
6599
7674
|
if (verb === "install") {
|
|
6600
|
-
const
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
7675
|
+
const credAbs = isAbsolute2(credentialPath) ? credentialPath : resolve2(credentialPath);
|
|
7676
|
+
const installExecPath = chooseInstallExecPath(
|
|
7677
|
+
process.execPath,
|
|
7678
|
+
argOrEnv("--exec-path", "SECURECOMS_EXEC_PATH"),
|
|
7679
|
+
realResolveNodeDeps()
|
|
7680
|
+
);
|
|
7681
|
+
const r = await orchestrateInstall({
|
|
7682
|
+
credentialAbsPath: credAbs,
|
|
7683
|
+
label,
|
|
7684
|
+
explicitPort: portStr ? Number(portStr) : void 0,
|
|
7685
|
+
harness: argOrEnv("--harness", "SECURECOMS_HARNESS"),
|
|
7686
|
+
home: homedir2(),
|
|
7687
|
+
prepDeps: realPrepDeps(),
|
|
7688
|
+
// O-1: the real installService is 2-arg; partially apply the deps so
|
|
7689
|
+
// orchestrateInstall only ever sees the 1-arg seam.
|
|
7690
|
+
installService: (input) => installService(input, realServiceDeps()),
|
|
7691
|
+
buildServiceInput: (durableCredentialPath2, port, _slug) => ({
|
|
7692
|
+
credentialPath: durableCredentialPath2,
|
|
7693
|
+
storePath: argOrEnv("--store", "SECURECOMS_STORE"),
|
|
7694
|
+
// O-2: not dropped
|
|
7695
|
+
port,
|
|
7696
|
+
label,
|
|
7697
|
+
execPath: installExecPath,
|
|
7698
|
+
binScript,
|
|
7699
|
+
home: homedir2(),
|
|
7700
|
+
uid: userInfo().uid
|
|
7701
|
+
})
|
|
7702
|
+
});
|
|
7703
|
+
process.stdout.write(r.echo);
|
|
6606
7704
|
} else {
|
|
7705
|
+
const input = {
|
|
7706
|
+
credentialPath: credentialPath ?? "",
|
|
7707
|
+
storePath: argOrEnv("--store", "SECURECOMS_STORE"),
|
|
7708
|
+
port: portStr ? Number(portStr) : void 0,
|
|
7709
|
+
label,
|
|
7710
|
+
execPath: process.execPath,
|
|
7711
|
+
binScript,
|
|
7712
|
+
home: homedir2(),
|
|
7713
|
+
uid: userInfo().uid
|
|
7714
|
+
};
|
|
6607
7715
|
const r = uninstallService(input, realServiceDeps());
|
|
6608
7716
|
process.stdout.write(r.removed ? `Removed service (${r.filePath})
|
|
6609
7717
|
` : `No service installed for this agent
|
|
@@ -6624,8 +7732,16 @@ async function main() {
|
|
|
6624
7732
|
process.exit(1);
|
|
6625
7733
|
}
|
|
6626
7734
|
const verb = parseVerb(process.argv);
|
|
7735
|
+
if (verb === "status") {
|
|
7736
|
+
runStatus();
|
|
7737
|
+
return;
|
|
7738
|
+
}
|
|
7739
|
+
if (verb === "wire" || verb === "unwire") {
|
|
7740
|
+
runWireVerb(verb);
|
|
7741
|
+
return;
|
|
7742
|
+
}
|
|
6627
7743
|
if (verb) {
|
|
6628
|
-
runServiceVerb(verb);
|
|
7744
|
+
await runServiceVerb(verb);
|
|
6629
7745
|
return;
|
|
6630
7746
|
}
|
|
6631
7747
|
const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
|
|
@@ -6644,11 +7760,11 @@ async function main() {
|
|
|
6644
7760
|
`);
|
|
6645
7761
|
process.exit(1);
|
|
6646
7762
|
}
|
|
6647
|
-
const tokenFile = daemon.tokenFile ??
|
|
7763
|
+
const tokenFile = daemon.tokenFile ?? join11(dirname7(credentialPath), "bridge-token");
|
|
6648
7764
|
const token = resolveToken({ tokenFile, token: daemon.token });
|
|
6649
7765
|
const agent = new BridgeAgent(cfg);
|
|
6650
7766
|
await agent.start();
|
|
6651
|
-
const tls = daemon.tls ? { cert:
|
|
7767
|
+
const tls = daemon.tls ? { cert: readFileSync7(daemon.tlsCert, "utf8"), key: readFileSync7(daemon.tlsKey, "utf8") } : void 0;
|
|
6652
7768
|
const { server, closeAllSseStreams } = createHttpServer(agent, { token, allowOrigins: daemon.allowOrigins, tls, websocket: true });
|
|
6653
7769
|
const agentWs = attachAgentWebSocket(server, agent, { token, allowOrigins: daemon.allowOrigins });
|
|
6654
7770
|
let down = false;
|
|
@@ -6667,6 +7783,10 @@ async function main() {
|
|
|
6667
7783
|
};
|
|
6668
7784
|
process.on("SIGINT", shutdown);
|
|
6669
7785
|
process.on("SIGTERM", shutdown);
|
|
7786
|
+
const instanceId = newInstanceId();
|
|
7787
|
+
const identityId = agent.health().identity_id;
|
|
7788
|
+
const slug = slugFromCredentialPath(credentialPath);
|
|
7789
|
+
process.once("exit", () => removeConnectionFileIfOwned(homedir2(), identityId, instanceId));
|
|
6670
7790
|
server.listen(daemon.port, daemon.bind, () => {
|
|
6671
7791
|
const scheme = tls ? "https" : "http";
|
|
6672
7792
|
const wsScheme = tls ? "wss" : "ws";
|
|
@@ -6674,9 +7794,27 @@ async function main() {
|
|
|
6674
7794
|
`);
|
|
6675
7795
|
process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
|
|
6676
7796
|
`);
|
|
7797
|
+
const addr = server.address();
|
|
7798
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
|
|
7799
|
+
try {
|
|
7800
|
+
writeConnectionFile(homedir2(), buildConnectionFile({
|
|
7801
|
+
identityId,
|
|
7802
|
+
port: boundPort,
|
|
7803
|
+
tokenFile,
|
|
7804
|
+
slug,
|
|
7805
|
+
label: slug,
|
|
7806
|
+
instanceId,
|
|
7807
|
+
now: (/* @__PURE__ */ new Date()).toISOString()
|
|
7808
|
+
}));
|
|
7809
|
+
process.stderr.write(`[bridge] connection file: ~/.securecoms/connections/${identityId}.json
|
|
7810
|
+
`);
|
|
7811
|
+
} catch (e) {
|
|
7812
|
+
process.stderr.write(`[bridge] WARN: could not write connection file: ${e?.message ?? e}
|
|
7813
|
+
`);
|
|
7814
|
+
}
|
|
6677
7815
|
});
|
|
6678
7816
|
}
|
|
6679
|
-
var here =
|
|
7817
|
+
var here = fileURLToPath2(import.meta.url);
|
|
6680
7818
|
var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync);
|
|
6681
7819
|
if (isMain) {
|
|
6682
7820
|
main().catch((e) => {
|
|
@@ -6686,7 +7824,9 @@ if (isMain) {
|
|
|
6686
7824
|
});
|
|
6687
7825
|
}
|
|
6688
7826
|
export {
|
|
7827
|
+
chooseInstallExecPath,
|
|
6689
7828
|
nodeMajorOk,
|
|
6690
7829
|
parseVerb,
|
|
7830
|
+
slugFromCredentialPath,
|
|
6691
7831
|
tokenFingerprint
|
|
6692
7832
|
};
|