@securecoms/agent-bridge 0.2.0 → 0.3.1
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 +3 -2
package/dist/index.js
CHANGED
|
@@ -430,6 +430,144 @@ var payloadsSchema = {
|
|
|
430
430
|
"type": "object"
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
|
+
},
|
|
434
|
+
"agent_action": {
|
|
435
|
+
"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.',
|
|
436
|
+
"$defs": {
|
|
437
|
+
"node": {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"required": [
|
|
440
|
+
"id",
|
|
441
|
+
"type",
|
|
442
|
+
"title",
|
|
443
|
+
"status",
|
|
444
|
+
"ts"
|
|
445
|
+
],
|
|
446
|
+
"properties": {
|
|
447
|
+
"id": {
|
|
448
|
+
"type": "string"
|
|
449
|
+
},
|
|
450
|
+
"parentId": {
|
|
451
|
+
"type": "string"
|
|
452
|
+
},
|
|
453
|
+
"type": {
|
|
454
|
+
"type": "string",
|
|
455
|
+
"enum": [
|
|
456
|
+
"step",
|
|
457
|
+
"tool_call",
|
|
458
|
+
"sub_task",
|
|
459
|
+
"result"
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
"title": {
|
|
463
|
+
"type": "string"
|
|
464
|
+
},
|
|
465
|
+
"status": {
|
|
466
|
+
"type": "string",
|
|
467
|
+
"enum": [
|
|
468
|
+
"pending",
|
|
469
|
+
"running",
|
|
470
|
+
"done",
|
|
471
|
+
"error"
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
"detail": {
|
|
475
|
+
"type": "object",
|
|
476
|
+
"properties": {
|
|
477
|
+
"args": {},
|
|
478
|
+
"output": {},
|
|
479
|
+
"error": {
|
|
480
|
+
"type": "string"
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
"ts": {
|
|
485
|
+
"type": "number"
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
"oneOf": [
|
|
491
|
+
{
|
|
492
|
+
"type": "object",
|
|
493
|
+
"required": [
|
|
494
|
+
"v",
|
|
495
|
+
"type",
|
|
496
|
+
"phase",
|
|
497
|
+
"agentActionVersion",
|
|
498
|
+
"runId",
|
|
499
|
+
"nodes"
|
|
500
|
+
],
|
|
501
|
+
"properties": {
|
|
502
|
+
"v": {
|
|
503
|
+
"const": 1
|
|
504
|
+
},
|
|
505
|
+
"type": {
|
|
506
|
+
"const": "agent_action"
|
|
507
|
+
},
|
|
508
|
+
"phase": {
|
|
509
|
+
"const": "partial"
|
|
510
|
+
},
|
|
511
|
+
"agentActionVersion": {
|
|
512
|
+
"const": "1"
|
|
513
|
+
},
|
|
514
|
+
"runId": {
|
|
515
|
+
"type": "string"
|
|
516
|
+
},
|
|
517
|
+
"nodes": {
|
|
518
|
+
"type": "array",
|
|
519
|
+
"items": {
|
|
520
|
+
"$ref": "#/$defs/node"
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
"type": "object",
|
|
527
|
+
"required": [
|
|
528
|
+
"v",
|
|
529
|
+
"type",
|
|
530
|
+
"phase",
|
|
531
|
+
"agentActionVersion",
|
|
532
|
+
"runId",
|
|
533
|
+
"startedAt",
|
|
534
|
+
"endedAt",
|
|
535
|
+
"nodes"
|
|
536
|
+
],
|
|
537
|
+
"properties": {
|
|
538
|
+
"v": {
|
|
539
|
+
"const": 1
|
|
540
|
+
},
|
|
541
|
+
"type": {
|
|
542
|
+
"const": "agent_action"
|
|
543
|
+
},
|
|
544
|
+
"phase": {
|
|
545
|
+
"const": "settled"
|
|
546
|
+
},
|
|
547
|
+
"agentActionVersion": {
|
|
548
|
+
"const": "1"
|
|
549
|
+
},
|
|
550
|
+
"runId": {
|
|
551
|
+
"type": "string"
|
|
552
|
+
},
|
|
553
|
+
"startedAt": {
|
|
554
|
+
"type": "number"
|
|
555
|
+
},
|
|
556
|
+
"endedAt": {
|
|
557
|
+
"type": "number"
|
|
558
|
+
},
|
|
559
|
+
"resultMessageId": {
|
|
560
|
+
"type": "string"
|
|
561
|
+
},
|
|
562
|
+
"nodes": {
|
|
563
|
+
"type": "array",
|
|
564
|
+
"items": {
|
|
565
|
+
"$ref": "#/$defs/node"
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
]
|
|
433
571
|
}
|
|
434
572
|
}
|
|
435
573
|
};
|
|
@@ -449,6 +587,11 @@ for (const t of MESSAGE_TYPES) {
|
|
|
449
587
|
}
|
|
450
588
|
payloadValidators.set(t, ajv.compile(def));
|
|
451
589
|
}
|
|
590
|
+
var agentActionDef = payloadDefs["agent_action"];
|
|
591
|
+
if (!agentActionDef) {
|
|
592
|
+
throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
|
|
593
|
+
}
|
|
594
|
+
var agentActionValidator = ajv.compile(agentActionDef);
|
|
452
595
|
|
|
453
596
|
// ../connect-core/dist/envelope/sign.js
|
|
454
597
|
import { canonicalize } from "@agentvault/crypto/canonical";
|
|
@@ -781,6 +924,24 @@ async function staleEpochErrorFromResponse(res) {
|
|
|
781
924
|
return null;
|
|
782
925
|
return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
|
|
783
926
|
}
|
|
927
|
+
var AgentPausedError = class extends ConnectSdkError {
|
|
928
|
+
constructor() {
|
|
929
|
+
super("agent_paused: sending is paused for this agent in this room");
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
async function agentPausedErrorFromResponse(res) {
|
|
933
|
+
if (res.status !== 403)
|
|
934
|
+
return null;
|
|
935
|
+
let body;
|
|
936
|
+
try {
|
|
937
|
+
body = await res.json();
|
|
938
|
+
} catch {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
const detail = body?.detail;
|
|
942
|
+
const err = detail?.error;
|
|
943
|
+
return err === "agent_paused" ? new AgentPausedError() : null;
|
|
944
|
+
}
|
|
784
945
|
|
|
785
946
|
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
786
947
|
async function buildOutboundEnvelope(p) {
|
|
@@ -885,7 +1046,7 @@ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
|
|
|
885
1046
|
var KNOWN = new Set(Object.values(SPAN));
|
|
886
1047
|
|
|
887
1048
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
888
|
-
import { MLSGroupManager, bytesToHex, hexToBytes } from "@agentvault/crypto";
|
|
1049
|
+
import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
889
1050
|
|
|
890
1051
|
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
891
1052
|
var MlsOperationQueue = class {
|
|
@@ -914,11 +1075,27 @@ var MlsOperationQueue = class {
|
|
|
914
1075
|
chainSize(channelId) {
|
|
915
1076
|
return this.inFlight.get(channelId) ?? 0;
|
|
916
1077
|
}
|
|
1078
|
+
/** Number of channels with an in-flight or queued op. 0 == fully idle. */
|
|
1079
|
+
get activeChannels() {
|
|
1080
|
+
return this.chains.size;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* H1 graceful-drain barrier: resolve once every op currently chained (in-flight
|
|
1084
|
+
* + already queued) has SETTLED. Ops enqueued AFTER this call are not awaited —
|
|
1085
|
+
* the caller must stop accepting new work first (e.g. close the ping stream).
|
|
1086
|
+
* Uses allSettled so a rejected op can't make the drain reject. Lets a graceful
|
|
1087
|
+
* shutdown wait for an in-flight commit/join/ack to finish before the DB handle
|
|
1088
|
+
* is released, so a SIGTERM/`kickstart -k` can't tear MLS state mid-operation.
|
|
1089
|
+
*/
|
|
1090
|
+
async idle() {
|
|
1091
|
+
await Promise.allSettled([...this.chains.values()]);
|
|
1092
|
+
}
|
|
917
1093
|
};
|
|
918
1094
|
|
|
919
1095
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
920
1096
|
var LABEL = "agentvault-connect-v1";
|
|
921
1097
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
1098
|
+
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
922
1099
|
var BootstrapRaceLostError = class extends Error {
|
|
923
1100
|
constructor() {
|
|
924
1101
|
super("bootstrap race lost: another device created the group; join instead");
|
|
@@ -938,10 +1115,24 @@ var HealAddAfterRemoveError = class extends Error {
|
|
|
938
1115
|
this.cause = cause;
|
|
939
1116
|
}
|
|
940
1117
|
};
|
|
1118
|
+
var GuestHealStaleKpError = class extends Error {
|
|
1119
|
+
constructor(kpId) {
|
|
1120
|
+
super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
|
|
1121
|
+
this.name = "GuestHealStaleKpError";
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
var ForgedCredentialError = class extends Error {
|
|
1125
|
+
constructor(expected, actual) {
|
|
1126
|
+
super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
|
|
1127
|
+
this.name = "ForgedCredentialError";
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
941
1130
|
var MlsGroupKeyProvider = class {
|
|
942
1131
|
o;
|
|
943
1132
|
suite;
|
|
944
1133
|
managers = /* @__PURE__ */ new Map();
|
|
1134
|
+
/** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
|
|
1135
|
+
cryptoWarmed = false;
|
|
945
1136
|
/**
|
|
946
1137
|
* Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
|
|
947
1138
|
* manager-mutating operation on a channel takes this lock keyed by channelId,
|
|
@@ -962,6 +1153,12 @@ var MlsGroupKeyProvider = class {
|
|
|
962
1153
|
* holds that channel's lock).
|
|
963
1154
|
*/
|
|
964
1155
|
locks = new MlsOperationQueue();
|
|
1156
|
+
/** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
|
|
1157
|
+
* guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
|
|
1158
|
+
* both pass "no pending bundle", both POST, and orphan the first bundle when the
|
|
1159
|
+
* server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
|
|
1160
|
+
* from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
|
|
1161
|
+
publishLock = new MlsOperationQueue();
|
|
965
1162
|
/**
|
|
966
1163
|
* Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
|
|
967
1164
|
* of channels this device touches (channels are bounded; an entry is created
|
|
@@ -971,14 +1168,22 @@ var MlsGroupKeyProvider = class {
|
|
|
971
1168
|
*/
|
|
972
1169
|
errors = /* @__PURE__ */ new Map();
|
|
973
1170
|
stuckThreshold;
|
|
1171
|
+
/** Consecutive-error threshold at which a wedged channel's row is quarantined
|
|
1172
|
+
* (ack-drained). See {@link MlsGroupKeyProviderOpts.quarantineThreshold}. */
|
|
1173
|
+
quarantineThreshold;
|
|
974
1174
|
/** Cumulative count of past-epoch application rows permanently skipped (acked
|
|
975
1175
|
* and drained) because forward-secrecy has deleted their epoch's secrets. Read
|
|
976
1176
|
* via {@link permanentlySkippedCount}. */
|
|
977
1177
|
permanentlySkipped = 0;
|
|
1178
|
+
/** Cumulative count of rows ack-drained via QUARANTINE (P0a), as distinct from
|
|
1179
|
+
* the past-epoch {@link permanentlySkipped} drain. Read via
|
|
1180
|
+
* {@link quarantineDrainedCount}. */
|
|
1181
|
+
quarantineDrained = 0;
|
|
978
1182
|
constructor(o) {
|
|
979
1183
|
this.o = o;
|
|
980
1184
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
981
1185
|
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
1186
|
+
this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
|
|
982
1187
|
}
|
|
983
1188
|
get deviceId() {
|
|
984
1189
|
return this.o.deviceId;
|
|
@@ -996,7 +1201,14 @@ var MlsGroupKeyProvider = class {
|
|
|
996
1201
|
const cid = row.connect_channel_id;
|
|
997
1202
|
if (!cid)
|
|
998
1203
|
return;
|
|
999
|
-
const e = this.errors.get(cid) ?? {
|
|
1204
|
+
const e = this.errors.get(cid) ?? {
|
|
1205
|
+
decryptErrors: 0,
|
|
1206
|
+
consecutiveErrors: 0,
|
|
1207
|
+
lastErrorAt: null,
|
|
1208
|
+
quarantined: false,
|
|
1209
|
+
wedgedRun: 0,
|
|
1210
|
+
wedgedSignalled: false
|
|
1211
|
+
};
|
|
1000
1212
|
e.decryptErrors += 1;
|
|
1001
1213
|
e.consecutiveErrors += 1;
|
|
1002
1214
|
e.lastErrorAt = Date.now();
|
|
@@ -1006,12 +1218,42 @@ var MlsGroupKeyProvider = class {
|
|
|
1006
1218
|
* A commit/application row for the channel decrypted cleanly: the channel is
|
|
1007
1219
|
* advancing, so clear the consecutive-error run (un-sticks it). Leaves the
|
|
1008
1220
|
* cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
|
|
1009
|
-
* that never errored.
|
|
1221
|
+
* that never errored. Also clears QUARANTINE state (P0a): a healed/rejoined
|
|
1222
|
+
* channel resumes normal processing and can quarantine again later if it
|
|
1223
|
+
* re-wedges (the onWedged guard is reset so the hook can fire again).
|
|
1010
1224
|
*/
|
|
1011
1225
|
recordDecryptSuccess(channelId) {
|
|
1012
1226
|
const e = this.errors.get(channelId);
|
|
1013
|
-
if (e
|
|
1227
|
+
if (!e)
|
|
1228
|
+
return;
|
|
1229
|
+
if (e.consecutiveErrors !== 0)
|
|
1014
1230
|
e.consecutiveErrors = 0;
|
|
1231
|
+
e.wedgedRun = 0;
|
|
1232
|
+
e.quarantined = false;
|
|
1233
|
+
e.wedgedSignalled = false;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Called from the decrypt-failure catch in {@link receiveOnce} when the
|
|
1237
|
+
* channel's consecutive-error run has just reached {@link quarantineThreshold}
|
|
1238
|
+
* and the row was NOT already drained via the past-epoch `permanentlyDead`
|
|
1239
|
+
* check (P0a, daemon-stability). Marks the channel quarantined and fires
|
|
1240
|
+
* {@link MlsGroupKeyProviderOpts.onWedged} exactly once per episode. Does NOT
|
|
1241
|
+
* itself ack the row — the caller pushes `row.queue_id` onto the same acked
|
|
1242
|
+
* list the `permanentlyDead` drain uses, flushed via `ackDelivery` at the end
|
|
1243
|
+
* of `receiveOnce`. Deliberately independent of `liveEpoch`: it must work even
|
|
1244
|
+
* when there is no local state for the channel at all (the no-local-state /
|
|
1245
|
+
* Hermes storm case), because quarantine is a pure consecutive-failure-count
|
|
1246
|
+
* decision, not an epoch comparison.
|
|
1247
|
+
*/
|
|
1248
|
+
quarantineChannel(channelId) {
|
|
1249
|
+
const e = this.errors.get(channelId);
|
|
1250
|
+
if (!e)
|
|
1251
|
+
return;
|
|
1252
|
+
e.quarantined = true;
|
|
1253
|
+
if (!e.wedgedSignalled) {
|
|
1254
|
+
e.wedgedSignalled = true;
|
|
1255
|
+
this.o.onWedged?.(channelId);
|
|
1256
|
+
}
|
|
1015
1257
|
}
|
|
1016
1258
|
/**
|
|
1017
1259
|
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
@@ -1028,11 +1270,42 @@ var MlsGroupKeyProvider = class {
|
|
|
1028
1270
|
decryptErrors: e.decryptErrors,
|
|
1029
1271
|
consecutiveErrors: e.consecutiveErrors,
|
|
1030
1272
|
lastErrorAt: e.lastErrorAt,
|
|
1031
|
-
stuck: e.consecutiveErrors >= this.stuckThreshold
|
|
1273
|
+
stuck: e.consecutiveErrors >= this.stuckThreshold,
|
|
1274
|
+
quarantined: e.quarantined
|
|
1032
1275
|
};
|
|
1033
1276
|
}
|
|
1034
1277
|
return out;
|
|
1035
1278
|
}
|
|
1279
|
+
/**
|
|
1280
|
+
* H1 graceful shutdown: resolve once every in-flight per-channel op AND the
|
|
1281
|
+
* publish lock have settled, or `deadlineMs` elapses (bounded so a hung network
|
|
1282
|
+
* op can't block process exit). The caller MUST have already stopped feeding new
|
|
1283
|
+
* work (closed the ping stream) so the queues can actually drain. Loops because a
|
|
1284
|
+
* settling op can chain a follow-on (e.g. a commit that then drains a pending
|
|
1285
|
+
* commit). Lets close() wait for an in-flight commit/join/ack to finish before
|
|
1286
|
+
* the DB handle is released — a SIGTERM / `kickstart -k` can no longer tear MLS
|
|
1287
|
+
* state mid-operation (a half-applied commit, or a join whose Welcome was acked
|
|
1288
|
+
* but whose state was not yet saved).
|
|
1289
|
+
*/
|
|
1290
|
+
async drainInFlight(deadlineMs = 5e3) {
|
|
1291
|
+
let timer;
|
|
1292
|
+
const deadline = new Promise((res) => {
|
|
1293
|
+
timer = setTimeout(res, deadlineMs);
|
|
1294
|
+
timer.unref?.();
|
|
1295
|
+
});
|
|
1296
|
+
const drain = (async () => {
|
|
1297
|
+
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
|
|
1298
|
+
await this.locks.idle();
|
|
1299
|
+
await this.publishLock.idle();
|
|
1300
|
+
}
|
|
1301
|
+
})();
|
|
1302
|
+
try {
|
|
1303
|
+
await Promise.race([drain, deadline]);
|
|
1304
|
+
} finally {
|
|
1305
|
+
if (timer)
|
|
1306
|
+
clearTimeout(timer);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1036
1309
|
/** Total past-epoch rows permanently skipped (acked without decrypting) since
|
|
1037
1310
|
* this provider was instantiated. A permanently-undecryptable row is one whose
|
|
1038
1311
|
* `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
|
|
@@ -1040,6 +1313,12 @@ var MlsGroupKeyProvider = class {
|
|
|
1040
1313
|
permanentlySkippedCount() {
|
|
1041
1314
|
return this.permanentlySkipped;
|
|
1042
1315
|
}
|
|
1316
|
+
/** Total rows ack-drained via QUARANTINE (P0a) since this provider was
|
|
1317
|
+
* instantiated — a wedged channel's undecryptable backlog, distinct from the
|
|
1318
|
+
* past-epoch {@link permanentlySkippedCount} drain. */
|
|
1319
|
+
quarantineDrainedCount() {
|
|
1320
|
+
return this.quarantineDrained;
|
|
1321
|
+
}
|
|
1043
1322
|
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1044
1323
|
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1045
1324
|
* not enough on its own, because a warm manager keeps decrypting until the
|
|
@@ -1208,16 +1487,99 @@ var MlsGroupKeyProvider = class {
|
|
|
1208
1487
|
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
|
|
1209
1488
|
return w?.connect_channel_id ?? null;
|
|
1210
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Initialize the ts-mls libsodium (WASM) backend ONCE before any store
|
|
1492
|
+
* operation that deserializes a persisted KeyPackage. generateKeyPackage
|
|
1493
|
+
* awaits sodium.ready and is pure in-memory (it neither publishes to the
|
|
1494
|
+
* backend nor writes the store), so the throwaway bundle is a side-effect-free
|
|
1495
|
+
* warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
|
|
1496
|
+
* guard, and the join path) runs while WASM is still cold, throws deep in the
|
|
1497
|
+
* crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
|
|
1498
|
+
* and misread as "no held bundle", and the daemon mints + republishes a FRESH
|
|
1499
|
+
* KeyPackage on every restart — orphaning every Welcome sealed to the prior
|
|
1500
|
+
* published KP (the root cause of "fresh DM never opens" across restarts).
|
|
1501
|
+
* Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
|
|
1502
|
+
*/
|
|
1503
|
+
async ensureCryptoReady() {
|
|
1504
|
+
if (this.cryptoWarmed)
|
|
1505
|
+
return;
|
|
1506
|
+
await new MLSGroupManager().generateKeyPackage(this.o.identity);
|
|
1507
|
+
this.cryptoWarmed = true;
|
|
1508
|
+
}
|
|
1211
1509
|
async publishKeyPackage() {
|
|
1510
|
+
await this.ensureCryptoReady();
|
|
1511
|
+
return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Force a FRESH published KeyPackage even when a pending bundle is already held.
|
|
1515
|
+
*
|
|
1516
|
+
* `publishKeyPackage`'s republish guard deliberately no-ops while a bundle is
|
|
1517
|
+
* held (so a normal re-publish can't orphan a leaf a queued Welcome needs). But
|
|
1518
|
+
* the reset self-heal reaches here only AFTER a fully-verified supersede Welcome
|
|
1519
|
+
* FAILED to open ("No matching secret found") — proving the currently-published
|
|
1520
|
+
* KP has diverged from our held bundle, so that bundle is already useless for
|
|
1521
|
+
* opening any Welcome sealed to the published KP. Delete it (dropping the guard)
|
|
1522
|
+
* and publish anew under the same device publish-lock, so the server's published
|
|
1523
|
+
* KP now matches a bundle we hold and the owner's re-Welcome is openable. Returns
|
|
1524
|
+
* the new KP id. Only the reset-unopenable self-heal calls this.
|
|
1525
|
+
*/
|
|
1526
|
+
async forceRepublishKeyPackage() {
|
|
1527
|
+
return this.publishLock.run(this.o.deviceId, async () => {
|
|
1528
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1529
|
+
return this.publishKeyPackageUnlocked();
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* True iff we hold a pending bundle AND the server's currently-advertised
|
|
1534
|
+
* KeyPackage for this device is that SAME bundle's public KeyPackage.
|
|
1535
|
+
*
|
|
1536
|
+
* The reset self-heal uses this to avoid a needless — and harmful —
|
|
1537
|
+
* force-republish. A failed `joinFromWelcome` on a supersede proves only that
|
|
1538
|
+
* THIS Welcome's sealed KP != our held bundle; it does NOT prove the CURRENTLY
|
|
1539
|
+
* published KP diverged. In the common case the reset committer simply sealed to
|
|
1540
|
+
* an OLDER KP snapshot while the current published KP still equals our held
|
|
1541
|
+
* bundle — a plain re-Welcome (re-sealed to the current published KP) then opens,
|
|
1542
|
+
* and deleting the shared per-device bundle would ORPHAN other channels' openable
|
|
1543
|
+
* Welcomes sealed to it (the multichannel-join case). Only when this returns
|
|
1544
|
+
* false (published KP genuinely diverged from held) is a force-republish warranted:
|
|
1545
|
+
* in the normal single-daemon case every currently-sealed Welcome uses the published
|
|
1546
|
+
* KP, so deleting the held bundle orphans nothing; a Welcome sealed to the held
|
|
1547
|
+
* bundle can only exist in a rare multi-instance / out-of-band-republish window, and
|
|
1548
|
+
* even then a supersede Welcome self-heals next cycle (held == published again).
|
|
1549
|
+
*
|
|
1550
|
+
* Best-effort: any error resolving either side returns false, so the caller
|
|
1551
|
+
* force-republishes (the conservative recovery). */
|
|
1552
|
+
async heldBundleMatchesPublishedKp() {
|
|
1553
|
+
try {
|
|
1554
|
+
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1555
|
+
if (!bundle)
|
|
1556
|
+
return false;
|
|
1557
|
+
const heldHex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1558
|
+
const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
|
|
1559
|
+
const pubHex = published[this.o.deviceId];
|
|
1560
|
+
return !!pubHex && pubHex === heldHex;
|
|
1561
|
+
} catch {
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
async publishKeyPackageUnlocked() {
|
|
1212
1566
|
if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
|
|
1213
1567
|
console.warn("publishKeyPackage: pending bundle exists; skipping republish");
|
|
1214
|
-
return;
|
|
1568
|
+
return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
|
|
1215
1569
|
}
|
|
1570
|
+
console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
|
|
1216
1571
|
const mgr = new MLSGroupManager();
|
|
1217
1572
|
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
1218
1573
|
const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1219
|
-
await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1220
|
-
await this.o.store.
|
|
1574
|
+
const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1575
|
+
await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
|
|
1576
|
+
return id;
|
|
1577
|
+
}
|
|
1578
|
+
/** The server-assigned id of the last KeyPackage this device published,
|
|
1579
|
+
* or null if none has been published yet. Lets a diverged guest's self-heal
|
|
1580
|
+
* signal name its outstanding KP (P2-A). */
|
|
1581
|
+
async getLastPublishedKpId() {
|
|
1582
|
+
return this.o.store.loadLastKpId(this.o.deviceId);
|
|
1221
1583
|
}
|
|
1222
1584
|
async ensureSession(channelId, opts) {
|
|
1223
1585
|
return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
|
|
@@ -1225,6 +1587,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1225
1587
|
async ensureSessionUnlocked(channelId, opts) {
|
|
1226
1588
|
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
1227
1589
|
return;
|
|
1590
|
+
await this.ensureCryptoReady();
|
|
1228
1591
|
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1229
1592
|
if (!bundle && (opts.bootstrap || opts.forceReinit)) {
|
|
1230
1593
|
await this.publishKeyPackage();
|
|
@@ -1255,7 +1618,6 @@ var MlsGroupKeyProvider = class {
|
|
|
1255
1618
|
});
|
|
1256
1619
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1257
1620
|
this.managers.set(channelId, mgr);
|
|
1258
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1259
1621
|
return;
|
|
1260
1622
|
}
|
|
1261
1623
|
if (opts.bootstrap) {
|
|
@@ -1283,30 +1645,23 @@ var MlsGroupKeyProvider = class {
|
|
|
1283
1645
|
});
|
|
1284
1646
|
this.managers.set(channelId, mgr);
|
|
1285
1647
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1286
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1287
1648
|
} else {
|
|
1288
1649
|
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1289
1650
|
const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
|
|
1290
1651
|
if (!w)
|
|
1291
1652
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
1292
1653
|
const mgr = new MLSGroupManager();
|
|
1293
|
-
|
|
1654
|
+
try {
|
|
1655
|
+
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
this.recordDecryptError(w, err);
|
|
1658
|
+
console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
|
|
1659
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1294
1662
|
this.managers.set(channelId, mgr);
|
|
1295
1663
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1296
1664
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1297
|
-
const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1298
|
-
let burstPending = false;
|
|
1299
|
-
for (const r of remaining) {
|
|
1300
|
-
if (r.message_type !== "welcome")
|
|
1301
|
-
continue;
|
|
1302
|
-
const cid = r.connect_channel_id;
|
|
1303
|
-
if (!cid || !await this.o.store.load(cid)) {
|
|
1304
|
-
burstPending = true;
|
|
1305
|
-
break;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
if (!burstPending)
|
|
1309
|
-
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1310
1665
|
}
|
|
1311
1666
|
}
|
|
1312
1667
|
/**
|
|
@@ -1325,7 +1680,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1325
1680
|
async addMemberToLiveGroup(channelId, newDeviceId) {
|
|
1326
1681
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
|
|
1327
1682
|
}
|
|
1328
|
-
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt) {
|
|
1683
|
+
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
1329
1684
|
const mgr = await this.manager(channelId);
|
|
1330
1685
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1331
1686
|
const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
|
|
@@ -1333,6 +1688,13 @@ var MlsGroupKeyProvider = class {
|
|
|
1333
1688
|
if (!hex)
|
|
1334
1689
|
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
1335
1690
|
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1691
|
+
if (opts?.expectedIdentityId) {
|
|
1692
|
+
const cred = pkg.leafNode.credential;
|
|
1693
|
+
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
1694
|
+
if (claimed !== opts.expectedIdentityId) {
|
|
1695
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1336
1698
|
const priorEpoch = Number(mgr.epoch);
|
|
1337
1699
|
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
1338
1700
|
const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
@@ -1354,7 +1716,13 @@ var MlsGroupKeyProvider = class {
|
|
|
1354
1716
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1355
1717
|
} catch {
|
|
1356
1718
|
}
|
|
1357
|
-
|
|
1719
|
+
try {
|
|
1720
|
+
const work = await this.o.backend.getCommitterWork(channelId);
|
|
1721
|
+
if (!work.adds.includes(newDeviceId))
|
|
1722
|
+
return;
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
1358
1726
|
}
|
|
1359
1727
|
await this.o.store.save(channelId, mgr.exportState());
|
|
1360
1728
|
}
|
|
@@ -1546,7 +1914,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1546
1914
|
* Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
|
|
1547
1915
|
* addMemberToLiveGroupUnlocked) — no weaker path is introduced.
|
|
1548
1916
|
*/
|
|
1549
|
-
async healDivergedMember(channelId, memberDeviceId) {
|
|
1917
|
+
async healDivergedMember(channelId, memberDeviceId, opts) {
|
|
1550
1918
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
1551
1919
|
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
1552
1920
|
const hex = kps[memberDeviceId];
|
|
@@ -1557,6 +1925,9 @@ var MlsGroupKeyProvider = class {
|
|
|
1557
1925
|
throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
|
|
1558
1926
|
}
|
|
1559
1927
|
const identity = new TextDecoder().decode(cred.identity);
|
|
1928
|
+
if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
|
|
1929
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, identity);
|
|
1930
|
+
}
|
|
1560
1931
|
const mgr = await this.manager(channelId);
|
|
1561
1932
|
const leaves = mgr.leafIndicesForIdentity(identity);
|
|
1562
1933
|
if (leaves.length > 1) {
|
|
@@ -1573,7 +1944,7 @@ var MlsGroupKeyProvider = class {
|
|
|
1573
1944
|
}
|
|
1574
1945
|
}
|
|
1575
1946
|
try {
|
|
1576
|
-
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0);
|
|
1947
|
+
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
|
|
1577
1948
|
} catch (e) {
|
|
1578
1949
|
if (removeLanded)
|
|
1579
1950
|
throw new HealAddAfterRemoveError(e);
|
|
@@ -1581,6 +1952,48 @@ var MlsGroupKeyProvider = class {
|
|
|
1581
1952
|
}
|
|
1582
1953
|
}));
|
|
1583
1954
|
}
|
|
1955
|
+
/** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
|
|
1956
|
+
* epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
|
|
1957
|
+
* re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
|
|
1958
|
+
* sweep call sites and for the cross-tenant authz that differs only server-side.
|
|
1959
|
+
* Caller contract (like the member path): the guest republishes a fresh
|
|
1960
|
+
* KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
|
|
1961
|
+
*
|
|
1962
|
+
* Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
|
|
1963
|
+
* retained KeyPackage (the one the caller confirmed the guest republished).
|
|
1964
|
+
* Before doing anything else we fetch it BY ID and refuse
|
|
1965
|
+
* ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
|
|
1966
|
+
* strength of "some KeyPackage is published for this device", because that
|
|
1967
|
+
* could be stale, consumed, or absent and would seal an un-openable Welcome,
|
|
1968
|
+
* bricking the guest instead of healing it. Once the gate passes we delegate to
|
|
1969
|
+
* the proven {@link healDivergedMember}: given `publish_key_package`'s
|
|
1970
|
+
* delete-and-replace semantics and the guest's pending-bundle republish guard,
|
|
1971
|
+
* the by-device fetch inside healDivergedMember resolves to exactly this same
|
|
1972
|
+
* KeyPackage — the id-fetch here is the fail-closed contract, the member heal
|
|
1973
|
+
* is the mechanism. */
|
|
1974
|
+
async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
|
|
1975
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
1976
|
+
if (!hex)
|
|
1977
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
1978
|
+
return this.healDivergedMember(channelId, guestDeviceId);
|
|
1979
|
+
}
|
|
1980
|
+
/** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
|
|
1981
|
+
* AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
|
|
1982
|
+
* id-by-value gate (refuse if `expectedKpId` is gone/consumed →
|
|
1983
|
+
* {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
|
|
1984
|
+
* delegates to {@link healDivergedMember} with `expectedIdentityId` so the
|
|
1985
|
+
* committed-bytes assertion binds the re-Added leaf to the intended identity
|
|
1986
|
+
* (throws {@link ForgedCredentialError} on a credential-identity mismatch,
|
|
1987
|
+
* including on the 409-retry path). This is the entry point the owner's
|
|
1988
|
+
* agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
|
|
1989
|
+
* the welcome-request row the agent published, so a compromised agent cannot
|
|
1990
|
+
* substitute a KeyPackage that forges a different identity. */
|
|
1991
|
+
async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
|
|
1992
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
1993
|
+
if (!hex)
|
|
1994
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
1995
|
+
return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
|
|
1996
|
+
}
|
|
1584
1997
|
/**
|
|
1585
1998
|
* Member-side: drop the diverged local state for a channel so a subsequent
|
|
1586
1999
|
* {@link joinAndReconcile} consumes the committer's fresh Welcome (from
|
|
@@ -1615,6 +2028,56 @@ var MlsGroupKeyProvider = class {
|
|
|
1615
2028
|
this.errors.delete(channelId);
|
|
1616
2029
|
});
|
|
1617
2030
|
}
|
|
2031
|
+
/** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
|
|
2032
|
+
* ensureSession forceReinit branch but posts a client-chosen new_group_id +
|
|
2033
|
+
* signed attestation to the reset-mode endpoint, then builds + distributes
|
|
2034
|
+
* the new group. Runs under the channel lock (serialized against
|
|
2035
|
+
* receiveOnce / committer ops).
|
|
2036
|
+
*
|
|
2037
|
+
* `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
|
|
2038
|
+
* member device holding an unconsumed KeyPackage (H2) -- the caller (the web
|
|
2039
|
+
* client's members-endpoint enumeration) is responsible for that union;
|
|
2040
|
+
* this method only handles the mechanical THIS-device exclusion: this
|
|
2041
|
+
* device is the committer that creates the group, so it must not be in the
|
|
2042
|
+
* MLS addMembers set (adding your own new leaf via addMembers is
|
|
2043
|
+
* nonsensical / unsupported). The wire member_device_ids below re-includes
|
|
2044
|
+
* it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
|
|
2045
|
+
* (required subset of supplied) is still satisfied even though it never
|
|
2046
|
+
* goes through addMembers. */
|
|
2047
|
+
async resetSessionSupersede(channelId, opts) {
|
|
2048
|
+
return this.locks.run(channelId, async () => {
|
|
2049
|
+
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2050
|
+
if (!bundle) {
|
|
2051
|
+
await this.publishKeyPackage();
|
|
2052
|
+
bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2053
|
+
}
|
|
2054
|
+
if (!bundle)
|
|
2055
|
+
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
2056
|
+
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
2057
|
+
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
2058
|
+
const mgr = new MLSGroupManager();
|
|
2059
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
2060
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
2061
|
+
const pkgs = others.map((d) => {
|
|
2062
|
+
const hex = kps[d];
|
|
2063
|
+
if (!hex)
|
|
2064
|
+
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
2065
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
2066
|
+
});
|
|
2067
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
2068
|
+
await this.o.backend.distribute(reinit.group_id, {
|
|
2069
|
+
device_id: this.o.deviceId,
|
|
2070
|
+
commit: bytesToHex(commit),
|
|
2071
|
+
commit_epoch: Number(mgr.epoch),
|
|
2072
|
+
expected_epoch: reinit.current_epoch,
|
|
2073
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
2074
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
2075
|
+
});
|
|
2076
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2077
|
+
this.managers.set(channelId, mgr);
|
|
2078
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
1618
2081
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
1619
2082
|
* Cheap synchronous check; does NOT consult the store. */
|
|
1620
2083
|
hasChannelState(channelId) {
|
|
@@ -1628,6 +2091,19 @@ var MlsGroupKeyProvider = class {
|
|
|
1628
2091
|
return true;
|
|
1629
2092
|
return await this.o.store.load(channelId) !== null;
|
|
1630
2093
|
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Return the number of leaves the given identity holds in the live tree for
|
|
2096
|
+
* `channelId`. Loads the manager from the store if not already cached.
|
|
2097
|
+
*
|
|
2098
|
+
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
2099
|
+
* does NOT create a duplicate leaf for an already-added device. A return value
|
|
2100
|
+
* of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
|
|
2101
|
+
* duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
|
|
2102
|
+
*/
|
|
2103
|
+
async leafCountForIdentity(channelId, identityId) {
|
|
2104
|
+
const mgr = await this.manager(channelId);
|
|
2105
|
+
return mgr.leafIndicesForIdentity(identityId).length;
|
|
2106
|
+
}
|
|
1631
2107
|
/** Returns true when the live in-memory MLS tree for `channelId` contains at
|
|
1632
2108
|
* least one leaf bearing `identityId` as its credential. Used by the agent
|
|
1633
2109
|
* admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
|
|
@@ -1647,18 +2123,130 @@ var MlsGroupKeyProvider = class {
|
|
|
1647
2123
|
const r = await this.o.backend.getConnectChannelGroup(channelId);
|
|
1648
2124
|
return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
|
|
1649
2125
|
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Verified group supersede for the agent's receive pump (owner-attested reset).
|
|
2128
|
+
* Returns true when the channel's state was swapped to the new group and the
|
|
2129
|
+
* Welcome row acked; false when the supersede was REFUSED (row left un-acked).
|
|
2130
|
+
*
|
|
2131
|
+
* FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
|
|
2132
|
+
* ⇒ refuse, never call verify), then structural signer check, then attestation
|
|
2133
|
+
* verify against the NON-EMPTY pinned key, then post-join group-id assertion.
|
|
2134
|
+
*/
|
|
2135
|
+
async trySupersede(channelId, row, heldGroupId) {
|
|
2136
|
+
const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
|
|
2137
|
+
if (!owner || !owner.publicKeyHex) {
|
|
2138
|
+
this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
|
|
2139
|
+
return false;
|
|
2140
|
+
}
|
|
2141
|
+
const retired = await this.o.store.loadRetiredGroupIds(channelId);
|
|
2142
|
+
if (retired.includes(row.group_id)) {
|
|
2143
|
+
this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
|
|
2144
|
+
return false;
|
|
2145
|
+
}
|
|
2146
|
+
const att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
2147
|
+
if (!att) {
|
|
2148
|
+
this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
if (att.signerIdentityId !== owner.identityId) {
|
|
2152
|
+
this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
|
|
2153
|
+
return false;
|
|
2154
|
+
}
|
|
2155
|
+
let content;
|
|
2156
|
+
try {
|
|
2157
|
+
content = JSON.parse(att.signedContent);
|
|
2158
|
+
} catch {
|
|
2159
|
+
this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2162
|
+
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
2163
|
+
this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
2167
|
+
if (!ok) {
|
|
2168
|
+
this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
2172
|
+
if (!bundle) {
|
|
2173
|
+
this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
return await this.locks.run(channelId, async () => {
|
|
2177
|
+
const mgr = new MLSGroupManager();
|
|
2178
|
+
try {
|
|
2179
|
+
await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
|
|
2180
|
+
} catch (err) {
|
|
2181
|
+
this.recordDecryptError(row, err);
|
|
2182
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2183
|
+
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`);
|
|
2184
|
+
}
|
|
2185
|
+
let requested = false;
|
|
2186
|
+
try {
|
|
2187
|
+
requested = await this.o.onSupersedeUnopenable?.(channelId, row.group_id) ?? false;
|
|
2188
|
+
} catch {
|
|
2189
|
+
}
|
|
2190
|
+
if (requested) {
|
|
2191
|
+
try {
|
|
2192
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
2193
|
+
} catch {
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return false;
|
|
2197
|
+
}
|
|
2198
|
+
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
2199
|
+
this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
this.evictChannelState(channelId);
|
|
2203
|
+
await this.o.store.delete(channelId);
|
|
2204
|
+
await this.o.store.discardStaging(channelId);
|
|
2205
|
+
await this.o.store.addRetiredGroupId(channelId, heldGroupId);
|
|
2206
|
+
this.errors.delete(channelId);
|
|
2207
|
+
this.managers.set(channelId, mgr);
|
|
2208
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2209
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
2210
|
+
return true;
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
1650
2213
|
async receiveOnce(deviceId) {
|
|
1651
2214
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2215
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
|
|
2216
|
+
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(", "));
|
|
2217
|
+
}
|
|
1652
2218
|
const appEntries = [];
|
|
1653
2219
|
const handshakeAcked = [];
|
|
2220
|
+
const supersededChannelIds = [];
|
|
1654
2221
|
const order = { welcome: 0, commit: 1, application: 2 };
|
|
1655
2222
|
rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
|
|
1656
2223
|
for (const row of rows) {
|
|
1657
2224
|
try {
|
|
1658
2225
|
if (row.message_type === "welcome") {
|
|
1659
2226
|
const wcid = row.connect_channel_id;
|
|
1660
|
-
if (wcid
|
|
1661
|
-
|
|
2227
|
+
if (wcid) {
|
|
2228
|
+
const existing = await this.o.store.load(wcid);
|
|
2229
|
+
if (existing) {
|
|
2230
|
+
let heldGroupId = null;
|
|
2231
|
+
try {
|
|
2232
|
+
const mgr = await this.manager(wcid);
|
|
2233
|
+
heldGroupId = new TextDecoder().decode(mgr.groupId);
|
|
2234
|
+
} catch {
|
|
2235
|
+
heldGroupId = null;
|
|
2236
|
+
}
|
|
2237
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2238
|
+
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"}`);
|
|
2239
|
+
}
|
|
2240
|
+
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
2241
|
+
handshakeAcked.push(row.queue_id);
|
|
2242
|
+
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
2243
|
+
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
2244
|
+
if (ok)
|
|
2245
|
+
supersededChannelIds.push(wcid);
|
|
2246
|
+
}
|
|
2247
|
+
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
2248
|
+
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)`);
|
|
2249
|
+
}
|
|
1662
2250
|
}
|
|
1663
2251
|
} else if (row.message_type === "commit") {
|
|
1664
2252
|
const cid = row.connect_channel_id;
|
|
@@ -1692,12 +2280,14 @@ var MlsGroupKeyProvider = class {
|
|
|
1692
2280
|
plaintext,
|
|
1693
2281
|
envelope,
|
|
1694
2282
|
receivedAt: Date.now(),
|
|
1695
|
-
retryCount: 0
|
|
2283
|
+
retryCount: 0,
|
|
2284
|
+
expires_at: row.expires_at
|
|
1696
2285
|
});
|
|
1697
2286
|
}
|
|
1698
2287
|
} catch (err) {
|
|
1699
2288
|
this.recordDecryptError(row, err);
|
|
1700
2289
|
const cid = row.connect_channel_id;
|
|
2290
|
+
let drained = false;
|
|
1701
2291
|
if (cid && typeof row.epoch === "number") {
|
|
1702
2292
|
let liveEpoch;
|
|
1703
2293
|
try {
|
|
@@ -1709,12 +2299,27 @@ var MlsGroupKeyProvider = class {
|
|
|
1709
2299
|
if (permanentlyDead) {
|
|
1710
2300
|
handshakeAcked.push(row.queue_id);
|
|
1711
2301
|
this.permanentlySkipped++;
|
|
2302
|
+
drained = true;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (!drained && cid) {
|
|
2306
|
+
const e = this.errors.get(cid);
|
|
2307
|
+
if (e) {
|
|
2308
|
+
e.wedgedRun += 1;
|
|
2309
|
+
if (e.wedgedRun >= this.quarantineThreshold) {
|
|
2310
|
+
handshakeAcked.push(row.queue_id);
|
|
2311
|
+
this.quarantineDrained++;
|
|
2312
|
+
this.quarantineChannel(cid);
|
|
2313
|
+
}
|
|
1712
2314
|
}
|
|
1713
2315
|
}
|
|
1714
2316
|
}
|
|
1715
2317
|
}
|
|
1716
2318
|
if (handshakeAcked.length)
|
|
1717
2319
|
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
2320
|
+
if (supersededChannelIds.length) {
|
|
2321
|
+
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
2322
|
+
}
|
|
1718
2323
|
return appEntries;
|
|
1719
2324
|
}
|
|
1720
2325
|
/**
|
|
@@ -1795,11 +2400,38 @@ var FRAME_PARSERS = {
|
|
|
1795
2400
|
roomId: d?.room_id ? str(d.room_id) : void 0,
|
|
1796
2401
|
requestingDeviceId: str(d?.requesting_device_id),
|
|
1797
2402
|
requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
|
|
2403
|
+
freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
|
|
1798
2404
|
requestId: d?.request_id ? str(d.request_id) : void 0
|
|
1799
2405
|
}
|
|
2406
|
+
}),
|
|
2407
|
+
// Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
|
|
2408
|
+
// heal a guest whose ratchet tree diverged. Durable state is the
|
|
2409
|
+
// mls_guest_heal_request row; this frame is just the wakeup hint.
|
|
2410
|
+
mls_guest_heal_requested: (d) => ({
|
|
2411
|
+
guestHealRequest: {
|
|
2412
|
+
groupId: str(d?.group_id),
|
|
2413
|
+
channelId: str(d?.channel_id),
|
|
2414
|
+
enclaveId: str(d?.enclave_id),
|
|
2415
|
+
guestIdentityId: str(d?.guest_identity_id),
|
|
2416
|
+
guestDeviceId: str(d?.guest_device_id),
|
|
2417
|
+
freshKpId: str(d?.fresh_kp_id),
|
|
2418
|
+
requestId: str(d?.request_id)
|
|
2419
|
+
}
|
|
1800
2420
|
})
|
|
1801
2421
|
};
|
|
1802
2422
|
var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
|
|
2423
|
+
var TERMINAL_FRAME_EVENTS = /* @__PURE__ */ new Set([
|
|
2424
|
+
"device_revoked",
|
|
2425
|
+
"identity.revoked"
|
|
2426
|
+
]);
|
|
2427
|
+
function terminalFrameReason(frame) {
|
|
2428
|
+
if (!frame || typeof frame !== "object")
|
|
2429
|
+
return void 0;
|
|
2430
|
+
const f = frame;
|
|
2431
|
+
if (typeof f.event !== "string" || !TERMINAL_FRAME_EVENTS.has(f.event))
|
|
2432
|
+
return void 0;
|
|
2433
|
+
return typeof f.data?.reason === "string" && f.data.reason ? f.data.reason : f.event;
|
|
2434
|
+
}
|
|
1803
2435
|
function parseServerFrame(frame) {
|
|
1804
2436
|
if (!frame || typeof frame !== "object")
|
|
1805
2437
|
return void 0;
|
|
@@ -1815,8 +2447,22 @@ function openDeliveryPings(o) {
|
|
|
1815
2447
|
const rng = o.rng ?? Math.random;
|
|
1816
2448
|
const maxQueue = o.maxQueue ?? 8;
|
|
1817
2449
|
const stableResetMs = o.stableResetMs ?? 1e4;
|
|
2450
|
+
const hardened = o.hardened ?? false;
|
|
2451
|
+
const now = o.now ?? (() => Date.now());
|
|
2452
|
+
const silenceMs = o.silenceMs ?? 9e4;
|
|
2453
|
+
const watchdogMs = o.watchdogMs ?? 3e4;
|
|
2454
|
+
const wakeGapMs = o.wakeGapMs ?? 12e4;
|
|
2455
|
+
const authWarCap = o.authWarCap ?? 3;
|
|
2456
|
+
const authWarWindowMs = o.authWarWindowMs ?? 12e4;
|
|
2457
|
+
const lifecycle = (event, detail) => {
|
|
2458
|
+
try {
|
|
2459
|
+
o.onLifecycle?.(event, detail);
|
|
2460
|
+
} catch {
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
1818
2463
|
let closed = false;
|
|
1819
2464
|
let attempt = 0;
|
|
2465
|
+
let connectGen = 0;
|
|
1820
2466
|
let sock;
|
|
1821
2467
|
let backoffTimer;
|
|
1822
2468
|
let stableTimer;
|
|
@@ -1828,6 +2474,17 @@ function openDeliveryPings(o) {
|
|
|
1828
2474
|
pongTimer = void 0;
|
|
1829
2475
|
}
|
|
1830
2476
|
};
|
|
2477
|
+
let terminalReason;
|
|
2478
|
+
let lastInboundAt = 0;
|
|
2479
|
+
let lastTickAt;
|
|
2480
|
+
let watchdogTimer;
|
|
2481
|
+
const authCloses = [];
|
|
2482
|
+
const stopWatchdog = () => {
|
|
2483
|
+
if (watchdogTimer) {
|
|
2484
|
+
clearInterval(watchdogTimer);
|
|
2485
|
+
watchdogTimer = void 0;
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
1831
2488
|
const queue = [];
|
|
1832
2489
|
let waiter;
|
|
1833
2490
|
let iterStarted = false;
|
|
@@ -1847,9 +2504,18 @@ function openDeliveryPings(o) {
|
|
|
1847
2504
|
if (stableTimer)
|
|
1848
2505
|
clearTimeout(stableTimer);
|
|
1849
2506
|
stopPong();
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
2507
|
+
stopWatchdog();
|
|
2508
|
+
const ws = sock;
|
|
2509
|
+
sock = void 0;
|
|
2510
|
+
if (ws) {
|
|
2511
|
+
ws.onopen = null;
|
|
2512
|
+
ws.onmessage = null;
|
|
2513
|
+
ws.onclose = null;
|
|
2514
|
+
ws.onerror = null;
|
|
2515
|
+
try {
|
|
2516
|
+
ws.close();
|
|
2517
|
+
} catch {
|
|
2518
|
+
}
|
|
1853
2519
|
}
|
|
1854
2520
|
if (waiter) {
|
|
1855
2521
|
const w = waiter;
|
|
@@ -1860,13 +2526,60 @@ function openDeliveryPings(o) {
|
|
|
1860
2526
|
const scheduleReconnect = () => {
|
|
1861
2527
|
if (closed)
|
|
1862
2528
|
return;
|
|
2529
|
+
if (backoffTimer)
|
|
2530
|
+
clearTimeout(backoffTimer);
|
|
2531
|
+
connectGen += 1;
|
|
1863
2532
|
attempt += 1;
|
|
1864
2533
|
const ceil = Math.min(cap, base * 2 ** (attempt - 1));
|
|
1865
2534
|
backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
|
|
1866
2535
|
};
|
|
2536
|
+
const forceCycle = (event, detail) => {
|
|
2537
|
+
if (closed)
|
|
2538
|
+
return;
|
|
2539
|
+
lifecycle(event, detail);
|
|
2540
|
+
const ws = sock;
|
|
2541
|
+
sock = void 0;
|
|
2542
|
+
if (stableTimer)
|
|
2543
|
+
clearTimeout(stableTimer);
|
|
2544
|
+
stopPong();
|
|
2545
|
+
if (ws) {
|
|
2546
|
+
ws.onopen = null;
|
|
2547
|
+
ws.onmessage = null;
|
|
2548
|
+
ws.onclose = null;
|
|
2549
|
+
ws.onerror = null;
|
|
2550
|
+
try {
|
|
2551
|
+
ws.close();
|
|
2552
|
+
} catch {
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
scheduleReconnect();
|
|
2556
|
+
};
|
|
2557
|
+
const startWatchdog = () => {
|
|
2558
|
+
if (!hardened || watchdogTimer)
|
|
2559
|
+
return;
|
|
2560
|
+
lastTickAt = now();
|
|
2561
|
+
watchdogTimer = setInterval(() => {
|
|
2562
|
+
if (closed)
|
|
2563
|
+
return;
|
|
2564
|
+
const t = now();
|
|
2565
|
+
const gap = lastTickAt !== void 0 ? t - lastTickAt : 0;
|
|
2566
|
+
if (lastTickAt !== void 0 && gap > wakeGapMs) {
|
|
2567
|
+
lastTickAt = t;
|
|
2568
|
+
attempt = 0;
|
|
2569
|
+
forceCycle("wake", `${gap}ms gap`);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
lastTickAt = t;
|
|
2573
|
+
if (sock && sock.readyState === 1 && t - lastInboundAt > silenceMs) {
|
|
2574
|
+
forceCycle("halfopen", `${t - lastInboundAt}ms silent`);
|
|
2575
|
+
}
|
|
2576
|
+
}, watchdogMs);
|
|
2577
|
+
};
|
|
1867
2578
|
async function connect() {
|
|
1868
2579
|
if (closed)
|
|
1869
2580
|
return;
|
|
2581
|
+
const gen = connectGen;
|
|
2582
|
+
terminalReason = void 0;
|
|
1870
2583
|
let token;
|
|
1871
2584
|
try {
|
|
1872
2585
|
token = await o.tokenProvider();
|
|
@@ -1874,15 +2587,17 @@ function openDeliveryPings(o) {
|
|
|
1874
2587
|
scheduleReconnect();
|
|
1875
2588
|
return;
|
|
1876
2589
|
}
|
|
1877
|
-
if (closed)
|
|
2590
|
+
if (closed || gen !== connectGen)
|
|
1878
2591
|
return;
|
|
1879
2592
|
const sep = o.url.includes("?") ? "&" : "?";
|
|
1880
2593
|
const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
|
|
1881
2594
|
sock = ws;
|
|
1882
2595
|
ws.onopen = () => {
|
|
1883
2596
|
push({ reconnected: true });
|
|
2597
|
+
lastInboundAt = now();
|
|
1884
2598
|
stableTimer = setTimeout(() => {
|
|
1885
2599
|
attempt = 0;
|
|
2600
|
+
authCloses.length = 0;
|
|
1886
2601
|
}, stableResetMs);
|
|
1887
2602
|
stopPong();
|
|
1888
2603
|
pongTimer = setInterval(() => {
|
|
@@ -1894,8 +2609,26 @@ function openDeliveryPings(o) {
|
|
|
1894
2609
|
}, PRESENCE_PONG_MS);
|
|
1895
2610
|
};
|
|
1896
2611
|
ws.onmessage = (e) => {
|
|
2612
|
+
lastInboundAt = now();
|
|
2613
|
+
let f;
|
|
2614
|
+
try {
|
|
2615
|
+
f = JSON.parse(String(e.data));
|
|
2616
|
+
} catch {
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
if (hardened) {
|
|
2620
|
+
const tr = terminalFrameReason(f);
|
|
2621
|
+
if (tr) {
|
|
2622
|
+
terminalReason = tr;
|
|
2623
|
+
} else if (f?.event === "ping") {
|
|
2624
|
+
try {
|
|
2625
|
+
if (ws.readyState === 1)
|
|
2626
|
+
ws.send(JSON.stringify({ event: "pong" }));
|
|
2627
|
+
} catch {
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
1897
2631
|
try {
|
|
1898
|
-
const f = JSON.parse(String(e.data));
|
|
1899
2632
|
const ping = parseServerFrame(f);
|
|
1900
2633
|
if (ping)
|
|
1901
2634
|
push(ping);
|
|
@@ -1911,14 +2644,41 @@ function openDeliveryPings(o) {
|
|
|
1911
2644
|
stopPong();
|
|
1912
2645
|
if (closed)
|
|
1913
2646
|
return;
|
|
1914
|
-
|
|
2647
|
+
const code = e.code;
|
|
2648
|
+
if (!hardened) {
|
|
2649
|
+
if (code === 4001) {
|
|
2650
|
+
end();
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
scheduleReconnect();
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
const tr = terminalReason;
|
|
2657
|
+
terminalReason = void 0;
|
|
2658
|
+
if (code === 4006 || tr && code === 4001) {
|
|
2659
|
+
lifecycle("terminal", tr ?? `code ${code}`);
|
|
1915
2660
|
end();
|
|
1916
2661
|
return;
|
|
1917
2662
|
}
|
|
2663
|
+
if (code === 4001) {
|
|
2664
|
+
const t = now();
|
|
2665
|
+
authCloses.push(t);
|
|
2666
|
+
while (authCloses.length && t - authCloses[0] > authWarWindowMs)
|
|
2667
|
+
authCloses.shift();
|
|
2668
|
+
if (authCloses.length >= authWarCap) {
|
|
2669
|
+
lifecycle("terminal", `auth war-cap ${authCloses.length}/${authWarWindowMs}ms`);
|
|
2670
|
+
end();
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
lifecycle("reconnect", "auth-4001");
|
|
2674
|
+
scheduleReconnect();
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
1918
2677
|
scheduleReconnect();
|
|
1919
2678
|
};
|
|
1920
2679
|
}
|
|
1921
2680
|
void connect();
|
|
2681
|
+
startWatchdog();
|
|
1922
2682
|
const pings = {
|
|
1923
2683
|
[Symbol.asyncIterator]() {
|
|
1924
2684
|
if (iterStarted)
|
|
@@ -2033,6 +2793,11 @@ var RestTransport = class {
|
|
|
2033
2793
|
if (stale)
|
|
2034
2794
|
throw stale;
|
|
2035
2795
|
}
|
|
2796
|
+
if (res.status === 403) {
|
|
2797
|
+
const paused = await agentPausedErrorFromResponse(res.clone ? res.clone() : res);
|
|
2798
|
+
if (paused)
|
|
2799
|
+
throw paused;
|
|
2800
|
+
}
|
|
2036
2801
|
if (!res.ok)
|
|
2037
2802
|
throw new Error(`publish failed: ${res.status}`);
|
|
2038
2803
|
const j = await res.json();
|
|
@@ -2048,7 +2813,13 @@ var RestTransport = class {
|
|
|
2048
2813
|
if (this.opts.connectIdentityId) {
|
|
2049
2814
|
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
2050
2815
|
}
|
|
2051
|
-
const handle = openDeliveryPings({
|
|
2816
|
+
const handle = openDeliveryPings({
|
|
2817
|
+
url: wsUrl,
|
|
2818
|
+
tokenProvider: this.tokenProvider,
|
|
2819
|
+
wsImpl: this.ws,
|
|
2820
|
+
hardened: this.opts.hardenedReconnect ?? false,
|
|
2821
|
+
onLifecycle: this.opts.onWsLifecycle
|
|
2822
|
+
});
|
|
2052
2823
|
this._controlSend = handle.send;
|
|
2053
2824
|
const close = () => {
|
|
2054
2825
|
if (this._controlSend === handle.send)
|
|
@@ -2123,10 +2894,11 @@ var RestMlsBackend = class {
|
|
|
2123
2894
|
return { device_id: String(j.device_id) };
|
|
2124
2895
|
}
|
|
2125
2896
|
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
2126
|
-
await this.post("/api/v1/mls/key-packages", {
|
|
2897
|
+
const j = await this.post("/api/v1/mls/key-packages", {
|
|
2127
2898
|
key_package: keyPackageHex,
|
|
2128
2899
|
device_id: deviceId
|
|
2129
2900
|
});
|
|
2901
|
+
return String(j.id);
|
|
2130
2902
|
}
|
|
2131
2903
|
async getConnectChannelGroup(channelId) {
|
|
2132
2904
|
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
|
|
@@ -2163,10 +2935,37 @@ var RestMlsBackend = class {
|
|
|
2163
2935
|
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2164
2936
|
};
|
|
2165
2937
|
}
|
|
2166
|
-
async
|
|
2938
|
+
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
2939
|
+
const committer = memberDeviceIds[0];
|
|
2940
|
+
if (!committer)
|
|
2941
|
+
throw new Error("resetReinitConnectChannelGroup: empty member set");
|
|
2942
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
2943
|
+
member_device_ids: memberDeviceIds,
|
|
2944
|
+
committer_device_id: committer,
|
|
2945
|
+
mode: "reset",
|
|
2946
|
+
new_group_id: att.newGroupId,
|
|
2947
|
+
reset_attestation_signature: att.signatureHex,
|
|
2948
|
+
reset_attestation_nonce: att.nonceHex,
|
|
2949
|
+
reset_attestation_content: att.signedContentHex
|
|
2950
|
+
});
|
|
2951
|
+
return {
|
|
2952
|
+
group_id: String(j.group_id),
|
|
2953
|
+
current_epoch: Number(j.current_epoch),
|
|
2954
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
async fetchKeyPackages(deviceIds) {
|
|
2167
2958
|
const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
|
|
2168
2959
|
return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
|
|
2169
2960
|
}
|
|
2961
|
+
async fetchKeyPackageById(kpId) {
|
|
2962
|
+
try {
|
|
2963
|
+
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
|
|
2964
|
+
return String(j.key_package);
|
|
2965
|
+
} catch {
|
|
2966
|
+
return null;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2170
2969
|
async distribute(groupId, req) {
|
|
2171
2970
|
await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
|
|
2172
2971
|
...req,
|
|
@@ -2183,13 +2982,48 @@ var RestMlsBackend = class {
|
|
|
2183
2982
|
queue_ids: queueIds
|
|
2184
2983
|
});
|
|
2185
2984
|
}
|
|
2186
|
-
async requestWelcome(groupId, deviceId) {
|
|
2187
|
-
|
|
2985
|
+
async requestWelcome(groupId, deviceId, freshKpId) {
|
|
2986
|
+
const body = { device_id: deviceId };
|
|
2987
|
+
if (freshKpId)
|
|
2988
|
+
body.fresh_kp_id = freshKpId;
|
|
2989
|
+
await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
|
|
2990
|
+
}
|
|
2991
|
+
async getCommitterWork(channelId) {
|
|
2992
|
+
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
2993
|
+
return {
|
|
2994
|
+
adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
|
|
2995
|
+
removes: Array.isArray(j.removes) ? j.removes.map(String) : []
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
async acquireCommitLease(channelId) {
|
|
2999
|
+
const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
|
|
3000
|
+
if (res.status === 409 || res.status === 503)
|
|
3001
|
+
return null;
|
|
3002
|
+
if (!res.ok)
|
|
3003
|
+
throw new Error(`commit-lease ${res.status}`);
|
|
3004
|
+
const j = await res.json();
|
|
3005
|
+
return String(j.token);
|
|
3006
|
+
}
|
|
3007
|
+
async releaseCommitLease(channelId, token) {
|
|
3008
|
+
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(() => {
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
async fetchResetAttestation(groupId) {
|
|
3012
|
+
try {
|
|
3013
|
+
const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
|
|
3014
|
+
return {
|
|
3015
|
+
signedContent: String(j.signed_content),
|
|
3016
|
+
signatureHex: String(j.signature_hex),
|
|
3017
|
+
signerIdentityId: String(j.signer_identity_id)
|
|
3018
|
+
};
|
|
3019
|
+
} catch {
|
|
3020
|
+
return null;
|
|
3021
|
+
}
|
|
2188
3022
|
}
|
|
2189
3023
|
};
|
|
2190
3024
|
|
|
2191
3025
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
2192
|
-
import { bytesToHex as bytesToHex2 } from "@agentvault/crypto";
|
|
3026
|
+
import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
2193
3027
|
|
|
2194
3028
|
// ../connect-sdk-ts/dist/receive/inbox-cipher.js
|
|
2195
3029
|
import { hkdf } from "@noble/hashes/hkdf";
|
|
@@ -2239,9 +3073,10 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
|
|
|
2239
3073
|
|
|
2240
3074
|
// ../connect-mls-store/dist/sqlite-store.js
|
|
2241
3075
|
import { DatabaseSync } from "node:sqlite";
|
|
3076
|
+
import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
|
|
2242
3077
|
|
|
2243
3078
|
// ../connect-mls-store/dist/schema.js
|
|
2244
|
-
var SCHEMA_VERSION =
|
|
3079
|
+
var SCHEMA_VERSION = 4;
|
|
2245
3080
|
function openSchema(db) {
|
|
2246
3081
|
db.exec("PRAGMA journal_mode=WAL");
|
|
2247
3082
|
const row = db.prepare("PRAGMA user_version").get();
|
|
@@ -2256,8 +3091,39 @@ function openSchema(db) {
|
|
|
2256
3091
|
PRIMARY KEY (identity_id, channel_id)
|
|
2257
3092
|
);
|
|
2258
3093
|
`);
|
|
2259
|
-
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2260
3094
|
}
|
|
3095
|
+
if (row.user_version < 2) {
|
|
3096
|
+
db.exec(`
|
|
3097
|
+
CREATE TABLE IF NOT EXISTS mls_identity_pin (
|
|
3098
|
+
owner_identity_id TEXT PRIMARY KEY,
|
|
3099
|
+
public_key_hex TEXT NOT NULL,
|
|
3100
|
+
key_algo TEXT NOT NULL,
|
|
3101
|
+
pinned_at INTEGER NOT NULL
|
|
3102
|
+
);
|
|
3103
|
+
`);
|
|
3104
|
+
}
|
|
3105
|
+
if (row.user_version < 3) {
|
|
3106
|
+
db.exec(`
|
|
3107
|
+
CREATE TABLE IF NOT EXISTS mls_retired_group (
|
|
3108
|
+
channel_id TEXT NOT NULL,
|
|
3109
|
+
group_id TEXT NOT NULL,
|
|
3110
|
+
PRIMARY KEY (channel_id, group_id)
|
|
3111
|
+
);
|
|
3112
|
+
`);
|
|
3113
|
+
}
|
|
3114
|
+
if (row.user_version < 4) {
|
|
3115
|
+
db.exec(`
|
|
3116
|
+
CREATE TABLE IF NOT EXISTS mls_pending_bundle (
|
|
3117
|
+
identity_id TEXT NOT NULL,
|
|
3118
|
+
ref TEXT NOT NULL,
|
|
3119
|
+
kp_id TEXT,
|
|
3120
|
+
sealed_bundle BLOB,
|
|
3121
|
+
updated_at INTEGER NOT NULL,
|
|
3122
|
+
PRIMARY KEY (identity_id, ref)
|
|
3123
|
+
);
|
|
3124
|
+
`);
|
|
3125
|
+
}
|
|
3126
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2261
3127
|
}
|
|
2262
3128
|
|
|
2263
3129
|
// ../connect-mls-store/dist/errors.js
|
|
@@ -2275,13 +3141,39 @@ var MlsStoreSealError = class extends Error {
|
|
|
2275
3141
|
};
|
|
2276
3142
|
|
|
2277
3143
|
// ../connect-mls-store/dist/sqlite-store.js
|
|
3144
|
+
function encodeBundle(bundle) {
|
|
3145
|
+
const pp = bundle.privatePackage;
|
|
3146
|
+
const obj = {
|
|
3147
|
+
pub: bytesToHex3(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
|
|
3148
|
+
initPriv: bytesToHex3(pp.initPrivateKey),
|
|
3149
|
+
hpkePriv: bytesToHex3(pp.hpkePrivateKey),
|
|
3150
|
+
sigPriv: bytesToHex3(pp.signaturePrivateKey)
|
|
3151
|
+
};
|
|
3152
|
+
return new TextEncoder().encode(JSON.stringify(obj));
|
|
3153
|
+
}
|
|
3154
|
+
function decodeBundle(bytes) {
|
|
3155
|
+
const obj = JSON.parse(new TextDecoder().decode(bytes));
|
|
3156
|
+
return {
|
|
3157
|
+
publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
|
|
3158
|
+
privatePackage: {
|
|
3159
|
+
initPrivateKey: hexToBytes3(obj.initPriv),
|
|
3160
|
+
hpkePrivateKey: hexToBytes3(obj.hpkePriv),
|
|
3161
|
+
signaturePrivateKey: hexToBytes3(obj.sigPriv)
|
|
3162
|
+
}
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
2278
3165
|
var SqliteMlsStateStore = class {
|
|
2279
3166
|
db;
|
|
2280
3167
|
id;
|
|
2281
3168
|
cipher;
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
3169
|
+
// In-memory write-through cache over the durable mls_pending_bundle table.
|
|
3170
|
+
// Persisting the bundle (schema v4) is the root-cause fix for restart-induced
|
|
3171
|
+
// KeyPackage rotation: with the bundle in-memory ONLY, every restart lost it,
|
|
3172
|
+
// re-minted+re-published a fresh KP, and orphaned every Welcome sealed to the
|
|
3173
|
+
// prior published KP. The cache avoids re-unsealing on every access; sqlite is
|
|
3174
|
+
// the source of truth across process restarts.
|
|
2284
3175
|
bundles = /* @__PURE__ */ new Map();
|
|
3176
|
+
lastKpIds = /* @__PURE__ */ new Map();
|
|
2285
3177
|
constructor(opts) {
|
|
2286
3178
|
this.id = opts.identityId;
|
|
2287
3179
|
this.cipher = opts.cipherFor(opts.identityId);
|
|
@@ -2350,12 +3242,74 @@ var SqliteMlsStateStore = class {
|
|
|
2350
3242
|
}
|
|
2351
3243
|
async savePendingBundle(ref, bundle) {
|
|
2352
3244
|
this.bundles.set(ref, bundle);
|
|
3245
|
+
const sealed = await this.cipher.seal(encodeBundle(bundle));
|
|
3246
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, sealed_bundle, updated_at)
|
|
3247
|
+
VALUES (?,?,?,?)
|
|
3248
|
+
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());
|
|
2353
3249
|
}
|
|
2354
3250
|
async loadPendingBundle(ref) {
|
|
2355
|
-
|
|
3251
|
+
const cached = this.bundles.get(ref);
|
|
3252
|
+
if (cached)
|
|
3253
|
+
return cached;
|
|
3254
|
+
const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
|
|
3255
|
+
if (!row?.sealed_bundle)
|
|
3256
|
+
return null;
|
|
3257
|
+
let bundle;
|
|
3258
|
+
try {
|
|
3259
|
+
bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
|
|
3260
|
+
} catch (e) {
|
|
3261
|
+
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
3262
|
+
console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
|
|
3263
|
+
}
|
|
3264
|
+
return null;
|
|
3265
|
+
}
|
|
3266
|
+
this.bundles.set(ref, bundle);
|
|
3267
|
+
return bundle;
|
|
2356
3268
|
}
|
|
2357
3269
|
async deletePendingBundle(ref) {
|
|
2358
3270
|
this.bundles.delete(ref);
|
|
3271
|
+
this.db.prepare("UPDATE mls_pending_bundle SET sealed_bundle=NULL, updated_at=? WHERE identity_id=? AND ref=?").run(Date.now(), this.id, ref);
|
|
3272
|
+
}
|
|
3273
|
+
async saveLastKpId(deviceId, id) {
|
|
3274
|
+
this.lastKpIds.set(deviceId, id);
|
|
3275
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, updated_at)
|
|
3276
|
+
VALUES (?,?,?,?)
|
|
3277
|
+
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());
|
|
3278
|
+
}
|
|
3279
|
+
async loadLastKpId(deviceId) {
|
|
3280
|
+
const cached = this.lastKpIds.get(deviceId);
|
|
3281
|
+
if (cached)
|
|
3282
|
+
return cached;
|
|
3283
|
+
const row = this.db.prepare("SELECT kp_id FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, deviceId);
|
|
3284
|
+
if (!row?.kp_id)
|
|
3285
|
+
return null;
|
|
3286
|
+
this.lastKpIds.set(deviceId, row.kp_id);
|
|
3287
|
+
return row.kp_id;
|
|
3288
|
+
}
|
|
3289
|
+
async saveLastKpAndBundle(deviceId, id, bundle) {
|
|
3290
|
+
this.lastKpIds.set(deviceId, id);
|
|
3291
|
+
this.bundles.set(deviceId, bundle);
|
|
3292
|
+
const sealed = await this.cipher.seal(encodeBundle(bundle));
|
|
3293
|
+
this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, sealed_bundle, updated_at)
|
|
3294
|
+
VALUES (?,?,?,?,?)
|
|
3295
|
+
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());
|
|
3296
|
+
}
|
|
3297
|
+
async savePinnedIdentityKey(identityId, key) {
|
|
3298
|
+
this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
|
|
3299
|
+
VALUES (?,?,?,?)
|
|
3300
|
+
ON CONFLICT(owner_identity_id) DO UPDATE SET
|
|
3301
|
+
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());
|
|
3302
|
+
}
|
|
3303
|
+
async loadPinnedIdentityKey(identityId) {
|
|
3304
|
+
const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
|
|
3305
|
+
return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
|
|
3306
|
+
}
|
|
3307
|
+
async addRetiredGroupId(channelId, groupId) {
|
|
3308
|
+
this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
|
|
3309
|
+
}
|
|
3310
|
+
async loadRetiredGroupIds(channelId) {
|
|
3311
|
+
const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
|
|
3312
|
+
return rows.map((r) => r.group_id);
|
|
2359
3313
|
}
|
|
2360
3314
|
/** @internal test-only */
|
|
2361
3315
|
_rawMainForTest(identityId, channelId) {
|
|
@@ -2519,6 +3473,80 @@ var TokenBucketFloor = class {
|
|
|
2519
3473
|
}
|
|
2520
3474
|
};
|
|
2521
3475
|
|
|
3476
|
+
// src/decrypt-error-log-limiter.ts
|
|
3477
|
+
var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
|
|
3478
|
+
var MAX_DISTINCT_PER_WINDOW = 4;
|
|
3479
|
+
function normalizeMessage(m) {
|
|
3480
|
+
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>");
|
|
3481
|
+
}
|
|
3482
|
+
var DecryptErrorLogLimiter = class {
|
|
3483
|
+
windowMs;
|
|
3484
|
+
now;
|
|
3485
|
+
log;
|
|
3486
|
+
windows = /* @__PURE__ */ new Map();
|
|
3487
|
+
constructor(opts = {}) {
|
|
3488
|
+
this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
|
|
3489
|
+
this.now = opts.now ?? Date.now;
|
|
3490
|
+
this.log = opts.log ?? ((line) => console.warn(line));
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3493
|
+
* Record one decrypt error for `channelId`. Logs immediately if this is
|
|
3494
|
+
* the channel's first error since its window opened (or its prior window
|
|
3495
|
+
* had already elapsed, in which case a summary for that just-closed
|
|
3496
|
+
* window is flushed first); otherwise the error is counted silently
|
|
3497
|
+
* toward the window's eventual summary line.
|
|
3498
|
+
*/
|
|
3499
|
+
record(channelId, info) {
|
|
3500
|
+
const t = this.now();
|
|
3501
|
+
const w = this.windows.get(channelId);
|
|
3502
|
+
if (!w) {
|
|
3503
|
+
this.logFirst(channelId, info);
|
|
3504
|
+
this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
if (t - w.windowStart >= this.windowMs) {
|
|
3508
|
+
this.flushSummary(channelId, w);
|
|
3509
|
+
this.logFirst(channelId, info);
|
|
3510
|
+
this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
w.count += 1;
|
|
3514
|
+
w.lastEpoch = info.epoch;
|
|
3515
|
+
w.lastMessage = info.message;
|
|
3516
|
+
const key = normalizeMessage(info.message);
|
|
3517
|
+
if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
|
|
3518
|
+
this.logFirst(channelId, info);
|
|
3519
|
+
w.loggedKeys.add(key);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Clear a channel's window (e.g. once the caller learns the channel
|
|
3524
|
+
* cleared/healed) so its NEXT error logs immediately as a fresh "first"
|
|
3525
|
+
* rather than being folded into a stale window's summary. There is
|
|
3526
|
+
* currently no onDecryptSuccess-style hook from the SDK to wire this to
|
|
3527
|
+
* automatically (see bridge-agent.ts's onDecryptError wiring comment) —
|
|
3528
|
+
* exposed here so a future success signal (or a caller with its own
|
|
3529
|
+
* channel-cleared signal) can reset explicitly. In its absence, the
|
|
3530
|
+
* window simply rolls over on interval elapse, which is an accepted
|
|
3531
|
+
* simplification (see report).
|
|
3532
|
+
*/
|
|
3533
|
+
reset(channelId) {
|
|
3534
|
+
this.windows.delete(channelId);
|
|
3535
|
+
}
|
|
3536
|
+
logFirst(channelId, info) {
|
|
3537
|
+
this.log(
|
|
3538
|
+
`[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
|
|
3539
|
+
);
|
|
3540
|
+
}
|
|
3541
|
+
flushSummary(channelId, w) {
|
|
3542
|
+
if (w.count <= 1) return;
|
|
3543
|
+
const seconds = Math.round(this.windowMs / 1e3);
|
|
3544
|
+
this.log(
|
|
3545
|
+
`[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
};
|
|
3549
|
+
|
|
2522
3550
|
// src/attachments.ts
|
|
2523
3551
|
import { basename } from "node:path";
|
|
2524
3552
|
function sanitizeFilename(name, fallback) {
|
|
@@ -2615,16 +3643,40 @@ var InvalidPathError = class extends Error {
|
|
|
2615
3643
|
if (cause !== void 0) this.cause = cause;
|
|
2616
3644
|
}
|
|
2617
3645
|
};
|
|
3646
|
+
var AgentPausedSendError = class extends Error {
|
|
3647
|
+
constructor(channelId) {
|
|
3648
|
+
super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
|
|
3649
|
+
this.channelId = channelId;
|
|
3650
|
+
this.name = "AgentPausedSendError";
|
|
3651
|
+
}
|
|
3652
|
+
};
|
|
3653
|
+
var UnknownActionRunError = class extends Error {
|
|
3654
|
+
constructor(runId) {
|
|
3655
|
+
super(`unknown or already-settled agent action run: ${runId}`);
|
|
3656
|
+
this.runId = runId;
|
|
3657
|
+
this.name = "UnknownActionRunError";
|
|
3658
|
+
}
|
|
3659
|
+
};
|
|
2618
3660
|
var BRIDGE_VERSION = "0.0.0";
|
|
2619
|
-
function
|
|
3661
|
+
function hexToBytes4(h) {
|
|
2620
3662
|
return new Uint8Array(Buffer.from(h, "hex"));
|
|
2621
3663
|
}
|
|
2622
3664
|
var BridgeAgent = class _BridgeAgent {
|
|
2623
3665
|
constructor(cfg, deps = {}) {
|
|
2624
3666
|
this.cfg = cfg;
|
|
2625
|
-
const seed =
|
|
3667
|
+
const seed = hexToBytes4(cfg.signingPrivateKey);
|
|
2626
3668
|
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
2627
|
-
this.transport = deps.transport ?? new RestTransport({
|
|
3669
|
+
this.transport = deps.transport ?? new RestTransport({
|
|
3670
|
+
baseUrl: cfg.apiBase,
|
|
3671
|
+
bearerToken: cfg.apiKey,
|
|
3672
|
+
connectIdentityId: cfg.identityId,
|
|
3673
|
+
// P1b: hardened delivery-ping reconnect (verify-before-terminal +
|
|
3674
|
+
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
3675
|
+
// SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
|
|
3676
|
+
hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
|
|
3677
|
+
onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
|
|
3678
|
+
`)
|
|
3679
|
+
});
|
|
2628
3680
|
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
2629
3681
|
this.store = new SqliteMlsStateStore({
|
|
2630
3682
|
dbPath: cfg.storePath,
|
|
@@ -2639,12 +3691,47 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2639
3691
|
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
2640
3692
|
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
2641
3693
|
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
3694
|
+
// P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
|
|
3695
|
+
// rather than a raw console.warn per failure — a wedged channel used to
|
|
3696
|
+
// redeliver the same undecryptable row on every pullDelivery cycle and
|
|
3697
|
+
// produced a 114 MB / 765,000-line daemon log in a single day. The
|
|
3698
|
+
// limiter still logs the first failure immediately and collapses every
|
|
3699
|
+
// repeat within 60s into one summary line; errorStats() below is fed
|
|
3700
|
+
// directly by the provider and is completely unaffected.
|
|
2642
3701
|
onDecryptError: (row, err) => {
|
|
2643
3702
|
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
3703
|
+
this.decryptErrorLogLimiter.record(cid, {
|
|
3704
|
+
epoch: row.epoch,
|
|
3705
|
+
queueId: row.queue_id,
|
|
3706
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3707
|
+
});
|
|
3708
|
+
},
|
|
3709
|
+
// P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
|
|
3710
|
+
// channel's decrypt-failure run crosses quarantineThreshold and its
|
|
3711
|
+
// stuck row has been ack-drained. Log one quarantine line, then
|
|
3712
|
+
// best-effort trigger the SAME held-KP re-Welcome heal used by
|
|
3713
|
+
// ingestOnce's Task-11 branch and selfHealSweep (see
|
|
3714
|
+
// healWedgedChannel below) — never throws back into the SDK caller.
|
|
3715
|
+
onWedged: (channelId) => {
|
|
3716
|
+
try {
|
|
3717
|
+
console.warn(
|
|
3718
|
+
`[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
|
|
3719
|
+
);
|
|
3720
|
+
void this.healWedgedChannel(channelId);
|
|
3721
|
+
} catch {
|
|
3722
|
+
}
|
|
3723
|
+
},
|
|
3724
|
+
// Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
|
|
3725
|
+
// calls this before honoring an owner-authored supersede. It must return
|
|
3726
|
+
// a value ONLY when the structural owner is unambiguous AND a pin exists
|
|
3727
|
+
// for it — never trust a bare in-memory cache without reconfirming
|
|
3728
|
+
// against the persisted pin (see resolvePinnedOwnerHook below).
|
|
3729
|
+
onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId),
|
|
3730
|
+
// P1a self-heal: a verified reset supersede whose Welcome we cannot open
|
|
3731
|
+
// (sealed to a stale published KeyPackage) — ask the committer to re-Welcome
|
|
3732
|
+
// us to the reset group with our CURRENT KeyPackage. Cooldown-guarded, never
|
|
3733
|
+
// throws (mirrors the divergence-heal signal path).
|
|
3734
|
+
onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
|
|
2648
3735
|
});
|
|
2649
3736
|
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
2650
3737
|
this.latency = deps.latency ?? new LatencyRecorder();
|
|
@@ -2653,6 +3740,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2653
3740
|
capacity: cfg.rateLimitCapacity ?? 20,
|
|
2654
3741
|
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
2655
3742
|
});
|
|
3743
|
+
this.actionClock = deps.actionThrottleNow ?? Date.now;
|
|
3744
|
+
this.rng = deps.rng ?? Math.random;
|
|
3745
|
+
this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
3746
|
+
this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
2656
3747
|
const self = this;
|
|
2657
3748
|
this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
|
|
2658
3749
|
const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
@@ -2672,6 +3763,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2672
3763
|
return String(j.attachment_id);
|
|
2673
3764
|
};
|
|
2674
3765
|
this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
|
|
3766
|
+
this.action = {
|
|
3767
|
+
begin: (channelId) => this.actionBegin(channelId),
|
|
3768
|
+
start: (opts) => this.actionStart(opts),
|
|
3769
|
+
update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
|
|
3770
|
+
finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
|
|
3771
|
+
settle: (runId, opts) => this.actionSettle(runId, opts),
|
|
3772
|
+
wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
|
|
3773
|
+
};
|
|
2675
3774
|
}
|
|
2676
3775
|
transport;
|
|
2677
3776
|
provider;
|
|
@@ -2701,6 +3800,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2701
3800
|
* per channel on a miss.
|
|
2702
3801
|
*/
|
|
2703
3802
|
channelRoster = /* @__PURE__ */ new Map();
|
|
3803
|
+
/**
|
|
3804
|
+
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
3805
|
+
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
3806
|
+
* the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
|
|
3807
|
+
* means the DM roster shape was not exactly one non-self, non-agent
|
|
3808
|
+
* counterpart, OR the freshly-fetched key no longer matches an existing pin —
|
|
3809
|
+
* either way the hook must fail closed (return null) for that channel.
|
|
3810
|
+
*/
|
|
3811
|
+
channelOwner = /* @__PURE__ */ new Map();
|
|
2704
3812
|
buffer = [];
|
|
2705
3813
|
maxBuffer;
|
|
2706
3814
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
@@ -2715,6 +3823,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2715
3823
|
rateLimitedSends = 0;
|
|
2716
3824
|
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
2717
3825
|
welcomeRequestsSent = 0;
|
|
3826
|
+
/** 2026-07-03 latency triage: cumulative delivery pings received on the WS
|
|
3827
|
+
* stream. 0 while messages still flow = the stream is dead and inbound is
|
|
3828
|
+
* riding the 25s safety floor (surfaced on health()/metrics). */
|
|
3829
|
+
pingsReceived = 0;
|
|
3830
|
+
/** True once close() begins — gates the ping-stream-death stderr warning so
|
|
3831
|
+
* a normal shutdown doesn't cry wolf. */
|
|
3832
|
+
closingDown = false;
|
|
2718
3833
|
/**
|
|
2719
3834
|
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
2720
3835
|
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
@@ -2725,7 +3840,94 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2725
3840
|
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
2726
3841
|
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
2727
3842
|
static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
|
|
3843
|
+
// H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
|
|
3844
|
+
static GRACEFUL_DRAIN_MS = 5e3;
|
|
3845
|
+
/**
|
|
3846
|
+
* P0b (daemon-stability): collapses the onDecryptError console.warn from
|
|
3847
|
+
* "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
|
|
3848
|
+
* most one line immediately + one summary line per 60s per channel. LOG
|
|
3849
|
+
* OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
|
|
3850
|
+
* live entirely in the SDK provider and are unaffected by this class.
|
|
3851
|
+
*/
|
|
3852
|
+
decryptErrorLogLimiter = new DecryptErrorLogLimiter();
|
|
3853
|
+
/**
|
|
3854
|
+
* Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
|
|
3855
|
+
* only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
|
|
3856
|
+
* rows — an idle-but-wiped channel therefore loses its NEXT message (the
|
|
3857
|
+
* very message that would have proven it diverged). selfHealSweep() closes
|
|
3858
|
+
* that gap by proactively walking every non-DM member channel at boot and
|
|
3859
|
+
* on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
|
|
3860
|
+
* back to the delivery-row-driven Task 11 behaviour only.
|
|
3861
|
+
*/
|
|
3862
|
+
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
3863
|
+
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
3864
|
+
static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
3865
|
+
// C3: add up to this much random jitter to BOTH the boot sweep delay and each
|
|
3866
|
+
// periodic interval, so a fleet of daemons restarting/sweeping together does not
|
|
3867
|
+
// fire re-Welcome signals at the SAME instant and thundering-herd the single
|
|
3868
|
+
// committer (the observed 409 commit-lease saturation). Decorrelation only —
|
|
3869
|
+
// C1 already removed the restart KP-rotation storm that caused the herd.
|
|
3870
|
+
static SELF_HEAL_SWEEP_JITTER_MS = 3e4;
|
|
3871
|
+
selfHealTimer;
|
|
3872
|
+
bootSweepTimer;
|
|
3873
|
+
rng;
|
|
2728
3874
|
rateFloor;
|
|
3875
|
+
/**
|
|
3876
|
+
* agent.action.* run state, keyed by runId.
|
|
3877
|
+
*
|
|
3878
|
+
* Review M3 (bug fix): the ORIGINAL comment here said "deliberately
|
|
3879
|
+
* unbounded" — but nothing ever bounded it either. Only actionSettle()
|
|
3880
|
+
* deleted an entry; a begun-but-never-settled run (agent crash, bug,
|
|
3881
|
+
* dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
|
|
3882
|
+
* bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
|
|
3883
|
+
* lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
|
|
3884
|
+
* mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
|
|
3885
|
+
*/
|
|
3886
|
+
actionRuns = /* @__PURE__ */ new Map();
|
|
3887
|
+
/** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
|
|
3888
|
+
* evicts the oldest (LRU by startedAt) to make room — never grows past
|
|
3889
|
+
* this regardless of how many runs are begun and abandoned. */
|
|
3890
|
+
static MAX_UNSETTLED_ACTION_RUNS = 50;
|
|
3891
|
+
/** Review M3: an unsettled run older than this (by startedAt, relative to
|
|
3892
|
+
* actionClock()) is swept away on the next actionBegin(), independent of
|
|
3893
|
+
* the count-based cap — bounds memory even when begin() calls are rare. */
|
|
3894
|
+
static ACTION_RUN_STALE_MS = 60 * 6e4;
|
|
3895
|
+
// 60 minutes
|
|
3896
|
+
/** Review I3/M3: injected clock (defaults Date.now) for the action-partial
|
|
3897
|
+
* token buckets AND the stale-run sweep — a single seam so tests can
|
|
3898
|
+
* simulate elapsed time deterministically. */
|
|
3899
|
+
actionClock;
|
|
3900
|
+
/**
|
|
3901
|
+
* Per-run token bucket for `agent_action` PARTIAL frames (Global
|
|
3902
|
+
* Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
|
|
3903
|
+
* `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
|
|
3904
|
+
* capacity" — partials instead COALESCE: a throttled call just leaves its
|
|
3905
|
+
* change in `dirty` for the next allowed send to pick up).
|
|
3906
|
+
*
|
|
3907
|
+
* Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
|
|
3908
|
+
* backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
|
|
3909
|
+
* the settled frame + the agent's real reply + reportStatus all compete
|
|
3910
|
+
* for that one shared per-sender budget, so a long run's settled frame or
|
|
3911
|
+
* reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
|
|
3912
|
+
* partials alone use at most ~half the identity's send budget, leaving
|
|
3913
|
+
* headroom for the settled frame / reply / status traffic riding the SAME
|
|
3914
|
+
* cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
|
|
3915
|
+
* `settled` frames bypass this bucket entirely — they always send, exactly
|
|
3916
|
+
* once, per run.
|
|
3917
|
+
*/
|
|
3918
|
+
actionRateFloor;
|
|
3919
|
+
/**
|
|
3920
|
+
* Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
|
|
3921
|
+
* the per-run bucket above. Without this, N concurrent runs each get their
|
|
3922
|
+
* OWN ~30/min budget, so the partial send rate multiplies with agent
|
|
3923
|
+
* concurrency and can still blow past the shared 60/min identity edge cap
|
|
3924
|
+
* (each run individually "has headroom" but the SUM doesn't). Same
|
|
3925
|
+
* capacity/refill as the per-run bucket — it caps the aggregate, not any
|
|
3926
|
+
* single run.
|
|
3927
|
+
*/
|
|
3928
|
+
globalActionRateFloor;
|
|
3929
|
+
/** Public `agent.action.*` surface — assigned once at the end of the constructor. */
|
|
3930
|
+
action;
|
|
2729
3931
|
waiters = [];
|
|
2730
3932
|
pingHandle;
|
|
2731
3933
|
safetyTimer;
|
|
@@ -2762,6 +3964,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2762
3964
|
this.startedAt = Date.now();
|
|
2763
3965
|
await this.provider.publishKeyPackage();
|
|
2764
3966
|
this.startReceivePump();
|
|
3967
|
+
if (this.selfHealSweepEnabled) {
|
|
3968
|
+
this.bootSweepTimer = setTimeout(() => void this.selfHealSweep(), this.sweepJitter());
|
|
3969
|
+
this.bootSweepTimer.unref?.();
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
/** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
|
|
3973
|
+
sweepJitter() {
|
|
3974
|
+
return Math.floor(this.rng() * _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS);
|
|
2765
3975
|
}
|
|
2766
3976
|
async reconcileJoin(channelId) {
|
|
2767
3977
|
if (this.joinState.get(channelId) === "JOINED") return true;
|
|
@@ -2774,6 +3984,145 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2774
3984
|
return false;
|
|
2775
3985
|
}
|
|
2776
3986
|
}
|
|
3987
|
+
/**
|
|
3988
|
+
* The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
|
|
3989
|
+
* reactive no-local-state branch in ingestOnce(), the proactive
|
|
3990
|
+
* selfHealSweep() (Task 12), the onWedged forked-state recovery
|
|
3991
|
+
* (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
|
|
3992
|
+
*
|
|
3993
|
+
* Before asking the committer to (re-)Welcome us, it RECONCILES the server's
|
|
3994
|
+
* published KeyPackage to our held bundle. This is the fix for the observed
|
|
3995
|
+
* "fresh DM never replies" AND "reset doesn't work": the committer seals every
|
|
3996
|
+
* Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
|
|
3997
|
+
* locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
|
|
3998
|
+
* "private KeyPackage key no longer held" and the Welcome is discarded. A bare
|
|
3999
|
+
* re-Welcome re-seals to the SAME diverged published KP — un-openable again —
|
|
4000
|
+
* looping until the channel quarantines. So:
|
|
4001
|
+
* - published == held → publishKeyPackage() (no-op while held; mints only if
|
|
4002
|
+
* somehow none is held), then re-Welcome re-seals to the current KP we hold.
|
|
4003
|
+
* - published != held → forceRepublishKeyPackage() re-syncs the server to a
|
|
4004
|
+
* bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
|
|
4005
|
+
* diverged published KP means every other pending Welcome sealed via the
|
|
4006
|
+
* server is un-openable too, so replacing the shared per-device bundle
|
|
4007
|
+
* strands nothing that was openable (see heldBundleMatchesPublishedKp).
|
|
4008
|
+
* getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
|
|
4009
|
+
* Callers own their cooldown/rate-limit gate and try/catch — this does neither.
|
|
4010
|
+
*/
|
|
4011
|
+
async signalHeldKpWelcome(groupId) {
|
|
4012
|
+
if (await this.provider.heldBundleMatchesPublishedKp()) {
|
|
4013
|
+
await this.provider.publishKeyPackage();
|
|
4014
|
+
} else {
|
|
4015
|
+
await this.provider.forceRepublishKeyPackage();
|
|
4016
|
+
}
|
|
4017
|
+
const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
|
|
4018
|
+
await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
|
|
4019
|
+
this.welcomeRequestsSent++;
|
|
4020
|
+
}
|
|
4021
|
+
/**
|
|
4022
|
+
* P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
|
|
4023
|
+
* `connect_channel_id` — resolve it to an MLS groupId the SAME way
|
|
4024
|
+
* selfHealSweep does, via the read-only getConnectChannelGroup probe
|
|
4025
|
+
* (never initConnectChannelGroup, which side-effects a member_device_ids
|
|
4026
|
+
* union + commit), then fire the existing held-KP re-Welcome signal.
|
|
4027
|
+
* Respects the SAME per-channel cooldown (lastWelcomeRequest /
|
|
4028
|
+
* REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
|
|
4029
|
+
* selfHealSweep — this must never hammer requestWelcome on its own.
|
|
4030
|
+
*
|
|
4031
|
+
* Best-effort in every sense: if the channel has no resolvable group (no
|
|
4032
|
+
* local state to probe from), this logs nothing further and just returns —
|
|
4033
|
+
* the channel is left to the next selfHealSweep pass or reactive Task-11
|
|
4034
|
+
* branch, mirroring how those two paths already treat an unresolvable
|
|
4035
|
+
* group (`continue`, no signal). ANY failure along the way (probe throws,
|
|
4036
|
+
* signal throws) is swallowed: a wedge heal must never throw back into the
|
|
4037
|
+
* SDK's onWedged caller. Single-shot per wedge episode — full rejoin
|
|
4038
|
+
* reliability is P1, not this hook's job.
|
|
4039
|
+
*/
|
|
4040
|
+
async healWedgedChannel(channelId) {
|
|
4041
|
+
try {
|
|
4042
|
+
const now = performance.now();
|
|
4043
|
+
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4044
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
|
|
4045
|
+
const g = await this.backend.getConnectChannelGroup(channelId);
|
|
4046
|
+
if (!g?.group_id) return;
|
|
4047
|
+
this.lastWelcomeRequest.set(channelId, now);
|
|
4048
|
+
if (await this.provider.hasPersistedChannelState(channelId)) {
|
|
4049
|
+
await this.provider.resetChannelForRejoin(channelId);
|
|
4050
|
+
this.joinState.delete(channelId);
|
|
4051
|
+
}
|
|
4052
|
+
await this.signalHeldKpWelcome(g.group_id);
|
|
4053
|
+
} catch {
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
/**
|
|
4057
|
+
* P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
|
|
4058
|
+
* Welcome we cannot open — it was sealed to a stale published KeyPackage our
|
|
4059
|
+
* held bundle no longer matches ("No matching secret found"). trySupersede has
|
|
4060
|
+
* verified owner + attestation + signature, so this is a real reset we WANT; we
|
|
4061
|
+
* just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
|
|
4062
|
+
* `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
|
|
4063
|
+
* a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
|
|
4064
|
+
* and the reset converges. The groupId is known (handed in), so no probe needed.
|
|
4065
|
+
*
|
|
4066
|
+
* Returns true ONLY when a re-Welcome was actually requested — trySupersede then
|
|
4067
|
+
* discards the un-openable row. Under the shared per-channel cooldown (or on any
|
|
4068
|
+
* failure) returns false, so trySupersede LEAVES the row for a bounded retry
|
|
4069
|
+
* rather than dropping it with no replacement in flight. Never throws.
|
|
4070
|
+
*/
|
|
4071
|
+
async healSupersedeUnopenable(channelId, groupId) {
|
|
4072
|
+
try {
|
|
4073
|
+
const now = performance.now();
|
|
4074
|
+
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
4075
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return false;
|
|
4076
|
+
this.lastWelcomeRequest.set(channelId, now);
|
|
4077
|
+
await this.signalHeldKpWelcome(groupId);
|
|
4078
|
+
return true;
|
|
4079
|
+
} catch {
|
|
4080
|
+
return false;
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
/**
|
|
4084
|
+
* Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
|
|
4085
|
+
* branch only fires reactively — off a delivery ROW that already arrived
|
|
4086
|
+
* for a diverged channel. A wiped-but-IDLE channel (no traffic since the
|
|
4087
|
+
* wipe) never produces that row, so its next real message would be the
|
|
4088
|
+
* first thing lost. This sweep walks every member channel — rooms AND DMs
|
|
4089
|
+
* (H2: a wiped, idle DM with no traffic since the wipe otherwise never
|
|
4090
|
+
* proactively re-Welcomes, so it stays dark until the owner happens to send)
|
|
4091
|
+
* — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
|
|
4092
|
+
* persisted state even after a reconcile attempt, resolves group_id via
|
|
4093
|
+
* the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
|
|
4094
|
+
* which would side-effect a member_device_ids union + commit) and applies
|
|
4095
|
+
* the same held-KP signal — respecting the existing per-channel cooldown.
|
|
4096
|
+
*
|
|
4097
|
+
* Each channel is wrapped in its own try/catch: `listChannels` filters at
|
|
4098
|
+
* the identity level while `requestWelcome` (via request_welcome) gates at
|
|
4099
|
+
* the device level, so a channel where this identity is a member but this
|
|
4100
|
+
* DEVICE never held a leaf can 403 on the signal call. That's harmless and
|
|
4101
|
+
* must not abort the sweep for every other channel.
|
|
4102
|
+
*/
|
|
4103
|
+
async selfHealSweep() {
|
|
4104
|
+
let chans;
|
|
4105
|
+
try {
|
|
4106
|
+
chans = await this.listChannels();
|
|
4107
|
+
} catch {
|
|
4108
|
+
return;
|
|
4109
|
+
}
|
|
4110
|
+
for (const c of chans) {
|
|
4111
|
+
try {
|
|
4112
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4113
|
+
await this.reconcileJoin(c.channel_id);
|
|
4114
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
4115
|
+
const g = await this.backend.getConnectChannelGroup(c.channel_id);
|
|
4116
|
+
if (!g?.group_id) continue;
|
|
4117
|
+
const now = performance.now();
|
|
4118
|
+
const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
|
|
4119
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
4120
|
+
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
4121
|
+
await this.signalHeldKpWelcome(g.group_id);
|
|
4122
|
+
} catch {
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
2777
4126
|
/**
|
|
2778
4127
|
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
2779
4128
|
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
@@ -2786,6 +4135,40 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2786
4135
|
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
2787
4136
|
}
|
|
2788
4137
|
}
|
|
4138
|
+
/**
|
|
4139
|
+
* The ONE seam every application-send path (send/sendA2A/sendAttachment)
|
|
4140
|
+
* calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
|
|
4141
|
+
* daemon's per-room-pause terminal classification (Task 9).
|
|
4142
|
+
*
|
|
4143
|
+
* `AgentPausedError` (Task 8's SDK classification of the backend's 403
|
|
4144
|
+
* `agent_paused`) means an owner/admin has deliberately muted this agent in
|
|
4145
|
+
* this room — NOT a transient/network failure. That distinction MUST be
|
|
4146
|
+
* made here, before control returns to any caller, so:
|
|
4147
|
+
* - it never reaches a heal/self-heal trigger (healWedgedChannel /
|
|
4148
|
+
* onWedged re-Welcome are wired off the RECEIVE path via SDK provider
|
|
4149
|
+
* callbacks, not off a send failure — this seam guarantees a send
|
|
4150
|
+
* failure never grows one either, now or as the code evolves), and
|
|
4151
|
+
* - it never gets treated as a generic/retryable failure by a caller —
|
|
4152
|
+
* rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
|
|
4153
|
+
* ChannelPendingError/RateLimitedError, both also bridge-agent.ts
|
|
4154
|
+
* locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
|
|
4155
|
+
* same way they already do for those two, instead of it falling into
|
|
4156
|
+
* their generic "internal error" bucket.
|
|
4157
|
+
* Any other error (including StaleEpochError retries, which sendWithSelfHeal
|
|
4158
|
+
* already resolves internally) propagates unchanged.
|
|
4159
|
+
*/
|
|
4160
|
+
async sendWithSelfHealTerminal(...args2) {
|
|
4161
|
+
const [, channelId] = args2;
|
|
4162
|
+
try {
|
|
4163
|
+
return await sendWithSelfHeal(...args2);
|
|
4164
|
+
} catch (e) {
|
|
4165
|
+
if (e instanceof AgentPausedError) {
|
|
4166
|
+
console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
|
|
4167
|
+
throw new AgentPausedSendError(channelId);
|
|
4168
|
+
}
|
|
4169
|
+
throw e;
|
|
4170
|
+
}
|
|
4171
|
+
}
|
|
2789
4172
|
async send(channelId, text, messageId) {
|
|
2790
4173
|
const meta = this.freshMeta(channelId);
|
|
2791
4174
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
@@ -2793,7 +4176,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2793
4176
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2794
4177
|
const id = messageId ?? crypto.randomUUID();
|
|
2795
4178
|
const sendStart = performance.now();
|
|
2796
|
-
await
|
|
4179
|
+
await this.sendWithSelfHealTerminal(
|
|
2797
4180
|
this.provider,
|
|
2798
4181
|
channelId,
|
|
2799
4182
|
(epoch) => this.latency.time(
|
|
@@ -2829,7 +4212,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2829
4212
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
2830
4213
|
const id = messageId ?? crypto.randomUUID();
|
|
2831
4214
|
const sendStart = performance.now();
|
|
2832
|
-
await
|
|
4215
|
+
await this.sendWithSelfHealTerminal(
|
|
2833
4216
|
this.provider,
|
|
2834
4217
|
channelId,
|
|
2835
4218
|
(epoch) => this.latency.time(
|
|
@@ -2882,7 +4265,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2882
4265
|
}
|
|
2883
4266
|
const id = crypto.randomUUID();
|
|
2884
4267
|
const sendStart = performance.now();
|
|
2885
|
-
await
|
|
4268
|
+
await this.sendWithSelfHealTerminal(
|
|
2886
4269
|
this.provider,
|
|
2887
4270
|
channelId,
|
|
2888
4271
|
(epoch) => this.latency.time(
|
|
@@ -2979,9 +4362,77 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
2979
4362
|
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
2980
4363
|
}
|
|
2981
4364
|
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
4365
|
+
await this.resolveAndPinOwner(channelId, rows);
|
|
2982
4366
|
} catch {
|
|
2983
4367
|
}
|
|
2984
4368
|
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
|
|
4371
|
+
* NEVER read the server's `role` field to pick the owner. In a DM the owner
|
|
4372
|
+
* is the SINGLE non-self, non-agent counterpart; any other roster shape
|
|
4373
|
+
* (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
|
|
4374
|
+
* supersede eligibility). On the first clean sighting of an unambiguous
|
|
4375
|
+
* owner with no existing pin, TOFU-pin its public key. On a later sighting
|
|
4376
|
+
* where a pin already exists and the freshly-fetched key differs, this is an
|
|
4377
|
+
* anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
|
|
4378
|
+
* this must never throw into the roster refresh path.
|
|
4379
|
+
*/
|
|
4380
|
+
async resolveAndPinOwner(channelId, rows) {
|
|
4381
|
+
try {
|
|
4382
|
+
const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
|
|
4383
|
+
if (counterparts.length !== 1) {
|
|
4384
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
const owner = counterparts[0];
|
|
4388
|
+
const publicKeyHex = owner.public_key ?? "";
|
|
4389
|
+
if (!publicKeyHex) {
|
|
4390
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4391
|
+
return;
|
|
4392
|
+
}
|
|
4393
|
+
this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
|
|
4394
|
+
const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
|
|
4395
|
+
if (!existing) {
|
|
4396
|
+
await this.store.savePinnedIdentityKey(owner.identity_id, {
|
|
4397
|
+
publicKeyHex,
|
|
4398
|
+
keyAlgo: owner.key_algo ?? "ed25519"
|
|
4399
|
+
});
|
|
4400
|
+
} else if (existing.publicKeyHex !== publicKeyHex) {
|
|
4401
|
+
console.warn(
|
|
4402
|
+
`[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
|
|
4403
|
+
);
|
|
4404
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
4405
|
+
}
|
|
4406
|
+
} catch {
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
/**
|
|
4410
|
+
* Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
|
|
4411
|
+
* SDK's step-0 guard calls this before honoring an owner-authored supersede;
|
|
4412
|
+
* returning null makes it fail closed. Returns a value ONLY when (a) the
|
|
4413
|
+
* structural owner cache has an unambiguous entry for this channel AND (b) a
|
|
4414
|
+
* persisted pin still exists and matches it — a bare in-memory cache hit is
|
|
4415
|
+
* never trusted on its own, it is always reconfirmed against the store.
|
|
4416
|
+
*/
|
|
4417
|
+
async resolvePinnedOwnerHook(channelId) {
|
|
4418
|
+
const cached = this.channelOwner.get(channelId);
|
|
4419
|
+
const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
|
|
4420
|
+
if (!cached || cached === "anomalous") {
|
|
4421
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
|
|
4422
|
+
return null;
|
|
4423
|
+
}
|
|
4424
|
+
const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
|
|
4425
|
+
if (!pin) {
|
|
4426
|
+
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)})`);
|
|
4427
|
+
return null;
|
|
4428
|
+
}
|
|
4429
|
+
if (pin.publicKeyHex !== cached.publicKeyHex) {
|
|
4430
|
+
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)}`);
|
|
4431
|
+
return null;
|
|
4432
|
+
}
|
|
4433
|
+
if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
|
|
4434
|
+
return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
|
|
4435
|
+
}
|
|
2985
4436
|
async reportStatus(channelId, state, detail) {
|
|
2986
4437
|
const joined = await this.reconcileJoin(channelId);
|
|
2987
4438
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
@@ -3016,20 +4467,213 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3016
4467
|
(envelope) => this.transport.publish(envelope)
|
|
3017
4468
|
);
|
|
3018
4469
|
}
|
|
4470
|
+
/** `runId` must exist and not have settled yet — settle() is terminal. */
|
|
4471
|
+
requireActionRun(runId) {
|
|
4472
|
+
const run = this.actionRuns.get(runId);
|
|
4473
|
+
if (!run || run.settled) throw new UnknownActionRunError(runId);
|
|
4474
|
+
return run;
|
|
4475
|
+
}
|
|
4476
|
+
/**
|
|
4477
|
+
* Build + send one `agent_action` E2E body over `system_event` (mirrors
|
|
4478
|
+
* sendA2A/reportStatus's envelope construction — same self-heal seam). Used
|
|
4479
|
+
* for BOTH partial and settled frames; the caller decides whether a send is
|
|
4480
|
+
* throttle-gated (partials, via flushActionPartial) or unconditional
|
|
4481
|
+
* (settled, always exactly once).
|
|
4482
|
+
*/
|
|
4483
|
+
async sendActionFrame(channelId, body) {
|
|
4484
|
+
const joined = await this.reconcileJoin(channelId);
|
|
4485
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
4486
|
+
await sendWithSelfHeal(
|
|
4487
|
+
this.provider,
|
|
4488
|
+
channelId,
|
|
4489
|
+
(epoch) => buildOutboundEnvelope({
|
|
4490
|
+
custody: this.custody,
|
|
4491
|
+
groupKey: this.provider,
|
|
4492
|
+
messageId: crypto.randomUUID(),
|
|
4493
|
+
channelId,
|
|
4494
|
+
enclaveId: this.cfg.enclaveId,
|
|
4495
|
+
senderId: this.cfg.identityId,
|
|
4496
|
+
senderType: "agent",
|
|
4497
|
+
type: "system_event",
|
|
4498
|
+
bodyPlaintext: JSON.stringify(body),
|
|
4499
|
+
bindingId: "",
|
|
4500
|
+
bindingVersion: "0.0.0",
|
|
4501
|
+
agentSkillId: null,
|
|
4502
|
+
epoch,
|
|
4503
|
+
traceparent: ZERO_TRACEPARENT,
|
|
4504
|
+
references: [],
|
|
4505
|
+
stage5Outcome: "allow",
|
|
4506
|
+
stage5Evaluators: []
|
|
4507
|
+
}),
|
|
4508
|
+
(envelope) => this.transport.publish(envelope)
|
|
4509
|
+
);
|
|
4510
|
+
}
|
|
4511
|
+
/**
|
|
4512
|
+
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
4513
|
+
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
4514
|
+
* no-op too — the change stays in `dirty` and rides along with whatever the
|
|
4515
|
+
* NEXT allowed send captures (a later start/update/finish call, or settle(),
|
|
4516
|
+
* which always flushes regardless of the bucket). This is the coalescing:
|
|
4517
|
+
* a burst of rapid calls collapses into however many sends the ~1/sec floor
|
|
4518
|
+
* actually allows, each carrying every node changed since the last send.
|
|
4519
|
+
*/
|
|
4520
|
+
async flushActionPartial(runId, run) {
|
|
4521
|
+
if (run.dirty.size === 0) return;
|
|
4522
|
+
if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
|
|
4523
|
+
if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
|
|
4524
|
+
const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
|
|
4525
|
+
run.dirty.clear();
|
|
4526
|
+
await this.sendActionFrame(run.channelId, {
|
|
4527
|
+
v: 1,
|
|
4528
|
+
type: "agent_action",
|
|
4529
|
+
agentActionVersion: "1",
|
|
4530
|
+
phase: "partial",
|
|
4531
|
+
runId,
|
|
4532
|
+
nodes
|
|
4533
|
+
});
|
|
4534
|
+
}
|
|
4535
|
+
/** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
|
|
4536
|
+
* (relative to `now`). Lazy — invoked on every actionBegin() rather than
|
|
4537
|
+
* a background timer, mirroring the client store's orphan-TTL pattern. */
|
|
4538
|
+
sweepStaleActionRuns(now) {
|
|
4539
|
+
for (const [runId, run] of this.actionRuns) {
|
|
4540
|
+
if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
actionBegin(channelId) {
|
|
4544
|
+
const now = this.actionClock();
|
|
4545
|
+
this.sweepStaleActionRuns(now);
|
|
4546
|
+
if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
|
|
4547
|
+
let oldestId;
|
|
4548
|
+
let oldestAt = Infinity;
|
|
4549
|
+
for (const [id, r] of this.actionRuns) {
|
|
4550
|
+
if (r.startedAt < oldestAt) {
|
|
4551
|
+
oldestAt = r.startedAt;
|
|
4552
|
+
oldestId = id;
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
if (oldestId !== void 0) this.actionRuns.delete(oldestId);
|
|
4556
|
+
}
|
|
4557
|
+
const runId = crypto.randomUUID();
|
|
4558
|
+
this.actionRuns.set(runId, {
|
|
4559
|
+
channelId,
|
|
4560
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
4561
|
+
dirty: /* @__PURE__ */ new Set(),
|
|
4562
|
+
startedAt: now,
|
|
4563
|
+
settled: false
|
|
4564
|
+
});
|
|
4565
|
+
return runId;
|
|
4566
|
+
}
|
|
4567
|
+
async actionStart(opts) {
|
|
4568
|
+
const run = this.requireActionRun(opts.runId);
|
|
4569
|
+
const nodeId = crypto.randomUUID();
|
|
4570
|
+
const node = {
|
|
4571
|
+
id: nodeId,
|
|
4572
|
+
type: opts.type,
|
|
4573
|
+
title: opts.title,
|
|
4574
|
+
status: "running",
|
|
4575
|
+
ts: Date.now(),
|
|
4576
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
4577
|
+
};
|
|
4578
|
+
run.nodes.set(nodeId, node);
|
|
4579
|
+
run.dirty.add(nodeId);
|
|
4580
|
+
await this.flushActionPartial(opts.runId, run);
|
|
4581
|
+
return nodeId;
|
|
4582
|
+
}
|
|
4583
|
+
async actionUpdate(runId, nodeId, patch) {
|
|
4584
|
+
const run = this.requireActionRun(runId);
|
|
4585
|
+
const existing = run.nodes.get(nodeId);
|
|
4586
|
+
if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
|
|
4587
|
+
const updated = {
|
|
4588
|
+
...existing,
|
|
4589
|
+
...patch.status !== void 0 ? { status: patch.status } : {},
|
|
4590
|
+
...patch.detail !== void 0 ? { detail: patch.detail } : {},
|
|
4591
|
+
ts: Date.now()
|
|
4592
|
+
};
|
|
4593
|
+
run.nodes.set(nodeId, updated);
|
|
4594
|
+
run.dirty.add(nodeId);
|
|
4595
|
+
await this.flushActionPartial(runId, run);
|
|
4596
|
+
}
|
|
4597
|
+
async actionFinish(runId, nodeId, patch) {
|
|
4598
|
+
await this.actionUpdate(runId, nodeId, patch);
|
|
4599
|
+
}
|
|
4600
|
+
async actionSettle(runId, opts = {}) {
|
|
4601
|
+
const run = this.requireActionRun(runId);
|
|
4602
|
+
run.settled = true;
|
|
4603
|
+
run.dirty.clear();
|
|
4604
|
+
const nodes = [...run.nodes.values()];
|
|
4605
|
+
await this.sendActionFrame(run.channelId, {
|
|
4606
|
+
v: 1,
|
|
4607
|
+
type: "agent_action",
|
|
4608
|
+
agentActionVersion: "1",
|
|
4609
|
+
phase: "settled",
|
|
4610
|
+
runId,
|
|
4611
|
+
startedAt: run.startedAt,
|
|
4612
|
+
endedAt: Date.now(),
|
|
4613
|
+
...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
|
|
4614
|
+
nodes
|
|
4615
|
+
});
|
|
4616
|
+
this.actionRuns.delete(runId);
|
|
4617
|
+
}
|
|
4618
|
+
/**
|
|
4619
|
+
* Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
|
|
4620
|
+
* invocation so a bare agent gets a start→finish node pair without hand-
|
|
4621
|
+
* rolling the begin/start/update/finish calls itself. Success → status
|
|
4622
|
+
* "done"; a thrown error → status "error" with the message captured in
|
|
4623
|
+
* `detail.error`, then the original error is rethrown (wrap never swallows).
|
|
4624
|
+
*/
|
|
4625
|
+
async actionWrap(runId, fn, opts) {
|
|
4626
|
+
const nodeId = await this.actionStart({
|
|
4627
|
+
runId,
|
|
4628
|
+
type: opts.type,
|
|
4629
|
+
title: opts.title,
|
|
4630
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
4631
|
+
});
|
|
4632
|
+
try {
|
|
4633
|
+
const result = await fn();
|
|
4634
|
+
await this.actionFinish(runId, nodeId, { status: "done" });
|
|
4635
|
+
return result;
|
|
4636
|
+
} catch (err) {
|
|
4637
|
+
await this.actionFinish(runId, nodeId, {
|
|
4638
|
+
status: "error",
|
|
4639
|
+
detail: { error: err instanceof Error ? err.message : String(err) }
|
|
4640
|
+
});
|
|
4641
|
+
throw err;
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
3019
4644
|
startReceivePump() {
|
|
3020
4645
|
const sub = this.transport.subscribeDeliveryPings("");
|
|
3021
4646
|
this.pingHandle = { close: sub.close };
|
|
3022
4647
|
void (async () => {
|
|
3023
4648
|
try {
|
|
3024
|
-
for await (const
|
|
4649
|
+
for await (const ping of sub.pings) {
|
|
4650
|
+
this.pingsReceived++;
|
|
4651
|
+
if (ping?.reconnected) {
|
|
4652
|
+
process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
|
|
4653
|
+
}
|
|
3025
4654
|
await this.ingestOnce();
|
|
3026
4655
|
}
|
|
3027
4656
|
} catch {
|
|
3028
4657
|
}
|
|
4658
|
+
if (!this.closingDown) {
|
|
4659
|
+
process.stderr.write(
|
|
4660
|
+
"[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"
|
|
4661
|
+
);
|
|
4662
|
+
}
|
|
3029
4663
|
})();
|
|
3030
4664
|
this.safetyTimer = setInterval(() => {
|
|
3031
4665
|
void this.ingestOnce();
|
|
3032
4666
|
}, 25e3);
|
|
4667
|
+
if (this.selfHealSweepEnabled) {
|
|
4668
|
+
const scheduleSweep = () => {
|
|
4669
|
+
this.selfHealTimer = setTimeout(() => {
|
|
4670
|
+
void this.selfHealSweep();
|
|
4671
|
+
scheduleSweep();
|
|
4672
|
+
}, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
|
|
4673
|
+
this.selfHealTimer.unref?.();
|
|
4674
|
+
};
|
|
4675
|
+
scheduleSweep();
|
|
4676
|
+
}
|
|
3033
4677
|
}
|
|
3034
4678
|
async ingestOnce() {
|
|
3035
4679
|
const cycleStart = performance.now();
|
|
@@ -3044,6 +4688,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3044
4688
|
await this.reconcileJoin(cid);
|
|
3045
4689
|
}
|
|
3046
4690
|
}
|
|
4691
|
+
for (const cid of channelIds) {
|
|
4692
|
+
if (this.freshRoster(cid) === void 0) {
|
|
4693
|
+
await this.refreshChannelRoster(cid);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
3047
4696
|
for (const row of rows) {
|
|
3048
4697
|
const cid = row.connect_channel_id;
|
|
3049
4698
|
const gid = row.group_id;
|
|
@@ -3054,8 +4703,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3054
4703
|
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
3055
4704
|
this.lastWelcomeRequest.set(cid, now);
|
|
3056
4705
|
try {
|
|
3057
|
-
await this.
|
|
3058
|
-
this.welcomeRequestsSent++;
|
|
4706
|
+
await this.signalHeldKpWelcome(gid);
|
|
3059
4707
|
} catch {
|
|
3060
4708
|
}
|
|
3061
4709
|
}
|
|
@@ -3067,6 +4715,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3067
4715
|
} catch {
|
|
3068
4716
|
return;
|
|
3069
4717
|
}
|
|
4718
|
+
for (const cid of entries.supersededChannelIds ?? []) {
|
|
4719
|
+
this.joinState.set(cid, "JOINED");
|
|
4720
|
+
}
|
|
3070
4721
|
if (!entries.length) return;
|
|
3071
4722
|
let metaRefreshed = false;
|
|
3072
4723
|
if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
|
|
@@ -3249,6 +4900,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3249
4900
|
rate_limited: this.rateLimitedSends,
|
|
3250
4901
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
3251
4902
|
failed_attachments: this.failedAttachments,
|
|
4903
|
+
delivery_pings: this.pingsReceived,
|
|
3252
4904
|
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
3253
4905
|
version: BRIDGE_VERSION
|
|
3254
4906
|
};
|
|
@@ -3294,8 +4946,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
3294
4946
|
};
|
|
3295
4947
|
}
|
|
3296
4948
|
async close() {
|
|
4949
|
+
this.closingDown = true;
|
|
3297
4950
|
this.pingHandle?.close();
|
|
3298
4951
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
4952
|
+
if (this.bootSweepTimer) clearTimeout(this.bootSweepTimer);
|
|
4953
|
+
if (this.selfHealTimer) clearTimeout(this.selfHealTimer);
|
|
4954
|
+
await this.provider.drainInFlight(_BridgeAgent.GRACEFUL_DRAIN_MS);
|
|
3299
4955
|
this.store.close();
|
|
3300
4956
|
}
|
|
3301
4957
|
};
|
|
@@ -3427,6 +5083,7 @@ var BRIDGE_ERRORS = {
|
|
|
3427
5083
|
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
3428
5084
|
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
3429
5085
|
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
5086
|
+
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
3430
5087
|
internal: { http: 500, message: "internal error" }
|
|
3431
5088
|
};
|
|
3432
5089
|
function errorBody(code, detail) {
|
|
@@ -3836,6 +5493,79 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
3836
5493
|
}
|
|
3837
5494
|
return;
|
|
3838
5495
|
}
|
|
5496
|
+
const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
|
|
5497
|
+
const NODE_STATUSES = ["pending", "running", "done", "error"];
|
|
5498
|
+
const actionError = (e) => {
|
|
5499
|
+
sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
|
|
5500
|
+
};
|
|
5501
|
+
if (m.type === "action_begin") {
|
|
5502
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
5503
|
+
sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
|
|
5504
|
+
return;
|
|
5505
|
+
}
|
|
5506
|
+
try {
|
|
5507
|
+
sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
|
|
5508
|
+
} catch (e) {
|
|
5509
|
+
actionError(e);
|
|
5510
|
+
}
|
|
5511
|
+
return;
|
|
5512
|
+
}
|
|
5513
|
+
if (m.type === "action_start") {
|
|
5514
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
|
|
5515
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
|
|
5516
|
+
return;
|
|
5517
|
+
}
|
|
5518
|
+
try {
|
|
5519
|
+
const nodeId = await agent.action.start({
|
|
5520
|
+
runId: m.run_id,
|
|
5521
|
+
type: m.node_type,
|
|
5522
|
+
title: m.title,
|
|
5523
|
+
parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
|
|
5524
|
+
});
|
|
5525
|
+
sendJson(sock, { type: "action_started", node_id: nodeId });
|
|
5526
|
+
} catch (e) {
|
|
5527
|
+
actionError(e);
|
|
5528
|
+
}
|
|
5529
|
+
return;
|
|
5530
|
+
}
|
|
5531
|
+
if (m.type === "action_update" || m.type === "action_finish") {
|
|
5532
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
|
|
5533
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
try {
|
|
5537
|
+
if (m.type === "action_finish") {
|
|
5538
|
+
if (m.status !== "done" && m.status !== "error") {
|
|
5539
|
+
sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
|
|
5540
|
+
return;
|
|
5541
|
+
}
|
|
5542
|
+
await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
5543
|
+
} else {
|
|
5544
|
+
if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
|
|
5545
|
+
sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
|
|
5546
|
+
return;
|
|
5547
|
+
}
|
|
5548
|
+
await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
5549
|
+
}
|
|
5550
|
+
sendJson(sock, { type: "ok" });
|
|
5551
|
+
} catch (e) {
|
|
5552
|
+
actionError(e);
|
|
5553
|
+
}
|
|
5554
|
+
return;
|
|
5555
|
+
}
|
|
5556
|
+
if (m.type === "action_settle") {
|
|
5557
|
+
if (typeof m.run_id !== "string" || !m.run_id) {
|
|
5558
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
|
|
5559
|
+
return;
|
|
5560
|
+
}
|
|
5561
|
+
try {
|
|
5562
|
+
await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
|
|
5563
|
+
sendJson(sock, { type: "action_settled", run_id: m.run_id });
|
|
5564
|
+
} catch (e) {
|
|
5565
|
+
actionError(e);
|
|
5566
|
+
}
|
|
5567
|
+
return;
|
|
5568
|
+
}
|
|
3839
5569
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3840
5570
|
}
|
|
3841
5571
|
export {
|