@securecoms/agent-bridge 0.2.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/dist/bin.d.ts +10 -0
- package/dist/bin.js +1804 -64
- 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 +343 -0
- package/dist/decrypt-error-log-limiter.d.ts +52 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +1791 -61
- 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
|
}
|
|
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
|
+
}
|
|
2238
3029
|
async fetchKeyPackages(deviceIds) {
|
|
2239
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();
|
|
@@ -2325,11 +3160,42 @@ function openSchema(db) {
|
|
|
2325
3160
|
sealed_main BLOB NOT NULL,
|
|
2326
3161
|
sealed_staging BLOB,
|
|
2327
3162
|
updated_at INTEGER NOT NULL,
|
|
2328
|
-
PRIMARY KEY (identity_id, channel_id)
|
|
3163
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
3164
|
+
);
|
|
3165
|
+
`);
|
|
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)
|
|
2329
3195
|
);
|
|
2330
3196
|
`);
|
|
2331
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2332
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) {
|
|
@@ -2591,6 +3545,80 @@ var TokenBucketFloor = class {
|
|
|
2591
3545
|
}
|
|
2592
3546
|
};
|
|
2593
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
|
+
|
|
2594
3622
|
// src/attachments.ts
|
|
2595
3623
|
import { basename } from "node:path";
|
|
2596
3624
|
function sanitizeFilename(name, fallback) {
|
|
@@ -2687,16 +3715,40 @@ var InvalidPathError = class extends Error {
|
|
|
2687
3715
|
if (cause !== void 0) this.cause = cause;
|
|
2688
3716
|
}
|
|
2689
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
|
+
};
|
|
2690
3732
|
var BRIDGE_VERSION = "0.0.0";
|
|
2691
|
-
function
|
|
3733
|
+
function hexToBytes4(h) {
|
|
2692
3734
|
return new Uint8Array(Buffer.from(h, "hex"));
|
|
2693
3735
|
}
|
|
2694
3736
|
var BridgeAgent = class _BridgeAgent {
|
|
2695
3737
|
constructor(cfg, deps = {}) {
|
|
2696
3738
|
this.cfg = cfg;
|
|
2697
|
-
const seed =
|
|
3739
|
+
const seed = hexToBytes4(cfg.signingPrivateKey);
|
|
2698
3740
|
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
2699
|
-
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
|
+
});
|
|
2700
3752
|
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
2701
3753
|
this.store = new SqliteMlsStateStore({
|
|
2702
3754
|
dbPath: cfg.storePath,
|
|
@@ -2711,12 +3763,47 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2711
3763
|
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
2712
3764
|
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
2713
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.
|
|
2714
3773
|
onDecryptError: (row, err) => {
|
|
2715
3774
|
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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)
|
|
2720
3807
|
});
|
|
2721
3808
|
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
2722
3809
|
this.latency = deps.latency ?? new LatencyRecorder();
|
|
@@ -2725,6 +3812,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2725
3812
|
capacity: cfg.rateLimitCapacity ?? 20,
|
|
2726
3813
|
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
2727
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 });
|
|
2728
3819
|
const self = this;
|
|
2729
3820
|
this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
|
|
2730
3821
|
const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
@@ -2744,6 +3835,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2744
3835
|
return String(j.attachment_id);
|
|
2745
3836
|
};
|
|
2746
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
|
+
};
|
|
2747
3846
|
}
|
|
2748
3847
|
transport;
|
|
2749
3848
|
provider;
|
|
@@ -2773,6 +3872,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2773
3872
|
* per channel on a miss.
|
|
2774
3873
|
*/
|
|
2775
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();
|
|
2776
3884
|
buffer = [];
|
|
2777
3885
|
maxBuffer;
|
|
2778
3886
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
@@ -2787,6 +3895,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2787
3895
|
rateLimitedSends = 0;
|
|
2788
3896
|
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
2789
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;
|
|
2790
3905
|
/**
|
|
2791
3906
|
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
2792
3907
|
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
@@ -2797,7 +3912,94 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2797
3912
|
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
2798
3913
|
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
2799
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;
|
|
2800
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;
|
|
2801
4003
|
waiters = [];
|
|
2802
4004
|
pingHandle;
|
|
2803
4005
|
safetyTimer;
|
|
@@ -2834,6 +4036,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2834
4036
|
this.startedAt = Date.now();
|
|
2835
4037
|
await this.provider.publishKeyPackage();
|
|
2836
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);
|
|
2837
4047
|
}
|
|
2838
4048
|
async reconcileJoin(channelId) {
|
|
2839
4049
|
if (this.joinState.get(channelId) === "JOINED") return true;
|
|
@@ -2846,6 +4056,145 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2846
4056
|
return false;
|
|
2847
4057
|
}
|
|
2848
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
|
+
}
|
|
2849
4198
|
/**
|
|
2850
4199
|
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
2851
4200
|
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
@@ -2858,6 +4207,40 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2858
4207
|
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
2859
4208
|
}
|
|
2860
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
|
+
}
|
|
2861
4244
|
async send(channelId, text, messageId) {
|
|
2862
4245
|
const meta = this.freshMeta(channelId);
|
|
2863
4246
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
@@ -2865,7 +4248,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2865
4248
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2866
4249
|
const id = messageId ?? crypto.randomUUID();
|
|
2867
4250
|
const sendStart = performance.now();
|
|
2868
|
-
await
|
|
4251
|
+
await this.sendWithSelfHealTerminal(
|
|
2869
4252
|
this.provider,
|
|
2870
4253
|
channelId,
|
|
2871
4254
|
(epoch) => this.latency.time(
|
|
@@ -2901,7 +4284,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2901
4284
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2902
4285
|
const id = messageId ?? crypto.randomUUID();
|
|
2903
4286
|
const sendStart = performance.now();
|
|
2904
|
-
await
|
|
4287
|
+
await this.sendWithSelfHealTerminal(
|
|
2905
4288
|
this.provider,
|
|
2906
4289
|
channelId,
|
|
2907
4290
|
(epoch) => this.latency.time(
|
|
@@ -2954,7 +4337,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2954
4337
|
}
|
|
2955
4338
|
const id = crypto.randomUUID();
|
|
2956
4339
|
const sendStart = performance.now();
|
|
2957
|
-
await
|
|
4340
|
+
await this.sendWithSelfHealTerminal(
|
|
2958
4341
|
this.provider,
|
|
2959
4342
|
channelId,
|
|
2960
4343
|
(epoch) => this.latency.time(
|
|
@@ -3051,9 +4434,77 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3051
4434
|
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
3052
4435
|
}
|
|
3053
4436
|
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
4437
|
+
await this.resolveAndPinOwner(channelId, rows);
|
|
4438
|
+
} catch {
|
|
4439
|
+
}
|
|
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
|
+
}
|
|
3054
4478
|
} catch {
|
|
3055
4479
|
}
|
|
3056
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
|
+
}
|
|
3057
4508
|
async reportStatus(channelId, state, detail) {
|
|
3058
4509
|
const joined = await this.reconcileJoin(channelId);
|
|
3059
4510
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
@@ -3088,20 +4539,213 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3088
4539
|
(envelope) => this.transport.publish(envelope)
|
|
3089
4540
|
);
|
|
3090
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
|
+
}
|
|
3091
4716
|
startReceivePump() {
|
|
3092
4717
|
const sub = this.transport.subscribeDeliveryPings("");
|
|
3093
4718
|
this.pingHandle = { close: sub.close };
|
|
3094
4719
|
void (async () => {
|
|
3095
4720
|
try {
|
|
3096
|
-
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
|
+
}
|
|
3097
4726
|
await this.ingestOnce();
|
|
3098
4727
|
}
|
|
3099
4728
|
} catch {
|
|
3100
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
|
+
}
|
|
3101
4735
|
})();
|
|
3102
4736
|
this.safetyTimer = setInterval(() => {
|
|
3103
4737
|
void this.ingestOnce();
|
|
3104
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
|
+
}
|
|
3105
4749
|
}
|
|
3106
4750
|
async ingestOnce() {
|
|
3107
4751
|
const cycleStart = performance.now();
|
|
@@ -3116,6 +4760,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3116
4760
|
await this.reconcileJoin(cid);
|
|
3117
4761
|
}
|
|
3118
4762
|
}
|
|
4763
|
+
for (const cid of channelIds) {
|
|
4764
|
+
if (this.freshRoster(cid) === void 0) {
|
|
4765
|
+
await this.refreshChannelRoster(cid);
|
|
4766
|
+
}
|
|
4767
|
+
}
|
|
3119
4768
|
for (const row of rows) {
|
|
3120
4769
|
const cid = row.connect_channel_id;
|
|
3121
4770
|
const gid = row.group_id;
|
|
@@ -3126,8 +4775,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3126
4775
|
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
3127
4776
|
this.lastWelcomeRequest.set(cid, now);
|
|
3128
4777
|
try {
|
|
3129
|
-
await this.
|
|
3130
|
-
this.welcomeRequestsSent++;
|
|
4778
|
+
await this.signalHeldKpWelcome(gid);
|
|
3131
4779
|
} catch {
|
|
3132
4780
|
}
|
|
3133
4781
|
}
|
|
@@ -3139,6 +4787,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3139
4787
|
} catch {
|
|
3140
4788
|
return;
|
|
3141
4789
|
}
|
|
4790
|
+
for (const cid of entries.supersededChannelIds ?? []) {
|
|
4791
|
+
this.joinState.set(cid, "JOINED");
|
|
4792
|
+
}
|
|
3142
4793
|
if (!entries.length) return;
|
|
3143
4794
|
let metaRefreshed = false;
|
|
3144
4795
|
if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
|
|
@@ -3321,6 +4972,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3321
4972
|
rate_limited: this.rateLimitedSends,
|
|
3322
4973
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
3323
4974
|
failed_attachments: this.failedAttachments,
|
|
4975
|
+
delivery_pings: this.pingsReceived,
|
|
3324
4976
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
3325
4977
|
version: BRIDGE_VERSION
|
|
3326
4978
|
};
|
|
@@ -3366,8 +5018,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3366
5018
|
};
|
|
3367
5019
|
}
|
|
3368
5020
|
async close() {
|
|
5021
|
+
this.closingDown = true;
|
|
3369
5022
|
this.pingHandle?.close();
|
|
3370
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);
|
|
3371
5027
|
this.store.close();
|
|
3372
5028
|
}
|
|
3373
5029
|
};
|
|
@@ -3429,6 +5085,7 @@ var BRIDGE_ERRORS = {
|
|
|
3429
5085
|
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
3430
5086
|
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
3431
5087
|
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
5088
|
+
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
3432
5089
|
internal: { http: 500, message: "internal error" }
|
|
3433
5090
|
};
|
|
3434
5091
|
function errorBody(code, detail) {
|
|
@@ -3838,6 +5495,79 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
3838
5495
|
}
|
|
3839
5496
|
return;
|
|
3840
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
|
+
}
|
|
3841
5571
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3842
5572
|
}
|
|
3843
5573
|
|
|
@@ -4031,6 +5761,10 @@ function loadPackageMeta(importMetaUrl) {
|
|
|
4031
5761
|
function nodeMajorOk(version) {
|
|
4032
5762
|
return Number(version.split(".")[0]) >= 24;
|
|
4033
5763
|
}
|
|
5764
|
+
function tokenFingerprint(token) {
|
|
5765
|
+
const prefixLen = Math.min(6, token.length);
|
|
5766
|
+
return `${token.slice(0, prefixLen)}... (${token.length} chars)`;
|
|
5767
|
+
}
|
|
4034
5768
|
function argOrEnv(flag, env) {
|
|
4035
5769
|
const i = process.argv.indexOf(flag);
|
|
4036
5770
|
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
|
|
@@ -4150,7 +5884,12 @@ async function main() {
|
|
|
4150
5884
|
agentWs.close();
|
|
4151
5885
|
server.close();
|
|
4152
5886
|
closeAllSseStreams();
|
|
4153
|
-
|
|
5887
|
+
const hard = setTimeout(() => process.exit(0), 8e3);
|
|
5888
|
+
hard.unref?.();
|
|
5889
|
+
void agent.close().finally(() => {
|
|
5890
|
+
clearTimeout(hard);
|
|
5891
|
+
process.exit(0);
|
|
5892
|
+
});
|
|
4154
5893
|
};
|
|
4155
5894
|
process.on("SIGINT", shutdown);
|
|
4156
5895
|
process.on("SIGTERM", shutdown);
|
|
@@ -4159,7 +5898,7 @@ async function main() {
|
|
|
4159
5898
|
const wsScheme = tls ? "wss" : "ws";
|
|
4160
5899
|
process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
|
|
4161
5900
|
`);
|
|
4162
|
-
process.stderr.write(`
|
|
5901
|
+
process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
|
|
4163
5902
|
`);
|
|
4164
5903
|
});
|
|
4165
5904
|
}
|
|
@@ -4174,5 +5913,6 @@ if (isMain) {
|
|
|
4174
5913
|
}
|
|
4175
5914
|
export {
|
|
4176
5915
|
nodeMajorOk,
|
|
4177
|
-
parseVerb
|
|
5916
|
+
parseVerb,
|
|
5917
|
+
tokenFingerprint
|
|
4178
5918
|
};
|