@securecoms/agent-bridge 0.1.0 → 0.3.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 +19 -0
- package/dist/bin.d.ts +10 -0
- package/dist/bin.js +2032 -77
- package/dist/bin.js.pre-c1-f803007 +5795 -0
- package/dist/bin.js.pre-c3-096a9a3 +5832 -0
- package/dist/bin.js.pre-ffrebuild-b162d4a +5639 -0
- package/dist/bin.js.pre-kpheal-20260708-150924 +5531 -0
- package/dist/bin.js.pre-kppersist-20260708-152341 +5543 -0
- package/dist/bin.js.pre-p0-20260708-102415 +5196 -0
- package/dist/bin.js.pre-p1b-02f47e1 +5639 -0
- package/dist/bin.js.pre-warmfix-20260708-153705 +5613 -0
- package/dist/bin.js.pretrace-113205 +5395 -0
- package/dist/bridge-agent.d.ts +393 -0
- package/dist/decrypt-error-log-limiter.d.ts +52 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +2009 -64
- package/dist/index.js.pre-p0-20260708-102415 +4879 -0
- package/dist/ws-server.d.ts +5 -0
- package/package.json +2 -1
package/dist/bin.js
CHANGED
|
@@ -502,6 +502,144 @@ var payloadsSchema = {
|
|
|
502
502
|
"type": "object"
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
|
+
},
|
|
506
|
+
"agent_action": {
|
|
507
|
+
"description": 'The agent_action E2E message body (agent action tree). NOT a message_type \u2014 it rides envelope message_type "system_event"; this "type":"agent_action" field is a body-level discriminator. Two phases: partial (throttled, incremental upsert-by-id while an agent works) and settled (one authoritative full-tree frame, terminal for its runId). Note: this def is compiled standalone (see validate.ts validateAgentActionPayload), so its node sub-schema is nested under a LOCAL $defs (resolved relative to this def, not the sibling top-level $defs) rather than cross-referenced.',
|
|
508
|
+
"$defs": {
|
|
509
|
+
"node": {
|
|
510
|
+
"type": "object",
|
|
511
|
+
"required": [
|
|
512
|
+
"id",
|
|
513
|
+
"type",
|
|
514
|
+
"title",
|
|
515
|
+
"status",
|
|
516
|
+
"ts"
|
|
517
|
+
],
|
|
518
|
+
"properties": {
|
|
519
|
+
"id": {
|
|
520
|
+
"type": "string"
|
|
521
|
+
},
|
|
522
|
+
"parentId": {
|
|
523
|
+
"type": "string"
|
|
524
|
+
},
|
|
525
|
+
"type": {
|
|
526
|
+
"type": "string",
|
|
527
|
+
"enum": [
|
|
528
|
+
"step",
|
|
529
|
+
"tool_call",
|
|
530
|
+
"sub_task",
|
|
531
|
+
"result"
|
|
532
|
+
]
|
|
533
|
+
},
|
|
534
|
+
"title": {
|
|
535
|
+
"type": "string"
|
|
536
|
+
},
|
|
537
|
+
"status": {
|
|
538
|
+
"type": "string",
|
|
539
|
+
"enum": [
|
|
540
|
+
"pending",
|
|
541
|
+
"running",
|
|
542
|
+
"done",
|
|
543
|
+
"error"
|
|
544
|
+
]
|
|
545
|
+
},
|
|
546
|
+
"detail": {
|
|
547
|
+
"type": "object",
|
|
548
|
+
"properties": {
|
|
549
|
+
"args": {},
|
|
550
|
+
"output": {},
|
|
551
|
+
"error": {
|
|
552
|
+
"type": "string"
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
"ts": {
|
|
557
|
+
"type": "number"
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
"oneOf": [
|
|
563
|
+
{
|
|
564
|
+
"type": "object",
|
|
565
|
+
"required": [
|
|
566
|
+
"v",
|
|
567
|
+
"type",
|
|
568
|
+
"phase",
|
|
569
|
+
"agentActionVersion",
|
|
570
|
+
"runId",
|
|
571
|
+
"nodes"
|
|
572
|
+
],
|
|
573
|
+
"properties": {
|
|
574
|
+
"v": {
|
|
575
|
+
"const": 1
|
|
576
|
+
},
|
|
577
|
+
"type": {
|
|
578
|
+
"const": "agent_action"
|
|
579
|
+
},
|
|
580
|
+
"phase": {
|
|
581
|
+
"const": "partial"
|
|
582
|
+
},
|
|
583
|
+
"agentActionVersion": {
|
|
584
|
+
"const": "1"
|
|
585
|
+
},
|
|
586
|
+
"runId": {
|
|
587
|
+
"type": "string"
|
|
588
|
+
},
|
|
589
|
+
"nodes": {
|
|
590
|
+
"type": "array",
|
|
591
|
+
"items": {
|
|
592
|
+
"$ref": "#/$defs/node"
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
"type": "object",
|
|
599
|
+
"required": [
|
|
600
|
+
"v",
|
|
601
|
+
"type",
|
|
602
|
+
"phase",
|
|
603
|
+
"agentActionVersion",
|
|
604
|
+
"runId",
|
|
605
|
+
"startedAt",
|
|
606
|
+
"endedAt",
|
|
607
|
+
"nodes"
|
|
608
|
+
],
|
|
609
|
+
"properties": {
|
|
610
|
+
"v": {
|
|
611
|
+
"const": 1
|
|
612
|
+
},
|
|
613
|
+
"type": {
|
|
614
|
+
"const": "agent_action"
|
|
615
|
+
},
|
|
616
|
+
"phase": {
|
|
617
|
+
"const": "settled"
|
|
618
|
+
},
|
|
619
|
+
"agentActionVersion": {
|
|
620
|
+
"const": "1"
|
|
621
|
+
},
|
|
622
|
+
"runId": {
|
|
623
|
+
"type": "string"
|
|
624
|
+
},
|
|
625
|
+
"startedAt": {
|
|
626
|
+
"type": "number"
|
|
627
|
+
},
|
|
628
|
+
"endedAt": {
|
|
629
|
+
"type": "number"
|
|
630
|
+
},
|
|
631
|
+
"resultMessageId": {
|
|
632
|
+
"type": "string"
|
|
633
|
+
},
|
|
634
|
+
"nodes": {
|
|
635
|
+
"type": "array",
|
|
636
|
+
"items": {
|
|
637
|
+
"$ref": "#/$defs/node"
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
]
|
|
505
643
|
}
|
|
506
644
|
}
|
|
507
645
|
};
|
|
@@ -521,6 +659,11 @@ for (const t of MESSAGE_TYPES) {
|
|
|
521
659
|
}
|
|
522
660
|
payloadValidators.set(t, ajv.compile(def));
|
|
523
661
|
}
|
|
662
|
+
var agentActionDef = payloadDefs["agent_action"];
|
|
663
|
+
if (!agentActionDef) {
|
|
664
|
+
throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
|
|
665
|
+
}
|
|
666
|
+
var agentActionValidator = ajv.compile(agentActionDef);
|
|
524
667
|
|
|
525
668
|
// ../connect-core/dist/envelope/sign.js
|
|
526
669
|
import { canonicalize } from "@agentvault/crypto/canonical";
|
|
@@ -853,6 +996,24 @@ async function staleEpochErrorFromResponse(res) {
|
|
|
853
996
|
return null;
|
|
854
997
|
return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
|
|
855
998
|
}
|
|
999
|
+
var AgentPausedError = class extends ConnectSdkError {
|
|
1000
|
+
constructor() {
|
|
1001
|
+
super("agent_paused: sending is paused for this agent in this room");
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
async function agentPausedErrorFromResponse(res) {
|
|
1005
|
+
if (res.status !== 403)
|
|
1006
|
+
return null;
|
|
1007
|
+
let body;
|
|
1008
|
+
try {
|
|
1009
|
+
body = await res.json();
|
|
1010
|
+
} catch {
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
const detail = body?.detail;
|
|
1014
|
+
const err = detail?.error;
|
|
1015
|
+
return err === "agent_paused" ? new AgentPausedError() : null;
|
|
1016
|
+
}
|
|
856
1017
|
|
|
857
1018
|
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
858
1019
|
async function buildOutboundEnvelope(p) {
|
|
@@ -957,7 +1118,7 @@ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
|
|
|
957
1118
|
var KNOWN = new Set(Object.values(SPAN));
|
|
958
1119
|
|
|
959
1120
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
960
|
-
import { MLSGroupManager, bytesToHex, hexToBytes } from "@agentvault/crypto";
|
|
1121
|
+
import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
961
1122
|
|
|
962
1123
|
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
963
1124
|
var MlsOperationQueue = class {
|
|
@@ -986,11 +1147,27 @@ var MlsOperationQueue = class {
|
|
|
986
1147
|
chainSize(channelId) {
|
|
987
1148
|
return this.inFlight.get(channelId) ?? 0;
|
|
988
1149
|
}
|
|
1150
|
+
/** Number of channels with an in-flight or queued op. 0 == fully idle. */
|
|
1151
|
+
get activeChannels() {
|
|
1152
|
+
return this.chains.size;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* H1 graceful-drain barrier: resolve once every op currently chained (in-flight
|
|
1156
|
+
* + already queued) has SETTLED. Ops enqueued AFTER this call are not awaited —
|
|
1157
|
+
* the caller must stop accepting new work first (e.g. close the ping stream).
|
|
1158
|
+
* Uses allSettled so a rejected op can't make the drain reject. Lets a graceful
|
|
1159
|
+
* shutdown wait for an in-flight commit/join/ack to finish before the DB handle
|
|
1160
|
+
* is released, so a SIGTERM/`kickstart -k` can't tear MLS state mid-operation.
|
|
1161
|
+
*/
|
|
1162
|
+
async idle() {
|
|
1163
|
+
await Promise.allSettled([...this.chains.values()]);
|
|
1164
|
+
}
|
|
989
1165
|
};
|
|
990
1166
|
|
|
991
1167
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
992
1168
|
var LABEL = "agentvault-connect-v1";
|
|
993
1169
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
1170
|
+
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
994
1171
|
var BootstrapRaceLostError = class extends Error {
|
|
995
1172
|
constructor() {
|
|
996
1173
|
super("bootstrap race lost: another device created the group; join instead");
|
|
@@ -1010,10 +1187,24 @@ var HealAddAfterRemoveError = class extends Error {
|
|
|
1010
1187
|
this.cause = cause;
|
|
1011
1188
|
}
|
|
1012
1189
|
};
|
|
1190
|
+
var GuestHealStaleKpError = class extends Error {
|
|
1191
|
+
constructor(kpId) {
|
|
1192
|
+
super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
|
|
1193
|
+
this.name = "GuestHealStaleKpError";
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
var ForgedCredentialError = class extends Error {
|
|
1197
|
+
constructor(expected, actual) {
|
|
1198
|
+
super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
|
|
1199
|
+
this.name = "ForgedCredentialError";
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1013
1202
|
var MlsGroupKeyProvider = class {
|
|
1014
1203
|
o;
|
|
1015
1204
|
suite;
|
|
1016
1205
|
managers = /* @__PURE__ */ new Map();
|
|
1206
|
+
/** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
|
|
1207
|
+
cryptoWarmed = false;
|
|
1017
1208
|
/**
|
|
1018
1209
|
* Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
|
|
1019
1210
|
* manager-mutating operation on a channel takes this lock keyed by channelId,
|
|
@@ -1034,6 +1225,12 @@ var MlsGroupKeyProvider = class {
|
|
|
1034
1225
|
* holds that channel's lock).
|
|
1035
1226
|
*/
|
|
1036
1227
|
locks = new MlsOperationQueue();
|
|
1228
|
+
/** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
|
|
1229
|
+
* guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
|
|
1230
|
+
* both pass "no pending bundle", both POST, and orphan the first bundle when the
|
|
1231
|
+
* server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
|
|
1232
|
+
* from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
|
|
1233
|
+
publishLock = new MlsOperationQueue();
|
|
1037
1234
|
/**
|
|
1038
1235
|
* Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
|
|
1039
1236
|
* of channels this device touches (channels are bounded; an entry is created
|
|
@@ -1043,14 +1240,22 @@ var MlsGroupKeyProvider = class {
|
|
|
1043
1240
|
*/
|
|
1044
1241
|
errors = /* @__PURE__ */ new Map();
|
|
1045
1242
|
stuckThreshold;
|
|
1243
|
+
/** Consecutive-error threshold at which a wedged channel's row is quarantined
|
|
1244
|
+
* (ack-drained). See {@link MlsGroupKeyProviderOpts.quarantineThreshold}. */
|
|
1245
|
+
quarantineThreshold;
|
|
1046
1246
|
/** Cumulative count of past-epoch application rows permanently skipped (acked
|
|
1047
1247
|
* and drained) because forward-secrecy has deleted their epoch's secrets. Read
|
|
1048
1248
|
* via {@link permanentlySkippedCount}. */
|
|
1049
1249
|
permanentlySkipped = 0;
|
|
1250
|
+
/** Cumulative count of rows ack-drained via QUARANTINE (P0a), as distinct from
|
|
1251
|
+
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1252
|
+
* {@link quarantineDrainedCount}. */
|
|
1253
|
+
quarantineDrained = 0;
|
|
1050
1254
|
constructor(o) {
|
|
1051
1255
|
this.o = o;
|
|
1052
1256
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
1053
1257
|
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
1258
|
+
this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
|
|
1054
1259
|
}
|
|
1055
1260
|
get deviceId() {
|
|
1056
1261
|
return this.o.deviceId;
|
|
@@ -1068,7 +1273,14 @@ var MlsGroupKeyProvider = class {
|
|
|
1068
1273
|
const cid = row.connect_channel_id;
|
|
1069
1274
|
if (!cid)
|
|
1070
1275
|
return;
|
|
1071
|
-
const e = this.errors.get(cid) ?? {
|
|
1276
|
+
const e = this.errors.get(cid) ?? {
|
|
1277
|
+
decryptErrors: 0,
|
|
1278
|
+
consecutiveErrors: 0,
|
|
1279
|
+
lastErrorAt: null,
|
|
1280
|
+
quarantined: false,
|
|
1281
|
+
wedgedRun: 0,
|
|
1282
|
+
wedgedSignalled: false
|
|
1283
|
+
};
|
|
1072
1284
|
e.decryptErrors += 1;
|
|
1073
1285
|
e.consecutiveErrors += 1;
|
|
1074
1286
|
e.lastErrorAt = Date.now();
|
|
@@ -1078,12 +1290,42 @@ var MlsGroupKeyProvider = class {
|
|
|
1078
1290
|
* A commit/application row for the channel decrypted cleanly: the channel is
|
|
1079
1291
|
* advancing, so clear the consecutive-error run (un-sticks it). Leaves the
|
|
1080
1292
|
* cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
|
|
1081
|
-
* that never errored.
|
|
1293
|
+
* that never errored. Also clears QUARANTINE state (P0a): a healed/rejoined
|
|
1294
|
+
* channel resumes normal processing and can quarantine again later if it
|
|
1295
|
+
* re-wedges (the onWedged guard is reset so the hook can fire again).
|
|
1082
1296
|
*/
|
|
1083
1297
|
recordDecryptSuccess(channelId) {
|
|
1084
1298
|
const e = this.errors.get(channelId);
|
|
1085
|
-
if (e
|
|
1299
|
+
if (!e)
|
|
1300
|
+
return;
|
|
1301
|
+
if (e.consecutiveErrors !== 0)
|
|
1086
1302
|
e.consecutiveErrors = 0;
|
|
1303
|
+
e.wedgedRun = 0;
|
|
1304
|
+
e.quarantined = false;
|
|
1305
|
+
e.wedgedSignalled = false;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Called from the decrypt-failure catch in {@link receiveOnce} when the
|
|
1309
|
+
* channel's consecutive-error run has just reached {@link quarantineThreshold}
|
|
1310
|
+
* and the row was NOT already drained via the past-epoch `permanentlyDead`
|
|
1311
|
+
* check (P0a, daemon-stability). Marks the channel quarantined and fires
|
|
1312
|
+
* {@link MlsGroupKeyProviderOpts.onWedged} exactly once per episode. Does NOT
|
|
1313
|
+
* itself ack the row — the caller pushes `row.queue_id` onto the same acked
|
|
1314
|
+
* list the `permanentlyDead` drain uses, flushed via `ackDelivery` at the end
|
|
1315
|
+
* of `receiveOnce`. Deliberately independent of `liveEpoch`: it must work even
|
|
1316
|
+
* when there is no local state for the channel at all (the no-local-state /
|
|
1317
|
+
* Hermes storm case), because quarantine is a pure consecutive-failure-count
|
|
1318
|
+
* decision, not an epoch comparison.
|
|
1319
|
+
*/
|
|
1320
|
+
quarantineChannel(channelId) {
|
|
1321
|
+
const e = this.errors.get(channelId);
|
|
1322
|
+
if (!e)
|
|
1323
|
+
return;
|
|
1324
|
+
e.quarantined = true;
|
|
1325
|
+
if (!e.wedgedSignalled) {
|
|
1326
|
+
e.wedgedSignalled = true;
|
|
1327
|
+
this.o.onWedged?.(channelId);
|
|
1328
|
+
}
|
|
1087
1329
|
}
|
|
1088
1330
|
/**
|
|
1089
1331
|
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
@@ -1100,11 +1342,42 @@ var MlsGroupKeyProvider = class {
|
|
|
1100
1342
|
decryptErrors: e.decryptErrors,
|
|
1101
1343
|
consecutiveErrors: e.consecutiveErrors,
|
|
1102
1344
|
lastErrorAt: e.lastErrorAt,
|
|
1103
|
-
stuck: e.consecutiveErrors >= this.stuckThreshold
|
|
1345
|
+
stuck: e.consecutiveErrors >= this.stuckThreshold,
|
|
1346
|
+
quarantined: e.quarantined
|
|
1104
1347
|
};
|
|
1105
1348
|
}
|
|
1106
1349
|
return out;
|
|
1107
1350
|
}
|
|
1351
|
+
/**
|
|
1352
|
+
* H1 graceful shutdown: resolve once every in-flight per-channel op AND the
|
|
1353
|
+
* publish lock have settled, or `deadlineMs` elapses (bounded so a hung network
|
|
1354
|
+
* op can't block process exit). The caller MUST have already stopped feeding new
|
|
1355
|
+
* work (closed the ping stream) so the queues can actually drain. Loops because a
|
|
1356
|
+
* settling op can chain a follow-on (e.g. a commit that then drains a pending
|
|
1357
|
+
* commit). Lets close() wait for an in-flight commit/join/ack to finish before
|
|
1358
|
+
* the DB handle is released — a SIGTERM / `kickstart -k` can no longer tear MLS
|
|
1359
|
+
* state mid-operation (a half-applied commit, or a join whose Welcome was acked
|
|
1360
|
+
* but whose state was not yet saved).
|
|
1361
|
+
*/
|
|
1362
|
+
async drainInFlight(deadlineMs = 5e3) {
|
|
1363
|
+
let timer;
|
|
1364
|
+
const deadline = new Promise((res) => {
|
|
1365
|
+
timer = setTimeout(res, deadlineMs);
|
|
1366
|
+
timer.unref?.();
|
|
1367
|
+
});
|
|
1368
|
+
const drain = (async () => {
|
|
1369
|
+
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
|
|
1370
|
+
await this.locks.idle();
|
|
1371
|
+
await this.publishLock.idle();
|
|
1372
|
+
}
|
|
1373
|
+
})();
|
|
1374
|
+
try {
|
|
1375
|
+
await Promise.race([drain, deadline]);
|
|
1376
|
+
} finally {
|
|
1377
|
+
if (timer)
|
|
1378
|
+
clearTimeout(timer);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1108
1381
|
/** Total past-epoch rows permanently skipped (acked without decrypting) since
|
|
1109
1382
|
* this provider was instantiated. A permanently-undecryptable row is one whose
|
|
1110
1383
|
* `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
|
|
@@ -1112,6 +1385,12 @@ var MlsGroupKeyProvider = class {
|
|
|
1112
1385
|
permanentlySkippedCount() {
|
|
1113
1386
|
return this.permanentlySkipped;
|
|
1114
1387
|
}
|
|
1388
|
+
/** Total rows ack-drained via QUARANTINE (P0a) since this provider was
|
|
1389
|
+
* instantiated — a wedged channel's undecryptable backlog, distinct from the
|
|
1390
|
+
* past-epoch {@link permanentlySkippedCount} drain. */
|
|
1391
|
+
quarantineDrainedCount() {
|
|
1392
|
+
return this.quarantineDrained;
|
|
1393
|
+
}
|
|
1115
1394
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1116
1395
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1117
1396
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1280,16 +1559,99 @@ var MlsGroupKeyProvider = class {
|
|
|
1280
1559
|
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
|
|
1281
1560
|
return w?.connect_channel_id ?? null;
|
|
1282
1561
|
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Initialize the ts-mls libsodium (WASM) backend ONCE before any store
|
|
1564
|
+
* operation that deserializes a persisted KeyPackage. generateKeyPackage
|
|
1565
|
+
* awaits sodium.ready and is pure in-memory (it neither publishes to the
|
|
1566
|
+
* backend nor writes the store), so the throwaway bundle is a side-effect-free
|
|
1567
|
+
* warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
|
|
1568
|
+
* guard, and the join path) runs while WASM is still cold, throws deep in the
|
|
1569
|
+
* crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
|
|
1570
|
+
* and misread as "no held bundle", and the daemon mints + republishes a FRESH
|
|
1571
|
+
* KeyPackage on every restart — orphaning every Welcome sealed to the prior
|
|
1572
|
+
* published KP (the root cause of "fresh DM never opens" across restarts).
|
|
1573
|
+
* Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
|
|
1574
|
+
*/
|
|
1575
|
+
async ensureCryptoReady() {
|
|
1576
|
+
if (this.cryptoWarmed)
|
|
1577
|
+
return;
|
|
1578
|
+
await new MLSGroupManager().generateKeyPackage(this.o.identity);
|
|
1579
|
+
this.cryptoWarmed = true;
|
|
1580
|
+
}
|
|
1283
1581
|
async publishKeyPackage() {
|
|
1582
|
+
await this.ensureCryptoReady();
|
|
1583
|
+
return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Force a FRESH published KeyPackage even when a pending bundle is already held.
|
|
1587
|
+
*
|
|
1588
|
+
* `publishKeyPackage`'s republish guard deliberately no-ops while a bundle is
|
|
1589
|
+
* held (so a normal re-publish can't orphan a leaf a queued Welcome needs). But
|
|
1590
|
+
* the reset self-heal reaches here only AFTER a fully-verified supersede Welcome
|
|
1591
|
+
* FAILED to open ("No matching secret found") — proving the currently-published
|
|
1592
|
+
* KP has diverged from our held bundle, so that bundle is already useless for
|
|
1593
|
+
* opening any Welcome sealed to the published KP. Delete it (dropping the guard)
|
|
1594
|
+
* and publish anew under the same device publish-lock, so the server's published
|
|
1595
|
+
* KP now matches a bundle we hold and the owner's re-Welcome is openable. Returns
|
|
1596
|
+
* the new KP id. Only the reset-unopenable self-heal calls this.
|
|
1597
|
+
*/
|
|
1598
|
+
async forceRepublishKeyPackage() {
|
|
1599
|
+
return this.publishLock.run(this.o.deviceId, async () => {
|
|
1600
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1601
|
+
return this.publishKeyPackageUnlocked();
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* True iff we hold a pending bundle AND the server's currently-advertised
|
|
1606
|
+
* KeyPackage for this device is that SAME bundle's public KeyPackage.
|
|
1607
|
+
*
|
|
1608
|
+
* The reset self-heal uses this to avoid a needless — and harmful —
|
|
1609
|
+
* force-republish. A failed `joinFromWelcome` on a supersede proves only that
|
|
1610
|
+
* THIS Welcome's sealed KP != our held bundle; it does NOT prove the CURRENTLY
|
|
1611
|
+
* published KP diverged. In the common case the reset committer simply sealed to
|
|
1612
|
+
* an OLDER KP snapshot while the current published KP still equals our held
|
|
1613
|
+
* bundle — a plain re-Welcome (re-sealed to the current published KP) then opens,
|
|
1614
|
+
* and deleting the shared per-device bundle would ORPHAN other channels' openable
|
|
1615
|
+
* Welcomes sealed to it (the multichannel-join case). Only when this returns
|
|
1616
|
+
* false (published KP genuinely diverged from held) is a force-republish warranted:
|
|
1617
|
+
* in the normal single-daemon case every currently-sealed Welcome uses the published
|
|
1618
|
+
* KP, so deleting the held bundle orphans nothing; a Welcome sealed to the held
|
|
1619
|
+
* bundle can only exist in a rare multi-instance / out-of-band-republish window, and
|
|
1620
|
+
* even then a supersede Welcome self-heals next cycle (held == published again).
|
|
1621
|
+
*
|
|
1622
|
+
* Best-effort: any error resolving either side returns false, so the caller
|
|
1623
|
+
* force-republishes (the conservative recovery). */
|
|
1624
|
+
async heldBundleMatchesPublishedKp() {
|
|
1625
|
+
try {
|
|
1626
|
+
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1627
|
+
if (!bundle)
|
|
1628
|
+
return false;
|
|
1629
|
+
const heldHex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1630
|
+
const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
|
|
1631
|
+
const pubHex = published[this.o.deviceId];
|
|
1632
|
+
return !!pubHex && pubHex === heldHex;
|
|
1633
|
+
} catch {
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
async publishKeyPackageUnlocked() {
|
|
1284
1638
|
if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
|
|
1285
1639
|
console.warn("publishKeyPackage: pending bundle exists; skipping republish");
|
|
1286
|
-
return;
|
|
1640
|
+
return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
|
|
1287
1641
|
}
|
|
1642
|
+
console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
|
|
1288
1643
|
const mgr = new MLSGroupManager();
|
|
1289
1644
|
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
1290
1645
|
const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1291
|
-
await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1292
|
-
await this.o.store.
|
|
1646
|
+
const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1647
|
+
await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
|
|
1648
|
+
return id;
|
|
1649
|
+
}
|
|
1650
|
+
/** The server-assigned id of the last KeyPackage this device published,
|
|
1651
|
+
* or null if none has been published yet. Lets a diverged guest's self-heal
|
|
1652
|
+
* signal name its outstanding KP (P2-A). */
|
|
1653
|
+
async getLastPublishedKpId() {
|
|
1654
|
+
return this.o.store.loadLastKpId(this.o.deviceId);
|
|
1293
1655
|
}
|
|
1294
1656
|
async ensureSession(channelId, opts) {
|
|
1295
1657
|
return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
|
|
@@ -1297,6 +1659,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1297
1659
|
async ensureSessionUnlocked(channelId, opts) {
|
|
1298
1660
|
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
1299
1661
|
return;
|
|
1662
|
+
await this.ensureCryptoReady();
|
|
1300
1663
|
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1301
1664
|
if (!bundle && (opts.bootstrap || opts.forceReinit)) {
|
|
1302
1665
|
await this.publishKeyPackage();
|
|
@@ -1327,7 +1690,6 @@ var MlsGroupKeyProvider = class {
|
|
|
1327
1690
|
});
|
|
1328
1691
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1329
1692
|
this.managers.set(channelId, mgr);
|
|
1330
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1331
1693
|
return;
|
|
1332
1694
|
}
|
|
1333
1695
|
if (opts.bootstrap) {
|
|
@@ -1355,30 +1717,23 @@ var MlsGroupKeyProvider = class {
|
|
|
1355
1717
|
});
|
|
1356
1718
|
this.managers.set(channelId, mgr);
|
|
1357
1719
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1358
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1359
1720
|
} else {
|
|
1360
1721
|
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1361
1722
|
const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
|
|
1362
1723
|
if (!w)
|
|
1363
1724
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
1364
1725
|
const mgr = new MLSGroupManager();
|
|
1365
|
-
|
|
1726
|
+
try {
|
|
1727
|
+
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
this.recordDecryptError(w, err);
|
|
1730
|
+
console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
|
|
1731
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1366
1734
|
this.managers.set(channelId, mgr);
|
|
1367
1735
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1368
1736
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1369
|
-
const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1370
|
-
let burstPending = false;
|
|
1371
|
-
for (const r of remaining) {
|
|
1372
|
-
if (r.message_type !== "welcome")
|
|
1373
|
-
continue;
|
|
1374
|
-
const cid = r.connect_channel_id;
|
|
1375
|
-
if (!cid || !await this.o.store.load(cid)) {
|
|
1376
|
-
burstPending = true;
|
|
1377
|
-
break;
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
if (!burstPending)
|
|
1381
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1382
1737
|
}
|
|
1383
1738
|
}
|
|
1384
1739
|
/**
|
|
@@ -1397,7 +1752,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1397
1752
|
async addMemberToLiveGroup(channelId, newDeviceId) {
|
|
1398
1753
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
|
|
1399
1754
|
}
|
|
1400
|
-
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt) {
|
|
1755
|
+
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
1401
1756
|
const mgr = await this.manager(channelId);
|
|
1402
1757
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1403
1758
|
const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
|
|
@@ -1405,6 +1760,13 @@ var MlsGroupKeyProvider = class {
|
|
|
1405
1760
|
if (!hex)
|
|
1406
1761
|
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
1407
1762
|
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1763
|
+
if (opts?.expectedIdentityId) {
|
|
1764
|
+
const cred = pkg.leafNode.credential;
|
|
1765
|
+
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
1766
|
+
if (claimed !== opts.expectedIdentityId) {
|
|
1767
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1408
1770
|
const priorEpoch = Number(mgr.epoch);
|
|
1409
1771
|
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
1410
1772
|
const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
@@ -1426,7 +1788,13 @@ var MlsGroupKeyProvider = class {
|
|
|
1426
1788
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1427
1789
|
} catch {
|
|
1428
1790
|
}
|
|
1429
|
-
|
|
1791
|
+
try {
|
|
1792
|
+
const work = await this.o.backend.getCommitterWork(channelId);
|
|
1793
|
+
if (!work.adds.includes(newDeviceId))
|
|
1794
|
+
return;
|
|
1795
|
+
} catch {
|
|
1796
|
+
}
|
|
1797
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
1430
1798
|
}
|
|
1431
1799
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1432
1800
|
}
|
|
@@ -1618,7 +1986,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1618
1986
|
* Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
|
|
1619
1987
|
* addMemberToLiveGroupUnlocked) — no weaker path is introduced.
|
|
1620
1988
|
*/
|
|
1621
|
-
async healDivergedMember(channelId, memberDeviceId) {
|
|
1989
|
+
async healDivergedMember(channelId, memberDeviceId, opts) {
|
|
1622
1990
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
1623
1991
|
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
1624
1992
|
const hex = kps[memberDeviceId];
|
|
@@ -1629,6 +1997,9 @@ var MlsGroupKeyProvider = class {
|
|
|
1629
1997
|
throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
|
|
1630
1998
|
}
|
|
1631
1999
|
const identity = new TextDecoder().decode(cred.identity);
|
|
2000
|
+
if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
|
|
2001
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, identity);
|
|
2002
|
+
}
|
|
1632
2003
|
const mgr = await this.manager(channelId);
|
|
1633
2004
|
const leaves = mgr.leafIndicesForIdentity(identity);
|
|
1634
2005
|
if (leaves.length > 1) {
|
|
@@ -1645,7 +2016,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1645
2016
|
}
|
|
1646
2017
|
}
|
|
1647
2018
|
try {
|
|
1648
|
-
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0);
|
|
2019
|
+
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
|
|
1649
2020
|
} catch (e) {
|
|
1650
2021
|
if (removeLanded)
|
|
1651
2022
|
throw new HealAddAfterRemoveError(e);
|
|
@@ -1653,6 +2024,48 @@ var MlsGroupKeyProvider = class {
|
|
|
1653
2024
|
}
|
|
1654
2025
|
}));
|
|
1655
2026
|
}
|
|
2027
|
+
/** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
|
|
2028
|
+
* epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
|
|
2029
|
+
* re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
|
|
2030
|
+
* sweep call sites and for the cross-tenant authz that differs only server-side.
|
|
2031
|
+
* Caller contract (like the member path): the guest republishes a fresh
|
|
2032
|
+
* KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
|
|
2033
|
+
*
|
|
2034
|
+
* Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
|
|
2035
|
+
* retained KeyPackage (the one the caller confirmed the guest republished).
|
|
2036
|
+
* Before doing anything else we fetch it BY ID and refuse
|
|
2037
|
+
* ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
|
|
2038
|
+
* strength of "some KeyPackage is published for this device", because that
|
|
2039
|
+
* could be stale, consumed, or absent and would seal an un-openable Welcome,
|
|
2040
|
+
* bricking the guest instead of healing it. Once the gate passes we delegate to
|
|
2041
|
+
* the proven {@link healDivergedMember}: given `publish_key_package`'s
|
|
2042
|
+
* delete-and-replace semantics and the guest's pending-bundle republish guard,
|
|
2043
|
+
* the by-device fetch inside healDivergedMember resolves to exactly this same
|
|
2044
|
+
* KeyPackage — the id-fetch here is the fail-closed contract, the member heal
|
|
2045
|
+
* is the mechanism. */
|
|
2046
|
+
async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
|
|
2047
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
2048
|
+
if (!hex)
|
|
2049
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
2050
|
+
return this.healDivergedMember(channelId, guestDeviceId);
|
|
2051
|
+
}
|
|
2052
|
+
/** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
|
|
2053
|
+
* AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
|
|
2054
|
+
* id-by-value gate (refuse if `expectedKpId` is gone/consumed →
|
|
2055
|
+
* {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
|
|
2056
|
+
* delegates to {@link healDivergedMember} with `expectedIdentityId` so the
|
|
2057
|
+
* committed-bytes assertion binds the re-Added leaf to the intended identity
|
|
2058
|
+
* (throws {@link ForgedCredentialError} on a credential-identity mismatch,
|
|
2059
|
+
* including on the 409-retry path). This is the entry point the owner's
|
|
2060
|
+
* agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
|
|
2061
|
+
* the welcome-request row the agent published, so a compromised agent cannot
|
|
2062
|
+
* substitute a KeyPackage that forges a different identity. */
|
|
2063
|
+
async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
|
|
2064
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
2065
|
+
if (!hex)
|
|
2066
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
2067
|
+
return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
|
|
2068
|
+
}
|
|
1656
2069
|
/**
|
|
1657
2070
|
* Member-side: drop the diverged local state for a channel so a subsequent
|
|
1658
2071
|
* {@link joinAndReconcile} consumes the committer's fresh Welcome (from
|
|
@@ -1687,6 +2100,56 @@ var MlsGroupKeyProvider = class {
|
|
|
1687
2100
|
this.errors.delete(channelId);
|
|
1688
2101
|
});
|
|
1689
2102
|
}
|
|
2103
|
+
/** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
|
|
2104
|
+
* ensureSession forceReinit branch but posts a client-chosen new_group_id +
|
|
2105
|
+
* signed attestation to the reset-mode endpoint, then builds + distributes
|
|
2106
|
+
* the new group. Runs under the channel lock (serialized against
|
|
2107
|
+
* receiveOnce / committer ops).
|
|
2108
|
+
*
|
|
2109
|
+
* `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
|
|
2110
|
+
* member device holding an unconsumed KeyPackage (H2) -- the caller (the web
|
|
2111
|
+
* client's members-endpoint enumeration) is responsible for that union;
|
|
2112
|
+
* this method only handles the mechanical THIS-device exclusion: this
|
|
2113
|
+
* device is the committer that creates the group, so it must not be in the
|
|
2114
|
+
* MLS addMembers set (adding your own new leaf via addMembers is
|
|
2115
|
+
* nonsensical / unsupported). The wire member_device_ids below re-includes
|
|
2116
|
+
* it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
|
|
2117
|
+
* (required subset of supplied) is still satisfied even though it never
|
|
2118
|
+
* goes through addMembers. */
|
|
2119
|
+
async resetSessionSupersede(channelId, opts) {
|
|
2120
|
+
return this.locks.run(channelId, async () => {
|
|
2121
|
+
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2122
|
+
if (!bundle) {
|
|
2123
|
+
await this.publishKeyPackage();
|
|
2124
|
+
bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2125
|
+
}
|
|
2126
|
+
if (!bundle)
|
|
2127
|
+
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
2128
|
+
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
2129
|
+
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
2130
|
+
const mgr = new MLSGroupManager();
|
|
2131
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
2132
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
2133
|
+
const pkgs = others.map((d) => {
|
|
2134
|
+
const hex = kps[d];
|
|
2135
|
+
if (!hex)
|
|
2136
|
+
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
2137
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
2138
|
+
});
|
|
2139
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
2140
|
+
await this.o.backend.distribute(reinit.group_id, {
|
|
2141
|
+
device_id: this.o.deviceId,
|
|
2142
|
+
commit: bytesToHex(commit),
|
|
2143
|
+
commit_epoch: Number(mgr.epoch),
|
|
2144
|
+
expected_epoch: reinit.current_epoch,
|
|
2145
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
2146
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
2147
|
+
});
|
|
2148
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2149
|
+
this.managers.set(channelId, mgr);
|
|
2150
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
1690
2153
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
1691
2154
|
* Cheap synchronous check; does NOT consult the store. */
|
|
1692
2155
|
hasChannelState(channelId) {
|
|
@@ -1700,6 +2163,19 @@ var MlsGroupKeyProvider = class {
|
|
|
1700
2163
|
return true;
|
|
1701
2164
|
return await this.o.store.load(channelId) !== null;
|
|
1702
2165
|
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Return the number of leaves the given identity holds in the live tree for
|
|
2168
|
+
* `channelId`. Loads the manager from the store if not already cached.
|
|
2169
|
+
*
|
|
2170
|
+
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2171
|
+
* does NOT create a duplicate leaf for an already-added device. A return value
|
|
2172
|
+
* of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
|
|
2173
|
+
* duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
|
|
2174
|
+
*/
|
|
2175
|
+
async leafCountForIdentity(channelId, identityId) {
|
|
2176
|
+
const mgr = await this.manager(channelId);
|
|
2177
|
+
return mgr.leafIndicesForIdentity(identityId).length;
|
|
2178
|
+
}
|
|
1703
2179
|
/** Returns true when the live in-memory MLS tree for `channelId` contains at
|
|
1704
2180
|
* least one leaf bearing `identityId` as its credential. Used by the agent
|
|
1705
2181
|
* admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
|
|
@@ -1719,18 +2195,130 @@ var MlsGroupKeyProvider = class {
|
|
|
1719
2195
|
const r = await this.o.backend.getConnectChannelGroup(channelId);
|
|
1720
2196
|
return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
|
|
1721
2197
|
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Verified group supersede for the agent's receive pump (owner-attested reset).
|
|
2200
|
+
* Returns true when the channel's state was swapped to the new group and the
|
|
2201
|
+
* Welcome row acked; false when the supersede was REFUSED (row left un-acked).
|
|
2202
|
+
*
|
|
2203
|
+
* FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
|
|
2204
|
+
* ⇒ refuse, never call verify), then structural signer check, then attestation
|
|
2205
|
+
* verify against the NON-EMPTY pinned key, then post-join group-id assertion.
|
|
2206
|
+
*/
|
|
2207
|
+
async trySupersede(channelId, row, heldGroupId) {
|
|
2208
|
+
const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
|
|
2209
|
+
if (!owner || !owner.publicKeyHex) {
|
|
2210
|
+
this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
const retired = await this.o.store.loadRetiredGroupIds(channelId);
|
|
2214
|
+
if (retired.includes(row.group_id)) {
|
|
2215
|
+
this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
const att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
2219
|
+
if (!att) {
|
|
2220
|
+
this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
|
|
2221
|
+
return false;
|
|
2222
|
+
}
|
|
2223
|
+
if (att.signerIdentityId !== owner.identityId) {
|
|
2224
|
+
this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
let content;
|
|
2228
|
+
try {
|
|
2229
|
+
content = JSON.parse(att.signedContent);
|
|
2230
|
+
} catch {
|
|
2231
|
+
this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
|
|
2232
|
+
return false;
|
|
2233
|
+
}
|
|
2234
|
+
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
2235
|
+
this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
|
|
2236
|
+
return false;
|
|
2237
|
+
}
|
|
2238
|
+
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
2239
|
+
if (!ok) {
|
|
2240
|
+
this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
|
|
2241
|
+
return false;
|
|
2242
|
+
}
|
|
2243
|
+
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2244
|
+
if (!bundle) {
|
|
2245
|
+
this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
|
|
2246
|
+
return false;
|
|
2247
|
+
}
|
|
2248
|
+
return await this.locks.run(channelId, async () => {
|
|
2249
|
+
const mgr = new MLSGroupManager();
|
|
2250
|
+
try {
|
|
2251
|
+
await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
this.recordDecryptError(row, err);
|
|
2254
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2255
|
+
console.warn(`[trace] supersede joinFromWelcome FAILED ch=${channelId.slice(0, 8)} g=${row.group_id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)} -> self-heal`);
|
|
2256
|
+
}
|
|
2257
|
+
let requested = false;
|
|
2258
|
+
try {
|
|
2259
|
+
requested = await this.o.onSupersedeUnopenable?.(channelId, row.group_id) ?? false;
|
|
2260
|
+
} catch {
|
|
2261
|
+
}
|
|
2262
|
+
if (requested) {
|
|
2263
|
+
try {
|
|
2264
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
2265
|
+
} catch {
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return false;
|
|
2269
|
+
}
|
|
2270
|
+
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
2271
|
+
this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
|
|
2272
|
+
return false;
|
|
2273
|
+
}
|
|
2274
|
+
this.evictChannelState(channelId);
|
|
2275
|
+
await this.o.store.delete(channelId);
|
|
2276
|
+
await this.o.store.discardStaging(channelId);
|
|
2277
|
+
await this.o.store.addRetiredGroupId(channelId, heldGroupId);
|
|
2278
|
+
this.errors.delete(channelId);
|
|
2279
|
+
this.managers.set(channelId, mgr);
|
|
2280
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2281
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
2282
|
+
return true;
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
1722
2285
|
async receiveOnce(deviceId) {
|
|
1723
2286
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2287
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2288
|
+
console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
|
|
2289
|
+
}
|
|
1724
2290
|
const appEntries = [];
|
|
1725
2291
|
const handshakeAcked = [];
|
|
2292
|
+
const supersededChannelIds = [];
|
|
1726
2293
|
const order = { welcome: 0, commit: 1, application: 2 };
|
|
1727
2294
|
rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
|
|
1728
2295
|
for (const row of rows) {
|
|
1729
2296
|
try {
|
|
1730
2297
|
if (row.message_type === "welcome") {
|
|
1731
2298
|
const wcid = row.connect_channel_id;
|
|
1732
|
-
if (wcid
|
|
1733
|
-
|
|
2299
|
+
if (wcid) {
|
|
2300
|
+
const existing = await this.o.store.load(wcid);
|
|
2301
|
+
if (existing) {
|
|
2302
|
+
let heldGroupId = null;
|
|
2303
|
+
try {
|
|
2304
|
+
const mgr = await this.manager(wcid);
|
|
2305
|
+
heldGroupId = new TextDecoder().decode(mgr.groupId);
|
|
2306
|
+
} catch {
|
|
2307
|
+
heldGroupId = null;
|
|
2308
|
+
}
|
|
2309
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2310
|
+
console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} heldGroup=${heldGroupId ? heldGroupId.slice(0, 8) : "NULL"} -> ${heldGroupId === null ? "SKIP(manager-unloadable)" : heldGroupId === row.group_id ? "stray-ack" : "trySupersede"}`);
|
|
2311
|
+
}
|
|
2312
|
+
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
2313
|
+
handshakeAcked.push(row.queue_id);
|
|
2314
|
+
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
2315
|
+
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
2316
|
+
if (ok)
|
|
2317
|
+
supersededChannelIds.push(wcid);
|
|
2318
|
+
}
|
|
2319
|
+
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2320
|
+
console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
|
|
2321
|
+
}
|
|
1734
2322
|
}
|
|
1735
2323
|
} else if (row.message_type === "commit") {
|
|
1736
2324
|
const cid = row.connect_channel_id;
|
|
@@ -1764,12 +2352,14 @@ var MlsGroupKeyProvider = class {
|
|
|
1764
2352
|
plaintext,
|
|
1765
2353
|
envelope,
|
|
1766
2354
|
receivedAt: Date.now(),
|
|
1767
|
-
retryCount: 0
|
|
2355
|
+
retryCount: 0,
|
|
2356
|
+
expires_at: row.expires_at
|
|
1768
2357
|
});
|
|
1769
2358
|
}
|
|
1770
2359
|
} catch (err) {
|
|
1771
2360
|
this.recordDecryptError(row, err);
|
|
1772
2361
|
const cid = row.connect_channel_id;
|
|
2362
|
+
let drained = false;
|
|
1773
2363
|
if (cid && typeof row.epoch === "number") {
|
|
1774
2364
|
let liveEpoch;
|
|
1775
2365
|
try {
|
|
@@ -1781,12 +2371,27 @@ var MlsGroupKeyProvider = class {
|
|
|
1781
2371
|
if (permanentlyDead) {
|
|
1782
2372
|
handshakeAcked.push(row.queue_id);
|
|
1783
2373
|
this.permanentlySkipped++;
|
|
2374
|
+
drained = true;
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
if (!drained && cid) {
|
|
2378
|
+
const e = this.errors.get(cid);
|
|
2379
|
+
if (e) {
|
|
2380
|
+
e.wedgedRun += 1;
|
|
2381
|
+
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2382
|
+
handshakeAcked.push(row.queue_id);
|
|
2383
|
+
this.quarantineDrained++;
|
|
2384
|
+
this.quarantineChannel(cid);
|
|
2385
|
+
}
|
|
1784
2386
|
}
|
|
1785
2387
|
}
|
|
1786
2388
|
}
|
|
1787
2389
|
}
|
|
1788
2390
|
if (handshakeAcked.length)
|
|
1789
2391
|
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
2392
|
+
if (supersededChannelIds.length) {
|
|
2393
|
+
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
2394
|
+
}
|
|
1790
2395
|
return appEntries;
|
|
1791
2396
|
}
|
|
1792
2397
|
/**
|
|
@@ -1867,11 +2472,38 @@ var FRAME_PARSERS = {
|
|
|
1867
2472
|
roomId: d?.room_id ? str(d.room_id) : void 0,
|
|
1868
2473
|
requestingDeviceId: str(d?.requesting_device_id),
|
|
1869
2474
|
requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
|
|
2475
|
+
freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
|
|
1870
2476
|
requestId: d?.request_id ? str(d.request_id) : void 0
|
|
1871
2477
|
}
|
|
2478
|
+
}),
|
|
2479
|
+
// Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
|
|
2480
|
+
// heal a guest whose ratchet tree diverged. Durable state is the
|
|
2481
|
+
// mls_guest_heal_request row; this frame is just the wakeup hint.
|
|
2482
|
+
mls_guest_heal_requested: (d) => ({
|
|
2483
|
+
guestHealRequest: {
|
|
2484
|
+
groupId: str(d?.group_id),
|
|
2485
|
+
channelId: str(d?.channel_id),
|
|
2486
|
+
enclaveId: str(d?.enclave_id),
|
|
2487
|
+
guestIdentityId: str(d?.guest_identity_id),
|
|
2488
|
+
guestDeviceId: str(d?.guest_device_id),
|
|
2489
|
+
freshKpId: str(d?.fresh_kp_id),
|
|
2490
|
+
requestId: str(d?.request_id)
|
|
2491
|
+
}
|
|
1872
2492
|
})
|
|
1873
2493
|
};
|
|
1874
2494
|
var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
|
|
2495
|
+
var TERMINAL_FRAME_EVENTS = /* @__PURE__ */ new Set([
|
|
2496
|
+
"device_revoked",
|
|
2497
|
+
"identity.revoked"
|
|
2498
|
+
]);
|
|
2499
|
+
function terminalFrameReason(frame) {
|
|
2500
|
+
if (!frame || typeof frame !== "object")
|
|
2501
|
+
return void 0;
|
|
2502
|
+
const f = frame;
|
|
2503
|
+
if (typeof f.event !== "string" || !TERMINAL_FRAME_EVENTS.has(f.event))
|
|
2504
|
+
return void 0;
|
|
2505
|
+
return typeof f.data?.reason === "string" && f.data.reason ? f.data.reason : f.event;
|
|
2506
|
+
}
|
|
1875
2507
|
function parseServerFrame(frame) {
|
|
1876
2508
|
if (!frame || typeof frame !== "object")
|
|
1877
2509
|
return void 0;
|
|
@@ -1887,8 +2519,22 @@ function openDeliveryPings(o) {
|
|
|
1887
2519
|
const rng = o.rng ?? Math.random;
|
|
1888
2520
|
const maxQueue = o.maxQueue ?? 8;
|
|
1889
2521
|
const stableResetMs = o.stableResetMs ?? 1e4;
|
|
2522
|
+
const hardened = o.hardened ?? false;
|
|
2523
|
+
const now = o.now ?? (() => Date.now());
|
|
2524
|
+
const silenceMs = o.silenceMs ?? 9e4;
|
|
2525
|
+
const watchdogMs = o.watchdogMs ?? 3e4;
|
|
2526
|
+
const wakeGapMs = o.wakeGapMs ?? 12e4;
|
|
2527
|
+
const authWarCap = o.authWarCap ?? 3;
|
|
2528
|
+
const authWarWindowMs = o.authWarWindowMs ?? 12e4;
|
|
2529
|
+
const lifecycle = (event, detail) => {
|
|
2530
|
+
try {
|
|
2531
|
+
o.onLifecycle?.(event, detail);
|
|
2532
|
+
} catch {
|
|
2533
|
+
}
|
|
2534
|
+
};
|
|
1890
2535
|
let closed = false;
|
|
1891
2536
|
let attempt = 0;
|
|
2537
|
+
let connectGen = 0;
|
|
1892
2538
|
let sock;
|
|
1893
2539
|
let backoffTimer;
|
|
1894
2540
|
let stableTimer;
|
|
@@ -1900,6 +2546,17 @@ function openDeliveryPings(o) {
|
|
|
1900
2546
|
pongTimer = void 0;
|
|
1901
2547
|
}
|
|
1902
2548
|
};
|
|
2549
|
+
let terminalReason;
|
|
2550
|
+
let lastInboundAt = 0;
|
|
2551
|
+
let lastTickAt;
|
|
2552
|
+
let watchdogTimer;
|
|
2553
|
+
const authCloses = [];
|
|
2554
|
+
const stopWatchdog = () => {
|
|
2555
|
+
if (watchdogTimer) {
|
|
2556
|
+
clearInterval(watchdogTimer);
|
|
2557
|
+
watchdogTimer = void 0;
|
|
2558
|
+
}
|
|
2559
|
+
};
|
|
1903
2560
|
const queue = [];
|
|
1904
2561
|
let waiter;
|
|
1905
2562
|
let iterStarted = false;
|
|
@@ -1919,9 +2576,18 @@ function openDeliveryPings(o) {
|
|
|
1919
2576
|
if (stableTimer)
|
|
1920
2577
|
clearTimeout(stableTimer);
|
|
1921
2578
|
stopPong();
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2579
|
+
stopWatchdog();
|
|
2580
|
+
const ws = sock;
|
|
2581
|
+
sock = void 0;
|
|
2582
|
+
if (ws) {
|
|
2583
|
+
ws.onopen = null;
|
|
2584
|
+
ws.onmessage = null;
|
|
2585
|
+
ws.onclose = null;
|
|
2586
|
+
ws.onerror = null;
|
|
2587
|
+
try {
|
|
2588
|
+
ws.close();
|
|
2589
|
+
} catch {
|
|
2590
|
+
}
|
|
1925
2591
|
}
|
|
1926
2592
|
if (waiter) {
|
|
1927
2593
|
const w = waiter;
|
|
@@ -1932,13 +2598,60 @@ function openDeliveryPings(o) {
|
|
|
1932
2598
|
const scheduleReconnect = () => {
|
|
1933
2599
|
if (closed)
|
|
1934
2600
|
return;
|
|
2601
|
+
if (backoffTimer)
|
|
2602
|
+
clearTimeout(backoffTimer);
|
|
2603
|
+
connectGen += 1;
|
|
1935
2604
|
attempt += 1;
|
|
1936
2605
|
const ceil = Math.min(cap, base * 2 ** (attempt - 1));
|
|
1937
2606
|
backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
|
|
1938
2607
|
};
|
|
2608
|
+
const forceCycle = (event, detail) => {
|
|
2609
|
+
if (closed)
|
|
2610
|
+
return;
|
|
2611
|
+
lifecycle(event, detail);
|
|
2612
|
+
const ws = sock;
|
|
2613
|
+
sock = void 0;
|
|
2614
|
+
if (stableTimer)
|
|
2615
|
+
clearTimeout(stableTimer);
|
|
2616
|
+
stopPong();
|
|
2617
|
+
if (ws) {
|
|
2618
|
+
ws.onopen = null;
|
|
2619
|
+
ws.onmessage = null;
|
|
2620
|
+
ws.onclose = null;
|
|
2621
|
+
ws.onerror = null;
|
|
2622
|
+
try {
|
|
2623
|
+
ws.close();
|
|
2624
|
+
} catch {
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
scheduleReconnect();
|
|
2628
|
+
};
|
|
2629
|
+
const startWatchdog = () => {
|
|
2630
|
+
if (!hardened || watchdogTimer)
|
|
2631
|
+
return;
|
|
2632
|
+
lastTickAt = now();
|
|
2633
|
+
watchdogTimer = setInterval(() => {
|
|
2634
|
+
if (closed)
|
|
2635
|
+
return;
|
|
2636
|
+
const t = now();
|
|
2637
|
+
const gap = lastTickAt !== void 0 ? t - lastTickAt : 0;
|
|
2638
|
+
if (lastTickAt !== void 0 && gap > wakeGapMs) {
|
|
2639
|
+
lastTickAt = t;
|
|
2640
|
+
attempt = 0;
|
|
2641
|
+
forceCycle("wake", `${gap}ms gap`);
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
lastTickAt = t;
|
|
2645
|
+
if (sock && sock.readyState === 1 && t - lastInboundAt > silenceMs) {
|
|
2646
|
+
forceCycle("halfopen", `${t - lastInboundAt}ms silent`);
|
|
2647
|
+
}
|
|
2648
|
+
}, watchdogMs);
|
|
2649
|
+
};
|
|
1939
2650
|
async function connect() {
|
|
1940
2651
|
if (closed)
|
|
1941
2652
|
return;
|
|
2653
|
+
const gen = connectGen;
|
|
2654
|
+
terminalReason = void 0;
|
|
1942
2655
|
let token;
|
|
1943
2656
|
try {
|
|
1944
2657
|
token = await o.tokenProvider();
|
|
@@ -1946,15 +2659,17 @@ function openDeliveryPings(o) {
|
|
|
1946
2659
|
scheduleReconnect();
|
|
1947
2660
|
return;
|
|
1948
2661
|
}
|
|
1949
|
-
if (closed)
|
|
2662
|
+
if (closed || gen !== connectGen)
|
|
1950
2663
|
return;
|
|
1951
2664
|
const sep = o.url.includes("?") ? "&" : "?";
|
|
1952
2665
|
const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
|
|
1953
2666
|
sock = ws;
|
|
1954
2667
|
ws.onopen = () => {
|
|
1955
2668
|
push({ reconnected: true });
|
|
2669
|
+
lastInboundAt = now();
|
|
1956
2670
|
stableTimer = setTimeout(() => {
|
|
1957
2671
|
attempt = 0;
|
|
2672
|
+
authCloses.length = 0;
|
|
1958
2673
|
}, stableResetMs);
|
|
1959
2674
|
stopPong();
|
|
1960
2675
|
pongTimer = setInterval(() => {
|
|
@@ -1966,8 +2681,26 @@ function openDeliveryPings(o) {
|
|
|
1966
2681
|
}, PRESENCE_PONG_MS);
|
|
1967
2682
|
};
|
|
1968
2683
|
ws.onmessage = (e) => {
|
|
2684
|
+
lastInboundAt = now();
|
|
2685
|
+
let f;
|
|
2686
|
+
try {
|
|
2687
|
+
f = JSON.parse(String(e.data));
|
|
2688
|
+
} catch {
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
if (hardened) {
|
|
2692
|
+
const tr = terminalFrameReason(f);
|
|
2693
|
+
if (tr) {
|
|
2694
|
+
terminalReason = tr;
|
|
2695
|
+
} else if (f?.event === "ping") {
|
|
2696
|
+
try {
|
|
2697
|
+
if (ws.readyState === 1)
|
|
2698
|
+
ws.send(JSON.stringify({ event: "pong" }));
|
|
2699
|
+
} catch {
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
1969
2703
|
try {
|
|
1970
|
-
const f = JSON.parse(String(e.data));
|
|
1971
2704
|
const ping = parseServerFrame(f);
|
|
1972
2705
|
if (ping)
|
|
1973
2706
|
push(ping);
|
|
@@ -1983,14 +2716,41 @@ function openDeliveryPings(o) {
|
|
|
1983
2716
|
stopPong();
|
|
1984
2717
|
if (closed)
|
|
1985
2718
|
return;
|
|
1986
|
-
|
|
2719
|
+
const code = e.code;
|
|
2720
|
+
if (!hardened) {
|
|
2721
|
+
if (code === 4001) {
|
|
2722
|
+
end();
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
scheduleReconnect();
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
const tr = terminalReason;
|
|
2729
|
+
terminalReason = void 0;
|
|
2730
|
+
if (code === 4006 || tr && code === 4001) {
|
|
2731
|
+
lifecycle("terminal", tr ?? `code ${code}`);
|
|
1987
2732
|
end();
|
|
1988
2733
|
return;
|
|
1989
2734
|
}
|
|
2735
|
+
if (code === 4001) {
|
|
2736
|
+
const t = now();
|
|
2737
|
+
authCloses.push(t);
|
|
2738
|
+
while (authCloses.length && t - authCloses[0] > authWarWindowMs)
|
|
2739
|
+
authCloses.shift();
|
|
2740
|
+
if (authCloses.length >= authWarCap) {
|
|
2741
|
+
lifecycle("terminal", `auth war-cap ${authCloses.length}/${authWarWindowMs}ms`);
|
|
2742
|
+
end();
|
|
2743
|
+
return;
|
|
2744
|
+
}
|
|
2745
|
+
lifecycle("reconnect", "auth-4001");
|
|
2746
|
+
scheduleReconnect();
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
1990
2749
|
scheduleReconnect();
|
|
1991
2750
|
};
|
|
1992
2751
|
}
|
|
1993
2752
|
void connect();
|
|
2753
|
+
startWatchdog();
|
|
1994
2754
|
const pings = {
|
|
1995
2755
|
[Symbol.asyncIterator]() {
|
|
1996
2756
|
if (iterStarted)
|
|
@@ -2105,6 +2865,11 @@ var RestTransport = class {
|
|
|
2105
2865
|
if (stale)
|
|
2106
2866
|
throw stale;
|
|
2107
2867
|
}
|
|
2868
|
+
if (res.status === 403) {
|
|
2869
|
+
const paused = await agentPausedErrorFromResponse(res.clone ? res.clone() : res);
|
|
2870
|
+
if (paused)
|
|
2871
|
+
throw paused;
|
|
2872
|
+
}
|
|
2108
2873
|
if (!res.ok)
|
|
2109
2874
|
throw new Error(`publish failed: ${res.status}`);
|
|
2110
2875
|
const j = await res.json();
|
|
@@ -2120,7 +2885,13 @@ var RestTransport = class {
|
|
|
2120
2885
|
if (this.opts.connectIdentityId) {
|
|
2121
2886
|
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
2122
2887
|
}
|
|
2123
|
-
const handle = openDeliveryPings({
|
|
2888
|
+
const handle = openDeliveryPings({
|
|
2889
|
+
url: wsUrl,
|
|
2890
|
+
tokenProvider: this.tokenProvider,
|
|
2891
|
+
wsImpl: this.ws,
|
|
2892
|
+
hardened: this.opts.hardenedReconnect ?? false,
|
|
2893
|
+
onLifecycle: this.opts.onWsLifecycle
|
|
2894
|
+
});
|
|
2124
2895
|
this._controlSend = handle.send;
|
|
2125
2896
|
const close = () => {
|
|
2126
2897
|
if (this._controlSend === handle.send)
|
|
@@ -2195,10 +2966,11 @@ var RestMlsBackend = class {
|
|
|
2195
2966
|
return { device_id: String(j.device_id) };
|
|
2196
2967
|
}
|
|
2197
2968
|
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
2198
|
-
await this.post("/api/v1/mls/key-packages", {
|
|
2969
|
+
const j = await this.post("/api/v1/mls/key-packages", {
|
|
2199
2970
|
key_package: keyPackageHex,
|
|
2200
2971
|
device_id: deviceId
|
|
2201
2972
|
});
|
|
2973
|
+
return String(j.id);
|
|
2202
2974
|
}
|
|
2203
2975
|
async getConnectChannelGroup(channelId) {
|
|
2204
2976
|
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
|
|
@@ -2235,10 +3007,37 @@ var RestMlsBackend = class {
|
|
|
2235
3007
|
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2236
3008
|
};
|
|
2237
3009
|
}
|
|
2238
|
-
async
|
|
2239
|
-
const
|
|
3010
|
+
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
3011
|
+
const committer = memberDeviceIds[0];
|
|
3012
|
+
if (!committer)
|
|
3013
|
+
throw new Error("resetReinitConnectChannelGroup: empty member set");
|
|
3014
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
3015
|
+
member_device_ids: memberDeviceIds,
|
|
3016
|
+
committer_device_id: committer,
|
|
3017
|
+
mode: "reset",
|
|
3018
|
+
new_group_id: att.newGroupId,
|
|
3019
|
+
reset_attestation_signature: att.signatureHex,
|
|
3020
|
+
reset_attestation_nonce: att.nonceHex,
|
|
3021
|
+
reset_attestation_content: att.signedContentHex
|
|
3022
|
+
});
|
|
3023
|
+
return {
|
|
3024
|
+
group_id: String(j.group_id),
|
|
3025
|
+
current_epoch: Number(j.current_epoch),
|
|
3026
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
3027
|
+
};
|
|
3028
|
+
}
|
|
3029
|
+
async fetchKeyPackages(deviceIds) {
|
|
3030
|
+
const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
|
|
2240
3031
|
return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
|
|
2241
3032
|
}
|
|
3033
|
+
async fetchKeyPackageById(kpId) {
|
|
3034
|
+
try {
|
|
3035
|
+
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
|
|
3036
|
+
return String(j.key_package);
|
|
3037
|
+
} catch {
|
|
3038
|
+
return null;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
2242
3041
|
async distribute(groupId, req) {
|
|
2243
3042
|
await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
|
|
2244
3043
|
...req,
|
|
@@ -2255,13 +3054,48 @@ var RestMlsBackend = class {
|
|
|
2255
3054
|
queue_ids: queueIds
|
|
2256
3055
|
});
|
|
2257
3056
|
}
|
|
2258
|
-
async requestWelcome(groupId, deviceId) {
|
|
2259
|
-
|
|
3057
|
+
async requestWelcome(groupId, deviceId, freshKpId) {
|
|
3058
|
+
const body = { device_id: deviceId };
|
|
3059
|
+
if (freshKpId)
|
|
3060
|
+
body.fresh_kp_id = freshKpId;
|
|
3061
|
+
await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
|
|
3062
|
+
}
|
|
3063
|
+
async getCommitterWork(channelId) {
|
|
3064
|
+
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
3065
|
+
return {
|
|
3066
|
+
adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
|
|
3067
|
+
removes: Array.isArray(j.removes) ? j.removes.map(String) : []
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
async acquireCommitLease(channelId) {
|
|
3071
|
+
const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
|
|
3072
|
+
if (res.status === 409 || res.status === 503)
|
|
3073
|
+
return null;
|
|
3074
|
+
if (!res.ok)
|
|
3075
|
+
throw new Error(`commit-lease ${res.status}`);
|
|
3076
|
+
const j = await res.json();
|
|
3077
|
+
return String(j.token);
|
|
3078
|
+
}
|
|
3079
|
+
async releaseCommitLease(channelId, token) {
|
|
3080
|
+
await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
async fetchResetAttestation(groupId) {
|
|
3084
|
+
try {
|
|
3085
|
+
const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
|
|
3086
|
+
return {
|
|
3087
|
+
signedContent: String(j.signed_content),
|
|
3088
|
+
signatureHex: String(j.signature_hex),
|
|
3089
|
+
signerIdentityId: String(j.signer_identity_id)
|
|
3090
|
+
};
|
|
3091
|
+
} catch {
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
2260
3094
|
}
|
|
2261
3095
|
};
|
|
2262
3096
|
|
|
2263
3097
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
2264
|
-
import { bytesToHex as bytesToHex2 } from "@agentvault/crypto";
|
|
3098
|
+
import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
2265
3099
|
|
|
2266
3100
|
// ../connect-sdk-ts/dist/receive/inbox-cipher.js
|
|
2267
3101
|
import { hkdf } from "@noble/hashes/hkdf";
|
|
@@ -2311,9 +3145,10 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
|
|
|
2311
3145
|
|
|
2312
3146
|
// ../connect-mls-store/dist/sqlite-store.js
|
|
2313
3147
|
import { DatabaseSync } from "node:sqlite";
|
|
3148
|
+
import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
|
|
2314
3149
|
|
|
2315
3150
|
// ../connect-mls-store/dist/schema.js
|
|
2316
|
-
var SCHEMA_VERSION =
|
|
3151
|
+
var SCHEMA_VERSION = 4;
|
|
2317
3152
|
function openSchema(db) {
|
|
2318
3153
|
db.exec("PRAGMA journal_mode=WAL");
|
|
2319
3154
|
const row = db.prepare("PRAGMA user_version").get();
|
|
@@ -2328,8 +3163,39 @@ function openSchema(db) {
|
|
|
2328
3163
|
PRIMARY KEY (identity_id, channel_id)
|
|
2329
3164
|
);
|
|
2330
3165
|
`);
|
|
2331
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2332
3166
|
}
|
|
3167
|
+
if (row.user_version < 2) {
|
|
3168
|
+
db.exec(`
|
|
3169
|
+
CREATE TABLE IF NOT EXISTS mls_identity_pin (
|
|
3170
|
+
owner_identity_id TEXT PRIMARY KEY,
|
|
3171
|
+
public_key_hex TEXT NOT NULL,
|
|
3172
|
+
key_algo TEXT NOT NULL,
|
|
3173
|
+
pinned_at INTEGER NOT NULL
|
|
3174
|
+
);
|
|
3175
|
+
`);
|
|
3176
|
+
}
|
|
3177
|
+
if (row.user_version < 3) {
|
|
3178
|
+
db.exec(`
|
|
3179
|
+
CREATE TABLE IF NOT EXISTS mls_retired_group (
|
|
3180
|
+
channel_id TEXT NOT NULL,
|
|
3181
|
+
group_id TEXT NOT NULL,
|
|
3182
|
+
PRIMARY KEY (channel_id, group_id)
|
|
3183
|
+
);
|
|
3184
|
+
`);
|
|
3185
|
+
}
|
|
3186
|
+
if (row.user_version < 4) {
|
|
3187
|
+
db.exec(`
|
|
3188
|
+
CREATE TABLE IF NOT EXISTS mls_pending_bundle (
|
|
3189
|
+
identity_id TEXT NOT NULL,
|
|
3190
|
+
ref TEXT NOT NULL,
|
|
3191
|
+
kp_id TEXT,
|
|
3192
|
+
sealed_bundle BLOB,
|
|
3193
|
+
updated_at INTEGER NOT NULL,
|
|
3194
|
+
PRIMARY KEY (identity_id, ref)
|
|
3195
|
+
);
|
|
3196
|
+
`);
|
|
3197
|
+
}
|
|
3198
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2333
3199
|
}
|
|
2334
3200
|
|
|
2335
3201
|
// ../connect-mls-store/dist/errors.js
|
|
@@ -2347,13 +3213,39 @@ var MlsStoreSealError = class extends Error {
|
|
|
2347
3213
|
};
|
|
2348
3214
|
|
|
2349
3215
|
// ../connect-mls-store/dist/sqlite-store.js
|
|
3216
|
+
function encodeBundle(bundle) {
|
|
3217
|
+
const pp = bundle.privatePackage;
|
|
3218
|
+
const obj = {
|
|
3219
|
+
pub: bytesToHex3(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
|
|
3220
|
+
initPriv: bytesToHex3(pp.initPrivateKey),
|
|
3221
|
+
hpkePriv: bytesToHex3(pp.hpkePrivateKey),
|
|
3222
|
+
sigPriv: bytesToHex3(pp.signaturePrivateKey)
|
|
3223
|
+
};
|
|
3224
|
+
return new TextEncoder().encode(JSON.stringify(obj));
|
|
3225
|
+
}
|
|
3226
|
+
function decodeBundle(bytes) {
|
|
3227
|
+
const obj = JSON.parse(new TextDecoder().decode(bytes));
|
|
3228
|
+
return {
|
|
3229
|
+
publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
|
|
3230
|
+
privatePackage: {
|
|
3231
|
+
initPrivateKey: hexToBytes3(obj.initPriv),
|
|
3232
|
+
hpkePrivateKey: hexToBytes3(obj.hpkePriv),
|
|
3233
|
+
signaturePrivateKey: hexToBytes3(obj.sigPriv)
|
|
3234
|
+
}
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
2350
3237
|
var SqliteMlsStateStore = class {
|
|
2351
3238
|
db;
|
|
2352
3239
|
id;
|
|
2353
3240
|
cipher;
|
|
2354
|
-
//
|
|
2355
|
-
//
|
|
3241
|
+
// In-memory write-through cache over the durable mls_pending_bundle table.
|
|
3242
|
+
// Persisting the bundle (schema v4) is the root-cause fix for restart-induced
|
|
3243
|
+
// KeyPackage rotation: with the bundle in-memory ONLY, every restart lost it,
|
|
3244
|
+
// re-minted+re-published a fresh KP, and orphaned every Welcome sealed to the
|
|
3245
|
+
// prior published KP. The cache avoids re-unsealing on every access; sqlite is
|
|
3246
|
+
// the source of truth across process restarts.
|
|
2356
3247
|
bundles = /* @__PURE__ */ new Map();
|
|
3248
|
+
lastKpIds = /* @__PURE__ */ new Map();
|
|
2357
3249
|
constructor(opts) {
|
|
2358
3250
|
this.id = opts.identityId;
|
|
2359
3251
|
this.cipher = opts.cipherFor(opts.identityId);
|
|
@@ -2422,12 +3314,74 @@ var SqliteMlsStateStore = class {
|
|
|
2422
3314
|
}
|
|
2423
3315
|
async savePendingBundle(ref, bundle) {
|
|
2424
3316
|
this.bundles.set(ref, bundle);
|
|
3317
|
+
const sealed = await this.cipher.seal(encodeBundle(bundle));
|
|
3318
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, sealed_bundle, updated_at)
|
|
3319
|
+
VALUES (?,?,?,?)
|
|
3320
|
+
ON CONFLICT(identity_id, ref) DO UPDATE SET sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, ref, sealed, Date.now());
|
|
2425
3321
|
}
|
|
2426
3322
|
async loadPendingBundle(ref) {
|
|
2427
|
-
|
|
3323
|
+
const cached = this.bundles.get(ref);
|
|
3324
|
+
if (cached)
|
|
3325
|
+
return cached;
|
|
3326
|
+
const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
|
|
3327
|
+
if (!row?.sealed_bundle)
|
|
3328
|
+
return null;
|
|
3329
|
+
let bundle;
|
|
3330
|
+
try {
|
|
3331
|
+
bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
|
|
3332
|
+
} catch (e) {
|
|
3333
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
3334
|
+
console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
|
|
3335
|
+
}
|
|
3336
|
+
return null;
|
|
3337
|
+
}
|
|
3338
|
+
this.bundles.set(ref, bundle);
|
|
3339
|
+
return bundle;
|
|
2428
3340
|
}
|
|
2429
3341
|
async deletePendingBundle(ref) {
|
|
2430
3342
|
this.bundles.delete(ref);
|
|
3343
|
+
this.db.prepare("UPDATE mls_pending_bundle SET sealed_bundle=NULL, updated_at=? WHERE identity_id=? AND ref=?").run(Date.now(), this.id, ref);
|
|
3344
|
+
}
|
|
3345
|
+
async saveLastKpId(deviceId, id) {
|
|
3346
|
+
this.lastKpIds.set(deviceId, id);
|
|
3347
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, updated_at)
|
|
3348
|
+
VALUES (?,?,?,?)
|
|
3349
|
+
ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, updated_at=excluded.updated_at`).run(this.id, deviceId, id, Date.now());
|
|
3350
|
+
}
|
|
3351
|
+
async loadLastKpId(deviceId) {
|
|
3352
|
+
const cached = this.lastKpIds.get(deviceId);
|
|
3353
|
+
if (cached)
|
|
3354
|
+
return cached;
|
|
3355
|
+
const row = this.db.prepare("SELECT kp_id FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, deviceId);
|
|
3356
|
+
if (!row?.kp_id)
|
|
3357
|
+
return null;
|
|
3358
|
+
this.lastKpIds.set(deviceId, row.kp_id);
|
|
3359
|
+
return row.kp_id;
|
|
3360
|
+
}
|
|
3361
|
+
async saveLastKpAndBundle(deviceId, id, bundle) {
|
|
3362
|
+
this.lastKpIds.set(deviceId, id);
|
|
3363
|
+
this.bundles.set(deviceId, bundle);
|
|
3364
|
+
const sealed = await this.cipher.seal(encodeBundle(bundle));
|
|
3365
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, sealed_bundle, updated_at)
|
|
3366
|
+
VALUES (?,?,?,?,?)
|
|
3367
|
+
ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, deviceId, id, sealed, Date.now());
|
|
3368
|
+
}
|
|
3369
|
+
async savePinnedIdentityKey(identityId, key) {
|
|
3370
|
+
this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
|
|
3371
|
+
VALUES (?,?,?,?)
|
|
3372
|
+
ON CONFLICT(owner_identity_id) DO UPDATE SET
|
|
3373
|
+
public_key_hex=excluded.public_key_hex, key_algo=excluded.key_algo, pinned_at=excluded.pinned_at`).run(identityId, key.publicKeyHex, key.keyAlgo, Date.now());
|
|
3374
|
+
}
|
|
3375
|
+
async loadPinnedIdentityKey(identityId) {
|
|
3376
|
+
const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
|
|
3377
|
+
return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
|
|
3378
|
+
}
|
|
3379
|
+
async addRetiredGroupId(channelId, groupId) {
|
|
3380
|
+
this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
|
|
3381
|
+
}
|
|
3382
|
+
async loadRetiredGroupIds(channelId) {
|
|
3383
|
+
const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
|
|
3384
|
+
return rows.map((r) => r.group_id);
|
|
2431
3385
|
}
|
|
2432
3386
|
/** @internal test-only */
|
|
2433
3387
|
_rawMainForTest(identityId, channelId) {
|
|
@@ -2439,6 +3393,11 @@ var SqliteMlsStateStore = class {
|
|
|
2439
3393
|
}
|
|
2440
3394
|
};
|
|
2441
3395
|
|
|
3396
|
+
// src/bridge-agent.ts
|
|
3397
|
+
import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
|
|
3398
|
+
import { promises as fsp } from "node:fs";
|
|
3399
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
3400
|
+
|
|
2442
3401
|
// src/latency.ts
|
|
2443
3402
|
var DEFAULT_CAPACITY = 1024;
|
|
2444
3403
|
function round(n) {
|
|
@@ -2586,6 +3545,148 @@ var TokenBucketFloor = class {
|
|
|
2586
3545
|
}
|
|
2587
3546
|
};
|
|
2588
3547
|
|
|
3548
|
+
// src/decrypt-error-log-limiter.ts
|
|
3549
|
+
var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
|
|
3550
|
+
var MAX_DISTINCT_PER_WINDOW = 4;
|
|
3551
|
+
function normalizeMessage(m) {
|
|
3552
|
+
return m.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>").replace(/[0-9a-f]{16,}/gi, "<hex>");
|
|
3553
|
+
}
|
|
3554
|
+
var DecryptErrorLogLimiter = class {
|
|
3555
|
+
windowMs;
|
|
3556
|
+
now;
|
|
3557
|
+
log;
|
|
3558
|
+
windows = /* @__PURE__ */ new Map();
|
|
3559
|
+
constructor(opts = {}) {
|
|
3560
|
+
this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
|
|
3561
|
+
this.now = opts.now ?? Date.now;
|
|
3562
|
+
this.log = opts.log ?? ((line) => console.warn(line));
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* Record one decrypt error for `channelId`. Logs immediately if this is
|
|
3566
|
+
* the channel's first error since its window opened (or its prior window
|
|
3567
|
+
* had already elapsed, in which case a summary for that just-closed
|
|
3568
|
+
* window is flushed first); otherwise the error is counted silently
|
|
3569
|
+
* toward the window's eventual summary line.
|
|
3570
|
+
*/
|
|
3571
|
+
record(channelId, info) {
|
|
3572
|
+
const t = this.now();
|
|
3573
|
+
const w = this.windows.get(channelId);
|
|
3574
|
+
if (!w) {
|
|
3575
|
+
this.logFirst(channelId, info);
|
|
3576
|
+
this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
if (t - w.windowStart >= this.windowMs) {
|
|
3580
|
+
this.flushSummary(channelId, w);
|
|
3581
|
+
this.logFirst(channelId, info);
|
|
3582
|
+
this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
w.count += 1;
|
|
3586
|
+
w.lastEpoch = info.epoch;
|
|
3587
|
+
w.lastMessage = info.message;
|
|
3588
|
+
const key = normalizeMessage(info.message);
|
|
3589
|
+
if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
|
|
3590
|
+
this.logFirst(channelId, info);
|
|
3591
|
+
w.loggedKeys.add(key);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Clear a channel's window (e.g. once the caller learns the channel
|
|
3596
|
+
* cleared/healed) so its NEXT error logs immediately as a fresh "first"
|
|
3597
|
+
* rather than being folded into a stale window's summary. There is
|
|
3598
|
+
* currently no onDecryptSuccess-style hook from the SDK to wire this to
|
|
3599
|
+
* automatically (see bridge-agent.ts's onDecryptError wiring comment) —
|
|
3600
|
+
* exposed here so a future success signal (or a caller with its own
|
|
3601
|
+
* channel-cleared signal) can reset explicitly. In its absence, the
|
|
3602
|
+
* window simply rolls over on interval elapse, which is an accepted
|
|
3603
|
+
* simplification (see report).
|
|
3604
|
+
*/
|
|
3605
|
+
reset(channelId) {
|
|
3606
|
+
this.windows.delete(channelId);
|
|
3607
|
+
}
|
|
3608
|
+
logFirst(channelId, info) {
|
|
3609
|
+
this.log(
|
|
3610
|
+
`[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
|
|
3611
|
+
);
|
|
3612
|
+
}
|
|
3613
|
+
flushSummary(channelId, w) {
|
|
3614
|
+
if (w.count <= 1) return;
|
|
3615
|
+
const seconds = Math.round(this.windowMs / 1e3);
|
|
3616
|
+
this.log(
|
|
3617
|
+
`[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
|
|
3618
|
+
);
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
|
|
3622
|
+
// src/attachments.ts
|
|
3623
|
+
import { basename } from "node:path";
|
|
3624
|
+
function sanitizeFilename(name, fallback) {
|
|
3625
|
+
const base = basename(name ?? "");
|
|
3626
|
+
const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
|
|
3627
|
+
return cleaned.length > 0 ? cleaned : fallback;
|
|
3628
|
+
}
|
|
3629
|
+
var MIME_BY_EXT = {
|
|
3630
|
+
png: "image/png",
|
|
3631
|
+
jpg: "image/jpeg",
|
|
3632
|
+
jpeg: "image/jpeg",
|
|
3633
|
+
gif: "image/gif",
|
|
3634
|
+
webp: "image/webp",
|
|
3635
|
+
svg: "image/svg+xml",
|
|
3636
|
+
pdf: "application/pdf",
|
|
3637
|
+
txt: "text/plain",
|
|
3638
|
+
md: "text/markdown",
|
|
3639
|
+
json: "application/json",
|
|
3640
|
+
csv: "text/csv",
|
|
3641
|
+
html: "text/html",
|
|
3642
|
+
mp4: "video/mp4",
|
|
3643
|
+
mp3: "audio/mpeg",
|
|
3644
|
+
zip: "application/zip",
|
|
3645
|
+
wav: "audio/wav"
|
|
3646
|
+
};
|
|
3647
|
+
function mimeForName(name) {
|
|
3648
|
+
const dot = name.lastIndexOf(".");
|
|
3649
|
+
if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
|
|
3650
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
3651
|
+
}
|
|
3652
|
+
async function prepareAttachment(filePath, caption, channelId, deps) {
|
|
3653
|
+
const bytes = await deps.readFile(filePath);
|
|
3654
|
+
const name = basename(filePath);
|
|
3655
|
+
const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
|
|
3656
|
+
const blobId = await deps.uploadBlob(encryptedData, channelId);
|
|
3657
|
+
const body = {
|
|
3658
|
+
v: 1,
|
|
3659
|
+
type: "artifact_share",
|
|
3660
|
+
name,
|
|
3661
|
+
mime: mimeForName(name),
|
|
3662
|
+
size: bytes.length,
|
|
3663
|
+
blobId,
|
|
3664
|
+
fileKey: deps.bytesToBase64(fileKey),
|
|
3665
|
+
fileNonce: deps.bytesToBase64(fileNonce),
|
|
3666
|
+
digest
|
|
3667
|
+
};
|
|
3668
|
+
if (caption) body.text = caption;
|
|
3669
|
+
return body;
|
|
3670
|
+
}
|
|
3671
|
+
async function materializeAttachment(body, ctx, deps) {
|
|
3672
|
+
const ciphertext = await deps.downloadBlob(body.blobId);
|
|
3673
|
+
const sha2565 = deps.computeFileDigest(ciphertext);
|
|
3674
|
+
if (sha2565 !== body.digest) {
|
|
3675
|
+
throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
|
|
3676
|
+
}
|
|
3677
|
+
const plain = deps.decryptFile(
|
|
3678
|
+
ciphertext,
|
|
3679
|
+
deps.base64ToBytes(body.fileKey),
|
|
3680
|
+
deps.base64ToBytes(body.fileNonce)
|
|
3681
|
+
);
|
|
3682
|
+
const safeName = sanitizeFilename(body.name, ctx.messageId);
|
|
3683
|
+
const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
|
|
3684
|
+
const path = `${dir}/${ctx.messageId}-${safeName}`;
|
|
3685
|
+
await deps.mkdirp(dir);
|
|
3686
|
+
await deps.writeFile(path, plain);
|
|
3687
|
+
return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
|
|
3688
|
+
}
|
|
3689
|
+
|
|
2589
3690
|
// src/bridge-agent.ts
|
|
2590
3691
|
var DEFAULT_MAX_BUFFER = 1e3;
|
|
2591
3692
|
var CHANNEL_META_TTL_MS = 5 * 6e4;
|
|
@@ -2606,16 +3707,48 @@ var RateLimitedError = class extends Error {
|
|
|
2606
3707
|
this.name = "RateLimitedError";
|
|
2607
3708
|
}
|
|
2608
3709
|
};
|
|
3710
|
+
var InvalidPathError = class extends Error {
|
|
3711
|
+
constructor(filePath, cause) {
|
|
3712
|
+
super(`cannot read attachment file: ${filePath}`);
|
|
3713
|
+
this.filePath = filePath;
|
|
3714
|
+
this.name = "InvalidPathError";
|
|
3715
|
+
if (cause !== void 0) this.cause = cause;
|
|
3716
|
+
}
|
|
3717
|
+
};
|
|
3718
|
+
var AgentPausedSendError = class extends Error {
|
|
3719
|
+
constructor(channelId) {
|
|
3720
|
+
super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
|
|
3721
|
+
this.channelId = channelId;
|
|
3722
|
+
this.name = "AgentPausedSendError";
|
|
3723
|
+
}
|
|
3724
|
+
};
|
|
3725
|
+
var UnknownActionRunError = class extends Error {
|
|
3726
|
+
constructor(runId) {
|
|
3727
|
+
super(`unknown or already-settled agent action run: ${runId}`);
|
|
3728
|
+
this.runId = runId;
|
|
3729
|
+
this.name = "UnknownActionRunError";
|
|
3730
|
+
}
|
|
3731
|
+
};
|
|
2609
3732
|
var BRIDGE_VERSION = "0.0.0";
|
|
2610
|
-
function
|
|
3733
|
+
function hexToBytes4(h) {
|
|
2611
3734
|
return new Uint8Array(Buffer.from(h, "hex"));
|
|
2612
3735
|
}
|
|
2613
3736
|
var BridgeAgent = class _BridgeAgent {
|
|
2614
3737
|
constructor(cfg, deps = {}) {
|
|
2615
3738
|
this.cfg = cfg;
|
|
2616
|
-
const seed =
|
|
3739
|
+
const seed = hexToBytes4(cfg.signingPrivateKey);
|
|
2617
3740
|
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
2618
|
-
this.transport = deps.transport ?? new RestTransport({
|
|
3741
|
+
this.transport = deps.transport ?? new RestTransport({
|
|
3742
|
+
baseUrl: cfg.apiBase,
|
|
3743
|
+
bearerToken: cfg.apiKey,
|
|
3744
|
+
connectIdentityId: cfg.identityId,
|
|
3745
|
+
// P1b: hardened delivery-ping reconnect (verify-before-terminal +
|
|
3746
|
+
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
3747
|
+
// SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
|
|
3748
|
+
hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
|
|
3749
|
+
onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
|
|
3750
|
+
`)
|
|
3751
|
+
});
|
|
2619
3752
|
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
2620
3753
|
this.store = new SqliteMlsStateStore({
|
|
2621
3754
|
dbPath: cfg.storePath,
|
|
@@ -2630,12 +3763,47 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2630
3763
|
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
2631
3764
|
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
2632
3765
|
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
3766
|
+
// P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
|
|
3767
|
+
// rather than a raw console.warn per failure — a wedged channel used to
|
|
3768
|
+
// redeliver the same undecryptable row on every pullDelivery cycle and
|
|
3769
|
+
// produced a 114 MB / 765,000-line daemon log in a single day. The
|
|
3770
|
+
// limiter still logs the first failure immediately and collapses every
|
|
3771
|
+
// repeat within 60s into one summary line; errorStats() below is fed
|
|
3772
|
+
// directly by the provider and is completely unaffected.
|
|
2633
3773
|
onDecryptError: (row, err) => {
|
|
2634
3774
|
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
3775
|
+
this.decryptErrorLogLimiter.record(cid, {
|
|
3776
|
+
epoch: row.epoch,
|
|
3777
|
+
queueId: row.queue_id,
|
|
3778
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3779
|
+
});
|
|
3780
|
+
},
|
|
3781
|
+
// P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
|
|
3782
|
+
// channel's decrypt-failure run crosses quarantineThreshold and its
|
|
3783
|
+
// stuck row has been ack-drained. Log one quarantine line, then
|
|
3784
|
+
// best-effort trigger the SAME held-KP re-Welcome heal used by
|
|
3785
|
+
// ingestOnce's Task-11 branch and selfHealSweep (see
|
|
3786
|
+
// healWedgedChannel below) — never throws back into the SDK caller.
|
|
3787
|
+
onWedged: (channelId) => {
|
|
3788
|
+
try {
|
|
3789
|
+
console.warn(
|
|
3790
|
+
`[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
|
|
3791
|
+
);
|
|
3792
|
+
void this.healWedgedChannel(channelId);
|
|
3793
|
+
} catch {
|
|
3794
|
+
}
|
|
3795
|
+
},
|
|
3796
|
+
// Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
|
|
3797
|
+
// calls this before honoring an owner-authored supersede. It must return
|
|
3798
|
+
// a value ONLY when the structural owner is unambiguous AND a pin exists
|
|
3799
|
+
// for it — never trust a bare in-memory cache without reconfirming
|
|
3800
|
+
// against the persisted pin (see resolvePinnedOwnerHook below).
|
|
3801
|
+
onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId),
|
|
3802
|
+
// P1a self-heal: a verified reset supersede whose Welcome we cannot open
|
|
3803
|
+
// (sealed to a stale published KeyPackage) — ask the committer to re-Welcome
|
|
3804
|
+
// us to the reset group with our CURRENT KeyPackage. Cooldown-guarded, never
|
|
3805
|
+
// throws (mirrors the divergence-heal signal path).
|
|
3806
|
+
onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
|
|
2639
3807
|
});
|
|
2640
3808
|
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
2641
3809
|
this.latency = deps.latency ?? new LatencyRecorder();
|
|
@@ -2644,6 +3812,37 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2644
3812
|
capacity: cfg.rateLimitCapacity ?? 20,
|
|
2645
3813
|
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
2646
3814
|
});
|
|
3815
|
+
this.actionClock = deps.actionThrottleNow ?? Date.now;
|
|
3816
|
+
this.rng = deps.rng ?? Math.random;
|
|
3817
|
+
this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
3818
|
+
this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
3819
|
+
const self = this;
|
|
3820
|
+
this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
|
|
3821
|
+
const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
3822
|
+
const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
|
|
3823
|
+
if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
|
|
3824
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
3825
|
+
};
|
|
3826
|
+
this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
|
|
3827
|
+
const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
3828
|
+
const res = await self.fetchImpl(url, {
|
|
3829
|
+
method: "POST",
|
|
3830
|
+
headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
|
|
3831
|
+
body: ciphertext
|
|
3832
|
+
});
|
|
3833
|
+
if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
|
|
3834
|
+
const j = await res.json();
|
|
3835
|
+
return String(j.attachment_id);
|
|
3836
|
+
};
|
|
3837
|
+
this.attachmentsDirImpl = deps.attachmentsDir ?? join2(dirname2(cfg.storePath), "attachments");
|
|
3838
|
+
this.action = {
|
|
3839
|
+
begin: (channelId) => this.actionBegin(channelId),
|
|
3840
|
+
start: (opts) => this.actionStart(opts),
|
|
3841
|
+
update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
|
|
3842
|
+
finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
|
|
3843
|
+
settle: (runId, opts) => this.actionSettle(runId, opts),
|
|
3844
|
+
wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
|
|
3845
|
+
};
|
|
2647
3846
|
}
|
|
2648
3847
|
transport;
|
|
2649
3848
|
provider;
|
|
@@ -2673,6 +3872,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2673
3872
|
* per channel on a miss.
|
|
2674
3873
|
*/
|
|
2675
3874
|
channelRoster = /* @__PURE__ */ new Map();
|
|
3875
|
+
/**
|
|
3876
|
+
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
3877
|
+
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
3878
|
+
* the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
|
|
3879
|
+
* means the DM roster shape was not exactly one non-self, non-agent
|
|
3880
|
+
* counterpart, OR the freshly-fetched key no longer matches an existing pin —
|
|
3881
|
+
* either way the hook must fail closed (return null) for that channel.
|
|
3882
|
+
*/
|
|
3883
|
+
channelOwner = /* @__PURE__ */ new Map();
|
|
2676
3884
|
buffer = [];
|
|
2677
3885
|
maxBuffer;
|
|
2678
3886
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
@@ -2687,11 +3895,111 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2687
3895
|
rateLimitedSends = 0;
|
|
2688
3896
|
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
2689
3897
|
welcomeRequestsSent = 0;
|
|
3898
|
+
/** 2026-07-03 latency triage: cumulative delivery pings received on the WS
|
|
3899
|
+
* stream. 0 while messages still flow = the stream is dead and inbound is
|
|
3900
|
+
* riding the 25s safety floor (surfaced on health()/metrics). */
|
|
3901
|
+
pingsReceived = 0;
|
|
3902
|
+
/** True once close() begins — gates the ping-stream-death stderr warning so
|
|
3903
|
+
* a normal shutdown doesn't cry wolf. */
|
|
3904
|
+
closingDown = false;
|
|
3905
|
+
/**
|
|
3906
|
+
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
3907
|
+
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
3908
|
+
* counter is incremented. Surfaced on health()/metrics().
|
|
3909
|
+
*/
|
|
3910
|
+
failedAttachments = 0;
|
|
2690
3911
|
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
2691
3912
|
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
2692
3913
|
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
2693
3914
|
static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
|
|
3915
|
+
// H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
|
|
3916
|
+
static GRACEFUL_DRAIN_MS = 5e3;
|
|
3917
|
+
/**
|
|
3918
|
+
* P0b (daemon-stability): collapses the onDecryptError console.warn from
|
|
3919
|
+
* "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
|
|
3920
|
+
* most one line immediately + one summary line per 60s per channel. LOG
|
|
3921
|
+
* OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
|
|
3922
|
+
* live entirely in the SDK provider and are unaffected by this class.
|
|
3923
|
+
*/
|
|
3924
|
+
decryptErrorLogLimiter = new DecryptErrorLogLimiter();
|
|
3925
|
+
/**
|
|
3926
|
+
* Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
|
|
3927
|
+
* only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
|
|
3928
|
+
* rows — an idle-but-wiped channel therefore loses its NEXT message (the
|
|
3929
|
+
* very message that would have proven it diverged). selfHealSweep() closes
|
|
3930
|
+
* that gap by proactively walking every non-DM member channel at boot and
|
|
3931
|
+
* on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
|
|
3932
|
+
* back to the delivery-row-driven Task 11 behaviour only.
|
|
3933
|
+
*/
|
|
3934
|
+
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
3935
|
+
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
3936
|
+
static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
3937
|
+
// C3: add up to this much random jitter to BOTH the boot sweep delay and each
|
|
3938
|
+
// periodic interval, so a fleet of daemons restarting/sweeping together does not
|
|
3939
|
+
// fire re-Welcome signals at the SAME instant and thundering-herd the single
|
|
3940
|
+
// committer (the observed 409 commit-lease saturation). Decorrelation only —
|
|
3941
|
+
// C1 already removed the restart KP-rotation storm that caused the herd.
|
|
3942
|
+
static SELF_HEAL_SWEEP_JITTER_MS = 3e4;
|
|
3943
|
+
selfHealTimer;
|
|
3944
|
+
bootSweepTimer;
|
|
3945
|
+
rng;
|
|
2694
3946
|
rateFloor;
|
|
3947
|
+
/**
|
|
3948
|
+
* agent.action.* run state, keyed by runId.
|
|
3949
|
+
*
|
|
3950
|
+
* Review M3 (bug fix): the ORIGINAL comment here said "deliberately
|
|
3951
|
+
* unbounded" — but nothing ever bounded it either. Only actionSettle()
|
|
3952
|
+
* deleted an entry; a begun-but-never-settled run (agent crash, bug,
|
|
3953
|
+
* dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
|
|
3954
|
+
* bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
|
|
3955
|
+
* lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
|
|
3956
|
+
* mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
|
|
3957
|
+
*/
|
|
3958
|
+
actionRuns = /* @__PURE__ */ new Map();
|
|
3959
|
+
/** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
|
|
3960
|
+
* evicts the oldest (LRU by startedAt) to make room — never grows past
|
|
3961
|
+
* this regardless of how many runs are begun and abandoned. */
|
|
3962
|
+
static MAX_UNSETTLED_ACTION_RUNS = 50;
|
|
3963
|
+
/** Review M3: an unsettled run older than this (by startedAt, relative to
|
|
3964
|
+
* actionClock()) is swept away on the next actionBegin(), independent of
|
|
3965
|
+
* the count-based cap — bounds memory even when begin() calls are rare. */
|
|
3966
|
+
static ACTION_RUN_STALE_MS = 60 * 6e4;
|
|
3967
|
+
// 60 minutes
|
|
3968
|
+
/** Review I3/M3: injected clock (defaults Date.now) for the action-partial
|
|
3969
|
+
* token buckets AND the stale-run sweep — a single seam so tests can
|
|
3970
|
+
* simulate elapsed time deterministically. */
|
|
3971
|
+
actionClock;
|
|
3972
|
+
/**
|
|
3973
|
+
* Per-run token bucket for `agent_action` PARTIAL frames (Global
|
|
3974
|
+
* Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
|
|
3975
|
+
* `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
|
|
3976
|
+
* capacity" — partials instead COALESCE: a throttled call just leaves its
|
|
3977
|
+
* change in `dirty` for the next allowed send to pick up).
|
|
3978
|
+
*
|
|
3979
|
+
* Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
|
|
3980
|
+
* backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
|
|
3981
|
+
* the settled frame + the agent's real reply + reportStatus all compete
|
|
3982
|
+
* for that one shared per-sender budget, so a long run's settled frame or
|
|
3983
|
+
* reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
|
|
3984
|
+
* partials alone use at most ~half the identity's send budget, leaving
|
|
3985
|
+
* headroom for the settled frame / reply / status traffic riding the SAME
|
|
3986
|
+
* cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
|
|
3987
|
+
* `settled` frames bypass this bucket entirely — they always send, exactly
|
|
3988
|
+
* once, per run.
|
|
3989
|
+
*/
|
|
3990
|
+
actionRateFloor;
|
|
3991
|
+
/**
|
|
3992
|
+
* Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
|
|
3993
|
+
* the per-run bucket above. Without this, N concurrent runs each get their
|
|
3994
|
+
* OWN ~30/min budget, so the partial send rate multiplies with agent
|
|
3995
|
+
* concurrency and can still blow past the shared 60/min identity edge cap
|
|
3996
|
+
* (each run individually "has headroom" but the SUM doesn't). Same
|
|
3997
|
+
* capacity/refill as the per-run bucket — it caps the aggregate, not any
|
|
3998
|
+
* single run.
|
|
3999
|
+
*/
|
|
4000
|
+
globalActionRateFloor;
|
|
4001
|
+
/** Public `agent.action.*` surface — assigned once at the end of the constructor. */
|
|
4002
|
+
action;
|
|
2695
4003
|
waiters = [];
|
|
2696
4004
|
pingHandle;
|
|
2697
4005
|
safetyTimer;
|
|
@@ -2699,6 +4007,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2699
4007
|
startedAt = 0;
|
|
2700
4008
|
store;
|
|
2701
4009
|
latency;
|
|
4010
|
+
/** Track 5: injectable blob download fn (default: fetch-based). */
|
|
4011
|
+
downloadBlobImpl;
|
|
4012
|
+
/** Track 5: injectable blob upload fn (default: fetch-based). */
|
|
4013
|
+
uploadBlobImpl;
|
|
4014
|
+
/** Track 5: directory where received attachments are saved. */
|
|
4015
|
+
attachmentsDirImpl;
|
|
2702
4016
|
/**
|
|
2703
4017
|
* Enforce the inbound-buffer cap with a DROP-OLDEST policy.
|
|
2704
4018
|
*
|
|
@@ -2722,6 +4036,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2722
4036
|
this.startedAt = Date.now();
|
|
2723
4037
|
await this.provider.publishKeyPackage();
|
|
2724
4038
|
this.startReceivePump();
|
|
4039
|
+
if (this.selfHealSweepEnabled) {
|
|
4040
|
+
this.bootSweepTimer = setTimeout(() => void this.selfHealSweep(), this.sweepJitter());
|
|
4041
|
+
this.bootSweepTimer.unref?.();
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
/** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
|
|
4045
|
+
sweepJitter() {
|
|
4046
|
+
return Math.floor(this.rng() * _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS);
|
|
2725
4047
|
}
|
|
2726
4048
|
async reconcileJoin(channelId) {
|
|
2727
4049
|
if (this.joinState.get(channelId) === "JOINED") return true;
|
|
@@ -2734,6 +4056,145 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2734
4056
|
return false;
|
|
2735
4057
|
}
|
|
2736
4058
|
}
|
|
4059
|
+
/**
|
|
4060
|
+
* The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
|
|
4061
|
+
* reactive no-local-state branch in ingestOnce(), the proactive
|
|
4062
|
+
* selfHealSweep() (Task 12), the onWedged forked-state recovery
|
|
4063
|
+
* (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
|
|
4064
|
+
*
|
|
4065
|
+
* Before asking the committer to (re-)Welcome us, it RECONCILES the server's
|
|
4066
|
+
* published KeyPackage to our held bundle. This is the fix for the observed
|
|
4067
|
+
* "fresh DM never replies" AND "reset doesn't work": the committer seals every
|
|
4068
|
+
* Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
|
|
4069
|
+
* locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
|
|
4070
|
+
* "private KeyPackage key no longer held" and the Welcome is discarded. A bare
|
|
4071
|
+
* re-Welcome re-seals to the SAME diverged published KP — un-openable again —
|
|
4072
|
+
* looping until the channel quarantines. So:
|
|
4073
|
+
* - published == held → publishKeyPackage() (no-op while held; mints only if
|
|
4074
|
+
* somehow none is held), then re-Welcome re-seals to the current KP we hold.
|
|
4075
|
+
* - published != held → forceRepublishKeyPackage() re-syncs the server to a
|
|
4076
|
+
* bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
|
|
4077
|
+
* diverged published KP means every other pending Welcome sealed via the
|
|
4078
|
+
* server is un-openable too, so replacing the shared per-device bundle
|
|
4079
|
+
* strands nothing that was openable (see heldBundleMatchesPublishedKp).
|
|
4080
|
+
* getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
|
|
4081
|
+
* Callers own their cooldown/rate-limit gate and try/catch — this does neither.
|
|
4082
|
+
*/
|
|
4083
|
+
async signalHeldKpWelcome(groupId) {
|
|
4084
|
+
if (await this.provider.heldBundleMatchesPublishedKp()) {
|
|
4085
|
+
await this.provider.publishKeyPackage();
|
|
4086
|
+
} else {
|
|
4087
|
+
await this.provider.forceRepublishKeyPackage();
|
|
4088
|
+
}
|
|
4089
|
+
const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
|
|
4090
|
+
await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
|
|
4091
|
+
this.welcomeRequestsSent++;
|
|
4092
|
+
}
|
|
4093
|
+
/**
|
|
4094
|
+
* P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
|
|
4095
|
+
* `connect_channel_id` — resolve it to an MLS groupId the SAME way
|
|
4096
|
+
* selfHealSweep does, via the read-only getConnectChannelGroup probe
|
|
4097
|
+
* (never initConnectChannelGroup, which side-effects a member_device_ids
|
|
4098
|
+
* union + commit), then fire the existing held-KP re-Welcome signal.
|
|
4099
|
+
* Respects the SAME per-channel cooldown (lastWelcomeRequest /
|
|
4100
|
+
* REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
|
|
4101
|
+
* selfHealSweep — this must never hammer requestWelcome on its own.
|
|
4102
|
+
*
|
|
4103
|
+
* Best-effort in every sense: if the channel has no resolvable group (no
|
|
4104
|
+
* local state to probe from), this logs nothing further and just returns —
|
|
4105
|
+
* the channel is left to the next selfHealSweep pass or reactive Task-11
|
|
4106
|
+
* branch, mirroring how those two paths already treat an unresolvable
|
|
4107
|
+
* group (`continue`, no signal). ANY failure along the way (probe throws,
|
|
4108
|
+
* signal throws) is swallowed: a wedge heal must never throw back into the
|
|
4109
|
+
* SDK's onWedged caller. Single-shot per wedge episode — full rejoin
|
|
4110
|
+
* reliability is P1, not this hook's job.
|
|
4111
|
+
*/
|
|
4112
|
+
async healWedgedChannel(channelId) {
|
|
4113
|
+
try {
|
|
4114
|
+
const now = performance.now();
|
|
4115
|
+
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4116
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
|
|
4117
|
+
const g = await this.backend.getConnectChannelGroup(channelId);
|
|
4118
|
+
if (!g?.group_id) return;
|
|
4119
|
+
this.lastWelcomeRequest.set(channelId, now);
|
|
4120
|
+
if (await this.provider.hasPersistedChannelState(channelId)) {
|
|
4121
|
+
await this.provider.resetChannelForRejoin(channelId);
|
|
4122
|
+
this.joinState.delete(channelId);
|
|
4123
|
+
}
|
|
4124
|
+
await this.signalHeldKpWelcome(g.group_id);
|
|
4125
|
+
} catch {
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
/**
|
|
4129
|
+
* P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
|
|
4130
|
+
* Welcome we cannot open — it was sealed to a stale published KeyPackage our
|
|
4131
|
+
* held bundle no longer matches ("No matching secret found"). trySupersede has
|
|
4132
|
+
* verified owner + attestation + signature, so this is a real reset we WANT; we
|
|
4133
|
+
* just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
|
|
4134
|
+
* `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
|
|
4135
|
+
* a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
|
|
4136
|
+
* and the reset converges. The groupId is known (handed in), so no probe needed.
|
|
4137
|
+
*
|
|
4138
|
+
* Returns true ONLY when a re-Welcome was actually requested — trySupersede then
|
|
4139
|
+
* discards the un-openable row. Under the shared per-channel cooldown (or on any
|
|
4140
|
+
* failure) returns false, so trySupersede LEAVES the row for a bounded retry
|
|
4141
|
+
* rather than dropping it with no replacement in flight. Never throws.
|
|
4142
|
+
*/
|
|
4143
|
+
async healSupersedeUnopenable(channelId, groupId) {
|
|
4144
|
+
try {
|
|
4145
|
+
const now = performance.now();
|
|
4146
|
+
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4147
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return false;
|
|
4148
|
+
this.lastWelcomeRequest.set(channelId, now);
|
|
4149
|
+
await this.signalHeldKpWelcome(groupId);
|
|
4150
|
+
return true;
|
|
4151
|
+
} catch {
|
|
4152
|
+
return false;
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
|
|
4157
|
+
* branch only fires reactively — off a delivery ROW that already arrived
|
|
4158
|
+
* for a diverged channel. A wiped-but-IDLE channel (no traffic since the
|
|
4159
|
+
* wipe) never produces that row, so its next real message would be the
|
|
4160
|
+
* first thing lost. This sweep walks every member channel — rooms AND DMs
|
|
4161
|
+
* (H2: a wiped, idle DM with no traffic since the wipe otherwise never
|
|
4162
|
+
* proactively re-Welcomes, so it stays dark until the owner happens to send)
|
|
4163
|
+
* — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
|
|
4164
|
+
* persisted state even after a reconcile attempt, resolves group_id via
|
|
4165
|
+
* the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
|
|
4166
|
+
* which would side-effect a member_device_ids union + commit) and applies
|
|
4167
|
+
* the same held-KP signal — respecting the existing per-channel cooldown.
|
|
4168
|
+
*
|
|
4169
|
+
* Each channel is wrapped in its own try/catch: `listChannels` filters at
|
|
4170
|
+
* the identity level while `requestWelcome` (via request_welcome) gates at
|
|
4171
|
+
* the device level, so a channel where this identity is a member but this
|
|
4172
|
+
* DEVICE never held a leaf can 403 on the signal call. That's harmless and
|
|
4173
|
+
* must not abort the sweep for every other channel.
|
|
4174
|
+
*/
|
|
4175
|
+
async selfHealSweep() {
|
|
4176
|
+
let chans;
|
|
4177
|
+
try {
|
|
4178
|
+
chans = await this.listChannels();
|
|
4179
|
+
} catch {
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
for (const c of chans) {
|
|
4183
|
+
try {
|
|
4184
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4185
|
+
await this.reconcileJoin(c.channel_id);
|
|
4186
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4187
|
+
const g = await this.backend.getConnectChannelGroup(c.channel_id);
|
|
4188
|
+
if (!g?.group_id) continue;
|
|
4189
|
+
const now = performance.now();
|
|
4190
|
+
const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
|
|
4191
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
4192
|
+
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
4193
|
+
await this.signalHeldKpWelcome(g.group_id);
|
|
4194
|
+
} catch {
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
2737
4198
|
/**
|
|
2738
4199
|
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
2739
4200
|
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
@@ -2746,6 +4207,40 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2746
4207
|
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
2747
4208
|
}
|
|
2748
4209
|
}
|
|
4210
|
+
/**
|
|
4211
|
+
* The ONE seam every application-send path (send/sendA2A/sendAttachment)
|
|
4212
|
+
* calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
|
|
4213
|
+
* daemon's per-room-pause terminal classification (Task 9).
|
|
4214
|
+
*
|
|
4215
|
+
* `AgentPausedError` (Task 8's SDK classification of the backend's 403
|
|
4216
|
+
* `agent_paused`) means an owner/admin has deliberately muted this agent in
|
|
4217
|
+
* this room — NOT a transient/network failure. That distinction MUST be
|
|
4218
|
+
* made here, before control returns to any caller, so:
|
|
4219
|
+
* - it never reaches a heal/self-heal trigger (healWedgedChannel /
|
|
4220
|
+
* onWedged re-Welcome are wired off the RECEIVE path via SDK provider
|
|
4221
|
+
* callbacks, not off a send failure — this seam guarantees a send
|
|
4222
|
+
* failure never grows one either, now or as the code evolves), and
|
|
4223
|
+
* - it never gets treated as a generic/retryable failure by a caller —
|
|
4224
|
+
* rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
|
|
4225
|
+
* ChannelPendingError/RateLimitedError, both also bridge-agent.ts
|
|
4226
|
+
* locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
|
|
4227
|
+
* same way they already do for those two, instead of it falling into
|
|
4228
|
+
* their generic "internal error" bucket.
|
|
4229
|
+
* Any other error (including StaleEpochError retries, which sendWithSelfHeal
|
|
4230
|
+
* already resolves internally) propagates unchanged.
|
|
4231
|
+
*/
|
|
4232
|
+
async sendWithSelfHealTerminal(...args2) {
|
|
4233
|
+
const [, channelId] = args2;
|
|
4234
|
+
try {
|
|
4235
|
+
return await sendWithSelfHeal(...args2);
|
|
4236
|
+
} catch (e) {
|
|
4237
|
+
if (e instanceof AgentPausedError) {
|
|
4238
|
+
console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
|
|
4239
|
+
throw new AgentPausedSendError(channelId);
|
|
4240
|
+
}
|
|
4241
|
+
throw e;
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
2749
4244
|
async send(channelId, text, messageId) {
|
|
2750
4245
|
const meta = this.freshMeta(channelId);
|
|
2751
4246
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
@@ -2753,7 +4248,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2753
4248
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2754
4249
|
const id = messageId ?? crypto.randomUUID();
|
|
2755
4250
|
const sendStart = performance.now();
|
|
2756
|
-
await
|
|
4251
|
+
await this.sendWithSelfHealTerminal(
|
|
2757
4252
|
this.provider,
|
|
2758
4253
|
channelId,
|
|
2759
4254
|
(epoch) => this.latency.time(
|
|
@@ -2789,7 +4284,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2789
4284
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2790
4285
|
const id = messageId ?? crypto.randomUUID();
|
|
2791
4286
|
const sendStart = performance.now();
|
|
2792
|
-
await
|
|
4287
|
+
await this.sendWithSelfHealTerminal(
|
|
2793
4288
|
this.provider,
|
|
2794
4289
|
channelId,
|
|
2795
4290
|
(epoch) => this.latency.time(
|
|
@@ -2819,6 +4314,59 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2819
4314
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
2820
4315
|
return { message_id: id, status: "sent" };
|
|
2821
4316
|
}
|
|
4317
|
+
sendDeps() {
|
|
4318
|
+
return {
|
|
4319
|
+
uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
|
|
4320
|
+
encryptFile,
|
|
4321
|
+
bytesToBase64: bytesToBase642,
|
|
4322
|
+
readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
|
|
4323
|
+
};
|
|
4324
|
+
}
|
|
4325
|
+
async sendAttachment(channelId, filePath, caption) {
|
|
4326
|
+
this.enforceRateFloor("attachment", channelId);
|
|
4327
|
+
const joined = await this.reconcileJoin(channelId);
|
|
4328
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
4329
|
+
let body;
|
|
4330
|
+
try {
|
|
4331
|
+
body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
|
|
4332
|
+
} catch (e) {
|
|
4333
|
+
if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
|
|
4334
|
+
throw new InvalidPathError(filePath, e);
|
|
4335
|
+
}
|
|
4336
|
+
throw e;
|
|
4337
|
+
}
|
|
4338
|
+
const id = crypto.randomUUID();
|
|
4339
|
+
const sendStart = performance.now();
|
|
4340
|
+
await this.sendWithSelfHealTerminal(
|
|
4341
|
+
this.provider,
|
|
4342
|
+
channelId,
|
|
4343
|
+
(epoch) => this.latency.time(
|
|
4344
|
+
"send.encrypt",
|
|
4345
|
+
() => buildOutboundEnvelope({
|
|
4346
|
+
custody: this.custody,
|
|
4347
|
+
groupKey: this.provider,
|
|
4348
|
+
messageId: id,
|
|
4349
|
+
channelId,
|
|
4350
|
+
enclaveId: this.cfg.enclaveId,
|
|
4351
|
+
senderId: this.cfg.identityId,
|
|
4352
|
+
senderType: "agent",
|
|
4353
|
+
type: "artifact_share",
|
|
4354
|
+
bodyPlaintext: JSON.stringify(body),
|
|
4355
|
+
bindingId: "",
|
|
4356
|
+
bindingVersion: "0.0.0",
|
|
4357
|
+
agentSkillId: null,
|
|
4358
|
+
epoch,
|
|
4359
|
+
traceparent: ZERO_TRACEPARENT,
|
|
4360
|
+
references: [],
|
|
4361
|
+
stage5Outcome: "allow",
|
|
4362
|
+
stage5Evaluators: []
|
|
4363
|
+
})
|
|
4364
|
+
),
|
|
4365
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
4366
|
+
);
|
|
4367
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
4368
|
+
return { message_id: id, status: "sent" };
|
|
4369
|
+
}
|
|
2822
4370
|
async listChannels() {
|
|
2823
4371
|
const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
2824
4372
|
const res = await this.fetchImpl(url, {
|
|
@@ -2886,9 +4434,77 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2886
4434
|
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
2887
4435
|
}
|
|
2888
4436
|
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
4437
|
+
await this.resolveAndPinOwner(channelId, rows);
|
|
2889
4438
|
} catch {
|
|
2890
4439
|
}
|
|
2891
4440
|
}
|
|
4441
|
+
/**
|
|
4442
|
+
* Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
|
|
4443
|
+
* NEVER read the server's `role` field to pick the owner. In a DM the owner
|
|
4444
|
+
* is the SINGLE non-self, non-agent counterpart; any other roster shape
|
|
4445
|
+
* (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
|
|
4446
|
+
* supersede eligibility). On the first clean sighting of an unambiguous
|
|
4447
|
+
* owner with no existing pin, TOFU-pin its public key. On a later sighting
|
|
4448
|
+
* where a pin already exists and the freshly-fetched key differs, this is an
|
|
4449
|
+
* anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
|
|
4450
|
+
* this must never throw into the roster refresh path.
|
|
4451
|
+
*/
|
|
4452
|
+
async resolveAndPinOwner(channelId, rows) {
|
|
4453
|
+
try {
|
|
4454
|
+
const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
|
|
4455
|
+
if (counterparts.length !== 1) {
|
|
4456
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4457
|
+
return;
|
|
4458
|
+
}
|
|
4459
|
+
const owner = counterparts[0];
|
|
4460
|
+
const publicKeyHex = owner.public_key ?? "";
|
|
4461
|
+
if (!publicKeyHex) {
|
|
4462
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
|
|
4466
|
+
const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
|
|
4467
|
+
if (!existing) {
|
|
4468
|
+
await this.store.savePinnedIdentityKey(owner.identity_id, {
|
|
4469
|
+
publicKeyHex,
|
|
4470
|
+
keyAlgo: owner.key_algo ?? "ed25519"
|
|
4471
|
+
});
|
|
4472
|
+
} else if (existing.publicKeyHex !== publicKeyHex) {
|
|
4473
|
+
console.warn(
|
|
4474
|
+
`[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
|
|
4475
|
+
);
|
|
4476
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4477
|
+
}
|
|
4478
|
+
} catch {
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
|
|
4483
|
+
* SDK's step-0 guard calls this before honoring an owner-authored supersede;
|
|
4484
|
+
* returning null makes it fail closed. Returns a value ONLY when (a) the
|
|
4485
|
+
* structural owner cache has an unambiguous entry for this channel AND (b) a
|
|
4486
|
+
* persisted pin still exists and matches it — a bare in-memory cache hit is
|
|
4487
|
+
* never trusted on its own, it is always reconfirmed against the store.
|
|
4488
|
+
*/
|
|
4489
|
+
async resolvePinnedOwnerHook(channelId) {
|
|
4490
|
+
const cached = this.channelOwner.get(channelId);
|
|
4491
|
+
const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
|
|
4492
|
+
if (!cached || cached === "anomalous") {
|
|
4493
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
|
|
4494
|
+
return null;
|
|
4495
|
+
}
|
|
4496
|
+
const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
|
|
4497
|
+
if (!pin) {
|
|
4498
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: NO PERSISTED PIN for owner ${cached.identityId.slice(0, 8)} (cache had ${cached.publicKeyHex.slice(0, 8)})`);
|
|
4499
|
+
return null;
|
|
4500
|
+
}
|
|
4501
|
+
if (pin.publicKeyHex !== cached.publicKeyHex) {
|
|
4502
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: PIN MISMATCH pin=${pin.publicKeyHex.slice(0, 8)} cache=${cached.publicKeyHex.slice(0, 8)}`);
|
|
4503
|
+
return null;
|
|
4504
|
+
}
|
|
4505
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
|
|
4506
|
+
return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
|
|
4507
|
+
}
|
|
2892
4508
|
async reportStatus(channelId, state, detail) {
|
|
2893
4509
|
const joined = await this.reconcileJoin(channelId);
|
|
2894
4510
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
@@ -2923,20 +4539,213 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2923
4539
|
(envelope) => this.transport.publish(envelope)
|
|
2924
4540
|
);
|
|
2925
4541
|
}
|
|
4542
|
+
/** `runId` must exist and not have settled yet — settle() is terminal. */
|
|
4543
|
+
requireActionRun(runId) {
|
|
4544
|
+
const run = this.actionRuns.get(runId);
|
|
4545
|
+
if (!run || run.settled) throw new UnknownActionRunError(runId);
|
|
4546
|
+
return run;
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Build + send one `agent_action` E2E body over `system_event` (mirrors
|
|
4550
|
+
* sendA2A/reportStatus's envelope construction — same self-heal seam). Used
|
|
4551
|
+
* for BOTH partial and settled frames; the caller decides whether a send is
|
|
4552
|
+
* throttle-gated (partials, via flushActionPartial) or unconditional
|
|
4553
|
+
* (settled, always exactly once).
|
|
4554
|
+
*/
|
|
4555
|
+
async sendActionFrame(channelId, body) {
|
|
4556
|
+
const joined = await this.reconcileJoin(channelId);
|
|
4557
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
4558
|
+
await sendWithSelfHeal(
|
|
4559
|
+
this.provider,
|
|
4560
|
+
channelId,
|
|
4561
|
+
(epoch) => buildOutboundEnvelope({
|
|
4562
|
+
custody: this.custody,
|
|
4563
|
+
groupKey: this.provider,
|
|
4564
|
+
messageId: crypto.randomUUID(),
|
|
4565
|
+
channelId,
|
|
4566
|
+
enclaveId: this.cfg.enclaveId,
|
|
4567
|
+
senderId: this.cfg.identityId,
|
|
4568
|
+
senderType: "agent",
|
|
4569
|
+
type: "system_event",
|
|
4570
|
+
bodyPlaintext: JSON.stringify(body),
|
|
4571
|
+
bindingId: "",
|
|
4572
|
+
bindingVersion: "0.0.0",
|
|
4573
|
+
agentSkillId: null,
|
|
4574
|
+
epoch,
|
|
4575
|
+
traceparent: ZERO_TRACEPARENT,
|
|
4576
|
+
references: [],
|
|
4577
|
+
stage5Outcome: "allow",
|
|
4578
|
+
stage5Evaluators: []
|
|
4579
|
+
}),
|
|
4580
|
+
(envelope) => this.transport.publish(envelope)
|
|
4581
|
+
);
|
|
4582
|
+
}
|
|
4583
|
+
/**
|
|
4584
|
+
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
4585
|
+
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
4586
|
+
* no-op too — the change stays in `dirty` and rides along with whatever the
|
|
4587
|
+
* NEXT allowed send captures (a later start/update/finish call, or settle(),
|
|
4588
|
+
* which always flushes regardless of the bucket). This is the coalescing:
|
|
4589
|
+
* a burst of rapid calls collapses into however many sends the ~1/sec floor
|
|
4590
|
+
* actually allows, each carrying every node changed since the last send.
|
|
4591
|
+
*/
|
|
4592
|
+
async flushActionPartial(runId, run) {
|
|
4593
|
+
if (run.dirty.size === 0) return;
|
|
4594
|
+
if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
|
|
4595
|
+
if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
|
|
4596
|
+
const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
|
|
4597
|
+
run.dirty.clear();
|
|
4598
|
+
await this.sendActionFrame(run.channelId, {
|
|
4599
|
+
v: 1,
|
|
4600
|
+
type: "agent_action",
|
|
4601
|
+
agentActionVersion: "1",
|
|
4602
|
+
phase: "partial",
|
|
4603
|
+
runId,
|
|
4604
|
+
nodes
|
|
4605
|
+
});
|
|
4606
|
+
}
|
|
4607
|
+
/** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
|
|
4608
|
+
* (relative to `now`). Lazy — invoked on every actionBegin() rather than
|
|
4609
|
+
* a background timer, mirroring the client store's orphan-TTL pattern. */
|
|
4610
|
+
sweepStaleActionRuns(now) {
|
|
4611
|
+
for (const [runId, run] of this.actionRuns) {
|
|
4612
|
+
if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
actionBegin(channelId) {
|
|
4616
|
+
const now = this.actionClock();
|
|
4617
|
+
this.sweepStaleActionRuns(now);
|
|
4618
|
+
if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
|
|
4619
|
+
let oldestId;
|
|
4620
|
+
let oldestAt = Infinity;
|
|
4621
|
+
for (const [id, r] of this.actionRuns) {
|
|
4622
|
+
if (r.startedAt < oldestAt) {
|
|
4623
|
+
oldestAt = r.startedAt;
|
|
4624
|
+
oldestId = id;
|
|
4625
|
+
}
|
|
4626
|
+
}
|
|
4627
|
+
if (oldestId !== void 0) this.actionRuns.delete(oldestId);
|
|
4628
|
+
}
|
|
4629
|
+
const runId = crypto.randomUUID();
|
|
4630
|
+
this.actionRuns.set(runId, {
|
|
4631
|
+
channelId,
|
|
4632
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
4633
|
+
dirty: /* @__PURE__ */ new Set(),
|
|
4634
|
+
startedAt: now,
|
|
4635
|
+
settled: false
|
|
4636
|
+
});
|
|
4637
|
+
return runId;
|
|
4638
|
+
}
|
|
4639
|
+
async actionStart(opts) {
|
|
4640
|
+
const run = this.requireActionRun(opts.runId);
|
|
4641
|
+
const nodeId = crypto.randomUUID();
|
|
4642
|
+
const node = {
|
|
4643
|
+
id: nodeId,
|
|
4644
|
+
type: opts.type,
|
|
4645
|
+
title: opts.title,
|
|
4646
|
+
status: "running",
|
|
4647
|
+
ts: Date.now(),
|
|
4648
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
4649
|
+
};
|
|
4650
|
+
run.nodes.set(nodeId, node);
|
|
4651
|
+
run.dirty.add(nodeId);
|
|
4652
|
+
await this.flushActionPartial(opts.runId, run);
|
|
4653
|
+
return nodeId;
|
|
4654
|
+
}
|
|
4655
|
+
async actionUpdate(runId, nodeId, patch) {
|
|
4656
|
+
const run = this.requireActionRun(runId);
|
|
4657
|
+
const existing = run.nodes.get(nodeId);
|
|
4658
|
+
if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
|
|
4659
|
+
const updated = {
|
|
4660
|
+
...existing,
|
|
4661
|
+
...patch.status !== void 0 ? { status: patch.status } : {},
|
|
4662
|
+
...patch.detail !== void 0 ? { detail: patch.detail } : {},
|
|
4663
|
+
ts: Date.now()
|
|
4664
|
+
};
|
|
4665
|
+
run.nodes.set(nodeId, updated);
|
|
4666
|
+
run.dirty.add(nodeId);
|
|
4667
|
+
await this.flushActionPartial(runId, run);
|
|
4668
|
+
}
|
|
4669
|
+
async actionFinish(runId, nodeId, patch) {
|
|
4670
|
+
await this.actionUpdate(runId, nodeId, patch);
|
|
4671
|
+
}
|
|
4672
|
+
async actionSettle(runId, opts = {}) {
|
|
4673
|
+
const run = this.requireActionRun(runId);
|
|
4674
|
+
run.settled = true;
|
|
4675
|
+
run.dirty.clear();
|
|
4676
|
+
const nodes = [...run.nodes.values()];
|
|
4677
|
+
await this.sendActionFrame(run.channelId, {
|
|
4678
|
+
v: 1,
|
|
4679
|
+
type: "agent_action",
|
|
4680
|
+
agentActionVersion: "1",
|
|
4681
|
+
phase: "settled",
|
|
4682
|
+
runId,
|
|
4683
|
+
startedAt: run.startedAt,
|
|
4684
|
+
endedAt: Date.now(),
|
|
4685
|
+
...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
|
|
4686
|
+
nodes
|
|
4687
|
+
});
|
|
4688
|
+
this.actionRuns.delete(runId);
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
|
|
4692
|
+
* invocation so a bare agent gets a start→finish node pair without hand-
|
|
4693
|
+
* rolling the begin/start/update/finish calls itself. Success → status
|
|
4694
|
+
* "done"; a thrown error → status "error" with the message captured in
|
|
4695
|
+
* `detail.error`, then the original error is rethrown (wrap never swallows).
|
|
4696
|
+
*/
|
|
4697
|
+
async actionWrap(runId, fn, opts) {
|
|
4698
|
+
const nodeId = await this.actionStart({
|
|
4699
|
+
runId,
|
|
4700
|
+
type: opts.type,
|
|
4701
|
+
title: opts.title,
|
|
4702
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
4703
|
+
});
|
|
4704
|
+
try {
|
|
4705
|
+
const result = await fn();
|
|
4706
|
+
await this.actionFinish(runId, nodeId, { status: "done" });
|
|
4707
|
+
return result;
|
|
4708
|
+
} catch (err) {
|
|
4709
|
+
await this.actionFinish(runId, nodeId, {
|
|
4710
|
+
status: "error",
|
|
4711
|
+
detail: { error: err instanceof Error ? err.message : String(err) }
|
|
4712
|
+
});
|
|
4713
|
+
throw err;
|
|
4714
|
+
}
|
|
4715
|
+
}
|
|
2926
4716
|
startReceivePump() {
|
|
2927
4717
|
const sub = this.transport.subscribeDeliveryPings("");
|
|
2928
4718
|
this.pingHandle = { close: sub.close };
|
|
2929
4719
|
void (async () => {
|
|
2930
4720
|
try {
|
|
2931
|
-
for await (const
|
|
4721
|
+
for await (const ping of sub.pings) {
|
|
4722
|
+
this.pingsReceived++;
|
|
4723
|
+
if (ping?.reconnected) {
|
|
4724
|
+
process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
|
|
4725
|
+
}
|
|
2932
4726
|
await this.ingestOnce();
|
|
2933
4727
|
}
|
|
2934
4728
|
} catch {
|
|
2935
4729
|
}
|
|
4730
|
+
if (!this.closingDown) {
|
|
4731
|
+
process.stderr.write(
|
|
4732
|
+
"[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
|
|
4733
|
+
);
|
|
4734
|
+
}
|
|
2936
4735
|
})();
|
|
2937
4736
|
this.safetyTimer = setInterval(() => {
|
|
2938
4737
|
void this.ingestOnce();
|
|
2939
4738
|
}, 25e3);
|
|
4739
|
+
if (this.selfHealSweepEnabled) {
|
|
4740
|
+
const scheduleSweep = () => {
|
|
4741
|
+
this.selfHealTimer = setTimeout(() => {
|
|
4742
|
+
void this.selfHealSweep();
|
|
4743
|
+
scheduleSweep();
|
|
4744
|
+
}, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
|
|
4745
|
+
this.selfHealTimer.unref?.();
|
|
4746
|
+
};
|
|
4747
|
+
scheduleSweep();
|
|
4748
|
+
}
|
|
2940
4749
|
}
|
|
2941
4750
|
async ingestOnce() {
|
|
2942
4751
|
const cycleStart = performance.now();
|
|
@@ -2951,6 +4760,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2951
4760
|
await this.reconcileJoin(cid);
|
|
2952
4761
|
}
|
|
2953
4762
|
}
|
|
4763
|
+
for (const cid of channelIds) {
|
|
4764
|
+
if (this.freshRoster(cid) === void 0) {
|
|
4765
|
+
await this.refreshChannelRoster(cid);
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
2954
4768
|
for (const row of rows) {
|
|
2955
4769
|
const cid = row.connect_channel_id;
|
|
2956
4770
|
const gid = row.group_id;
|
|
@@ -2961,8 +4775,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2961
4775
|
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
2962
4776
|
this.lastWelcomeRequest.set(cid, now);
|
|
2963
4777
|
try {
|
|
2964
|
-
await this.
|
|
2965
|
-
this.welcomeRequestsSent++;
|
|
4778
|
+
await this.signalHeldKpWelcome(gid);
|
|
2966
4779
|
} catch {
|
|
2967
4780
|
}
|
|
2968
4781
|
}
|
|
@@ -2974,6 +4787,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2974
4787
|
} catch {
|
|
2975
4788
|
return;
|
|
2976
4789
|
}
|
|
4790
|
+
for (const cid of entries.supersededChannelIds ?? []) {
|
|
4791
|
+
this.joinState.set(cid, "JOINED");
|
|
4792
|
+
}
|
|
2977
4793
|
if (!entries.length) return;
|
|
2978
4794
|
let metaRefreshed = false;
|
|
2979
4795
|
if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
|
|
@@ -2995,6 +4811,42 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2995
4811
|
new TextDecoder().decode(m.plaintext)
|
|
2996
4812
|
);
|
|
2997
4813
|
if (parsed.type && parsed.type !== "text") {
|
|
4814
|
+
if (parsed.type === "artifact_share") {
|
|
4815
|
+
try {
|
|
4816
|
+
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
4817
|
+
const senderType = m.envelope.sender_type;
|
|
4818
|
+
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
4819
|
+
const meta = await materializeAttachment(
|
|
4820
|
+
parsed,
|
|
4821
|
+
{ attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
|
|
4822
|
+
{
|
|
4823
|
+
downloadBlob: this.downloadBlobImpl,
|
|
4824
|
+
decryptFile,
|
|
4825
|
+
computeFileDigest,
|
|
4826
|
+
base64ToBytes: base64ToBytes2,
|
|
4827
|
+
mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
|
|
4828
|
+
writeFile: (p, data) => fsp.writeFile(p, data)
|
|
4829
|
+
}
|
|
4830
|
+
);
|
|
4831
|
+
this.buffer.push({
|
|
4832
|
+
channel_id: m.channelId,
|
|
4833
|
+
message_id: m.messageId,
|
|
4834
|
+
sender_id: m.envelope.sender_id,
|
|
4835
|
+
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
4836
|
+
ts: m.receivedAt,
|
|
4837
|
+
sender_name: senderName,
|
|
4838
|
+
sender_is_agent: senderIsAgent,
|
|
4839
|
+
attachments: [meta]
|
|
4840
|
+
});
|
|
4841
|
+
this.capBuffer();
|
|
4842
|
+
} catch (e) {
|
|
4843
|
+
this.failedAttachments++;
|
|
4844
|
+
process.stderr.write(
|
|
4845
|
+
`[bridge] attachment failed on ${m.messageId}: ${e.message}
|
|
4846
|
+
`
|
|
4847
|
+
);
|
|
4848
|
+
}
|
|
4849
|
+
}
|
|
2998
4850
|
acked.push(m.queueId);
|
|
2999
4851
|
continue;
|
|
3000
4852
|
}
|
|
@@ -3119,6 +4971,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3119
4971
|
filtered_room: this.filteredRoomMessages,
|
|
3120
4972
|
rate_limited: this.rateLimitedSends,
|
|
3121
4973
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
4974
|
+
failed_attachments: this.failedAttachments,
|
|
4975
|
+
delivery_pings: this.pingsReceived,
|
|
3122
4976
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
3123
4977
|
version: BRIDGE_VERSION
|
|
3124
4978
|
};
|
|
@@ -3159,12 +5013,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3159
5013
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
3160
5014
|
rateLimitedSends: this.rateLimitedSends,
|
|
3161
5015
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
3162
|
-
welcomeRequestsSent: this.welcomeRequestsSent
|
|
5016
|
+
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
5017
|
+
failedAttachments: this.failedAttachments
|
|
3163
5018
|
};
|
|
3164
5019
|
}
|
|
3165
5020
|
async close() {
|
|
5021
|
+
this.closingDown = true;
|
|
3166
5022
|
this.pingHandle?.close();
|
|
3167
5023
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
5024
|
+
if (this.bootSweepTimer) clearTimeout(this.bootSweepTimer);
|
|
5025
|
+
if (this.selfHealTimer) clearTimeout(this.selfHealTimer);
|
|
5026
|
+
await this.provider.drainInFlight(_BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
3168
5027
|
this.store.close();
|
|
3169
5028
|
}
|
|
3170
5029
|
};
|
|
@@ -3226,6 +5085,7 @@ var BRIDGE_ERRORS = {
|
|
|
3226
5085
|
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
3227
5086
|
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
3228
5087
|
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
5088
|
+
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
3229
5089
|
internal: { http: 500, message: "internal error" }
|
|
3230
5090
|
};
|
|
3231
5091
|
function errorBody(code, detail) {
|
|
@@ -3271,6 +5131,11 @@ var a2aSchema = z.object({
|
|
|
3271
5131
|
kind: z.enum(["message", "task", "artifact"]),
|
|
3272
5132
|
object: z.record(z.unknown())
|
|
3273
5133
|
});
|
|
5134
|
+
var sendAttachmentSchema = z.object({
|
|
5135
|
+
channel_id: z.string().min(1),
|
|
5136
|
+
path: z.string().min(1),
|
|
5137
|
+
caption: z.string().optional()
|
|
5138
|
+
});
|
|
3274
5139
|
var sendSchema = z.object({
|
|
3275
5140
|
channel_id: z.string().min(1),
|
|
3276
5141
|
text: z.string().optional(),
|
|
@@ -3365,6 +5230,12 @@ function createHttpServer(agent, opts) {
|
|
|
3365
5230
|
const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
|
|
3366
5231
|
return json(res, 200, r);
|
|
3367
5232
|
}
|
|
5233
|
+
if (method === "POST" && path === "/v1/send-attachment") {
|
|
5234
|
+
const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
5235
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
5236
|
+
const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
|
|
5237
|
+
return json(res, 200, r);
|
|
5238
|
+
}
|
|
3368
5239
|
if (method === "POST" && path === "/v1/status") {
|
|
3369
5240
|
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
3370
5241
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
@@ -3414,6 +5285,7 @@ function createHttpServer(agent, opts) {
|
|
|
3414
5285
|
}
|
|
3415
5286
|
if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
|
|
3416
5287
|
if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
|
|
5288
|
+
if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
|
|
3417
5289
|
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
3418
5290
|
process.stderr.write(`[bridge] request error: ${e?.message ?? e}
|
|
3419
5291
|
`);
|
|
@@ -3623,26 +5495,99 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
3623
5495
|
}
|
|
3624
5496
|
return;
|
|
3625
5497
|
}
|
|
5498
|
+
const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
|
|
5499
|
+
const NODE_STATUSES = ["pending", "running", "done", "error"];
|
|
5500
|
+
const actionError = (e) => {
|
|
5501
|
+
sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
|
|
5502
|
+
};
|
|
5503
|
+
if (m.type === "action_begin") {
|
|
5504
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
5505
|
+
sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
|
|
5506
|
+
return;
|
|
5507
|
+
}
|
|
5508
|
+
try {
|
|
5509
|
+
sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
|
|
5510
|
+
} catch (e) {
|
|
5511
|
+
actionError(e);
|
|
5512
|
+
}
|
|
5513
|
+
return;
|
|
5514
|
+
}
|
|
5515
|
+
if (m.type === "action_start") {
|
|
5516
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
|
|
5517
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
try {
|
|
5521
|
+
const nodeId = await agent.action.start({
|
|
5522
|
+
runId: m.run_id,
|
|
5523
|
+
type: m.node_type,
|
|
5524
|
+
title: m.title,
|
|
5525
|
+
parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
|
|
5526
|
+
});
|
|
5527
|
+
sendJson(sock, { type: "action_started", node_id: nodeId });
|
|
5528
|
+
} catch (e) {
|
|
5529
|
+
actionError(e);
|
|
5530
|
+
}
|
|
5531
|
+
return;
|
|
5532
|
+
}
|
|
5533
|
+
if (m.type === "action_update" || m.type === "action_finish") {
|
|
5534
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
|
|
5535
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
|
|
5536
|
+
return;
|
|
5537
|
+
}
|
|
5538
|
+
try {
|
|
5539
|
+
if (m.type === "action_finish") {
|
|
5540
|
+
if (m.status !== "done" && m.status !== "error") {
|
|
5541
|
+
sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5544
|
+
await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
5545
|
+
} else {
|
|
5546
|
+
if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
|
|
5547
|
+
sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
|
|
5548
|
+
return;
|
|
5549
|
+
}
|
|
5550
|
+
await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
5551
|
+
}
|
|
5552
|
+
sendJson(sock, { type: "ok" });
|
|
5553
|
+
} catch (e) {
|
|
5554
|
+
actionError(e);
|
|
5555
|
+
}
|
|
5556
|
+
return;
|
|
5557
|
+
}
|
|
5558
|
+
if (m.type === "action_settle") {
|
|
5559
|
+
if (typeof m.run_id !== "string" || !m.run_id) {
|
|
5560
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
|
|
5561
|
+
return;
|
|
5562
|
+
}
|
|
5563
|
+
try {
|
|
5564
|
+
await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
|
|
5565
|
+
sendJson(sock, { type: "action_settled", run_id: m.run_id });
|
|
5566
|
+
} catch (e) {
|
|
5567
|
+
actionError(e);
|
|
5568
|
+
}
|
|
5569
|
+
return;
|
|
5570
|
+
}
|
|
3626
5571
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3627
5572
|
}
|
|
3628
5573
|
|
|
3629
5574
|
// src/bin.ts
|
|
3630
5575
|
import { readFileSync as readFileSync3, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
|
|
3631
|
-
import { join as
|
|
5576
|
+
import { join as join4, dirname as dirname5 } from "node:path";
|
|
3632
5577
|
import { fileURLToPath } from "node:url";
|
|
3633
5578
|
import { homedir, userInfo } from "node:os";
|
|
3634
5579
|
import { spawnSync } from "node:child_process";
|
|
3635
5580
|
|
|
3636
5581
|
// src/service/install.ts
|
|
3637
|
-
import { dirname as
|
|
5582
|
+
import { dirname as dirname3 } from "node:path";
|
|
3638
5583
|
|
|
3639
5584
|
// src/service/config.ts
|
|
3640
|
-
import { basename, extname, isAbsolute, resolve } from "node:path";
|
|
5585
|
+
import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
|
|
3641
5586
|
function slugify(s) {
|
|
3642
5587
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
3643
5588
|
}
|
|
3644
5589
|
function deriveServiceConfig(input) {
|
|
3645
|
-
const stem = input.label ??
|
|
5590
|
+
const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
|
|
3646
5591
|
const slug = slugify(stem);
|
|
3647
5592
|
const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
|
|
3648
5593
|
const args2 = ["--credential", cred];
|
|
@@ -3718,7 +5663,7 @@ WantedBy=default.target
|
|
|
3718
5663
|
var ServiceUnsupportedError = class extends Error {
|
|
3719
5664
|
};
|
|
3720
5665
|
function tokenHint(input) {
|
|
3721
|
-
return `${
|
|
5666
|
+
return `${dirname3(input.credentialPath)}/bridge-token`;
|
|
3722
5667
|
}
|
|
3723
5668
|
function requireOk(r, what) {
|
|
3724
5669
|
if (r.status !== 0) throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
@@ -3726,8 +5671,8 @@ function requireOk(r, what) {
|
|
|
3726
5671
|
function installService(input, deps) {
|
|
3727
5672
|
const cfg = deriveServiceConfig(input);
|
|
3728
5673
|
if (deps.platform === "darwin") {
|
|
3729
|
-
deps.mkdirp(
|
|
3730
|
-
deps.mkdirp(
|
|
5674
|
+
deps.mkdirp(dirname3(cfg.plistPath));
|
|
5675
|
+
deps.mkdirp(dirname3(cfg.logPath));
|
|
3731
5676
|
deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
|
|
3732
5677
|
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
3733
5678
|
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
@@ -3736,7 +5681,7 @@ function installService(input, deps) {
|
|
|
3736
5681
|
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
|
|
3737
5682
|
}
|
|
3738
5683
|
if (deps.platform === "linux") {
|
|
3739
|
-
deps.mkdirp(
|
|
5684
|
+
deps.mkdirp(dirname3(cfg.unitPath));
|
|
3740
5685
|
deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
|
|
3741
5686
|
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
3742
5687
|
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
@@ -3764,7 +5709,7 @@ function uninstallService(input, deps) {
|
|
|
3764
5709
|
}
|
|
3765
5710
|
|
|
3766
5711
|
// src/service/resolve-bin.ts
|
|
3767
|
-
import { join as
|
|
5712
|
+
import { join as join3, dirname as dirname4 } from "node:path";
|
|
3768
5713
|
import { createRequire } from "node:module";
|
|
3769
5714
|
var GlobalInstallPermissionError = class extends Error {
|
|
3770
5715
|
constructor(message) {
|
|
@@ -3789,7 +5734,7 @@ function resolveStableBinScript(input, deps) {
|
|
|
3789
5734
|
if (deps.fileExists(input.binScript)) return input.binScript;
|
|
3790
5735
|
throw new Error(`bin path does not exist and is not an npx cache path: ${input.binScript}`);
|
|
3791
5736
|
}
|
|
3792
|
-
const pkgRoot =
|
|
5737
|
+
const pkgRoot = dirname4(dirname4(input.binScript));
|
|
3793
5738
|
const inst = deps.run("npm", ["i", "-g", pkgRoot, "--install-links"]);
|
|
3794
5739
|
if (inst.status !== 0) {
|
|
3795
5740
|
if (/EACCES|permission denied/i.test(inst.stderr)) {
|
|
@@ -3802,7 +5747,7 @@ function resolveStableBinScript(input, deps) {
|
|
|
3802
5747
|
}
|
|
3803
5748
|
const root = deps.run("npm", ["root", "-g"]);
|
|
3804
5749
|
if (root.status !== 0) throw new Error(`npm root -g failed (exit ${root.status})`);
|
|
3805
|
-
const stable =
|
|
5750
|
+
const stable = join3(root.stdout.trim(), input.packageName, "dist", "bin.js");
|
|
3806
5751
|
if (!deps.fileExists(stable)) throw new Error(`global install did not produce ${stable}`);
|
|
3807
5752
|
return stable;
|
|
3808
5753
|
}
|
|
@@ -3816,6 +5761,10 @@ function loadPackageMeta(importMetaUrl) {
|
|
|
3816
5761
|
function nodeMajorOk(version) {
|
|
3817
5762
|
return Number(version.split(".")[0]) >= 24;
|
|
3818
5763
|
}
|
|
5764
|
+
function tokenFingerprint(token) {
|
|
5765
|
+
const prefixLen = Math.min(6, token.length);
|
|
5766
|
+
return `${token.slice(0, prefixLen)}... (${token.length} chars)`;
|
|
5767
|
+
}
|
|
3819
5768
|
function argOrEnv(flag, env) {
|
|
3820
5769
|
const i = process.argv.indexOf(flag);
|
|
3821
5770
|
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
|
|
@@ -3921,7 +5870,7 @@ async function main() {
|
|
|
3921
5870
|
`);
|
|
3922
5871
|
process.exit(1);
|
|
3923
5872
|
}
|
|
3924
|
-
const tokenFile = daemon.tokenFile ??
|
|
5873
|
+
const tokenFile = daemon.tokenFile ?? join4(dirname5(credentialPath), "bridge-token");
|
|
3925
5874
|
const token = resolveToken({ tokenFile, token: daemon.token });
|
|
3926
5875
|
const agent = new BridgeAgent(cfg);
|
|
3927
5876
|
await agent.start();
|
|
@@ -3935,7 +5884,12 @@ async function main() {
|
|
|
3935
5884
|
agentWs.close();
|
|
3936
5885
|
server.close();
|
|
3937
5886
|
closeAllSseStreams();
|
|
3938
|
-
|
|
5887
|
+
const hard = setTimeout(() => process.exit(0), 8e3);
|
|
5888
|
+
hard.unref?.();
|
|
5889
|
+
void agent.close().finally(() => {
|
|
5890
|
+
clearTimeout(hard);
|
|
5891
|
+
process.exit(0);
|
|
5892
|
+
});
|
|
3939
5893
|
};
|
|
3940
5894
|
process.on("SIGINT", shutdown);
|
|
3941
5895
|
process.on("SIGTERM", shutdown);
|
|
@@ -3944,7 +5898,7 @@ async function main() {
|
|
|
3944
5898
|
const wsScheme = tls ? "wss" : "ws";
|
|
3945
5899
|
process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
|
|
3946
5900
|
`);
|
|
3947
|
-
process.stderr.write(`
|
|
5901
|
+
process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
|
|
3948
5902
|
`);
|
|
3949
5903
|
});
|
|
3950
5904
|
}
|
|
@@ -3959,5 +5913,6 @@ if (isMain) {
|
|
|
3959
5913
|
}
|
|
3960
5914
|
export {
|
|
3961
5915
|
nodeMajorOk,
|
|
3962
|
-
parseVerb
|
|
5916
|
+
parseVerb,
|
|
5917
|
+
tokenFingerprint
|
|
3963
5918
|
};
|