@securecoms/agent-bridge 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/connectors/hermes/adapter.py +33 -7
- package/connectors/hermes/tests/test_logging_privacy.py +61 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +560 -0
- package/connectors/openclaw/openclaw-entry.js +240 -47
- package/dist/bin.d.ts +3 -7
- package/dist/bin.js +2868 -511
- package/dist/bridge-agent.d.ts +222 -15
- package/dist/coordination.d.ts +90 -5
- package/dist/decrypt-error-log-limiter.d.ts +6 -30
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +20 -1
- package/dist/index.js +2862 -496
- package/dist/service-log.d.ts +7 -0
- package/dist/summarize.d.ts +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +26 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
// ../connect-sdk-ts/dist/service-log.js
|
|
2
|
+
var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
3
|
+
var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
4
|
+
function errorClass(error) {
|
|
5
|
+
if (error === null)
|
|
6
|
+
return "NullThrown";
|
|
7
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
8
|
+
const primitive = {
|
|
9
|
+
string: "StringThrown",
|
|
10
|
+
number: "NumberThrown",
|
|
11
|
+
boolean: "BooleanThrown",
|
|
12
|
+
bigint: "BigIntThrown",
|
|
13
|
+
symbol: "SymbolThrown",
|
|
14
|
+
undefined: "UndefinedThrown"
|
|
15
|
+
};
|
|
16
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
20
|
+
return typeof name === "string" && CLASS.test(name) ? name : "Error";
|
|
21
|
+
} catch {
|
|
22
|
+
return "Error";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function formatServiceLog(level, event, fields = {}, error) {
|
|
26
|
+
const safeEvent = TOKEN.test(event) ? event : "invalid_event";
|
|
27
|
+
const parts = [`[sdk] ${level} ${safeEvent}`];
|
|
28
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
29
|
+
if (!TOKEN.test(key))
|
|
30
|
+
continue;
|
|
31
|
+
if (typeof value === "boolean")
|
|
32
|
+
parts.push(`${key}=${value}`);
|
|
33
|
+
else if (typeof value === "number" && Number.isFinite(value))
|
|
34
|
+
parts.push(`${key}=${value}`);
|
|
35
|
+
}
|
|
36
|
+
if (error !== void 0)
|
|
37
|
+
parts.push(`error_class=${errorClass(error)}`);
|
|
38
|
+
return parts.join(" ");
|
|
39
|
+
}
|
|
40
|
+
function writeServiceLog(level, event, fields, error) {
|
|
41
|
+
const line = formatServiceLog(level, event, fields, error);
|
|
42
|
+
if (level === "error")
|
|
43
|
+
console.error(line);
|
|
44
|
+
else if (level === "warn")
|
|
45
|
+
console.warn(line);
|
|
46
|
+
else
|
|
47
|
+
console.log(line);
|
|
48
|
+
}
|
|
49
|
+
|
|
1
50
|
// ../connect-core/dist/cadence.js
|
|
2
51
|
var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
3
52
|
var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
|
|
@@ -1180,17 +1229,29 @@ var enc2 = new TextEncoder();
|
|
|
1180
1229
|
var dec2 = new TextDecoder();
|
|
1181
1230
|
|
|
1182
1231
|
// ../connect-clients-core/dist/filesafe/owner-pin.js
|
|
1232
|
+
function rejectionReason(e) {
|
|
1233
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1234
|
+
if (msg.includes("does not chain"))
|
|
1235
|
+
return "not_chaining";
|
|
1236
|
+
if (msg.includes("bad handoff signature"))
|
|
1237
|
+
return "bad_signature";
|
|
1238
|
+
return "other";
|
|
1239
|
+
}
|
|
1183
1240
|
async function resolvePinnedOwner(args2) {
|
|
1184
1241
|
let pin = await args2.store.load(args2.enclaveId);
|
|
1185
1242
|
if (pin === null) {
|
|
1186
1243
|
pin = { g0Cred: args2.observedG0Cred, chain: [] };
|
|
1187
1244
|
}
|
|
1188
1245
|
let chain = pin.chain;
|
|
1189
|
-
for (
|
|
1246
|
+
for (let i = 0; i < args2.relayedLinks.length; i++) {
|
|
1190
1247
|
const tail = currentOwnerCred(pin.g0Cred, chain);
|
|
1191
1248
|
try {
|
|
1192
|
-
chain = await extendOwnerChain(chain,
|
|
1193
|
-
} catch {
|
|
1249
|
+
chain = await extendOwnerChain(chain, args2.relayedLinks[i], tail);
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
try {
|
|
1252
|
+
args2.onRejectedLink?.({ index: i, reason: rejectionReason(e) });
|
|
1253
|
+
} catch {
|
|
1254
|
+
}
|
|
1194
1255
|
}
|
|
1195
1256
|
}
|
|
1196
1257
|
const next = { g0Cred: pin.g0Cred, chain };
|
|
@@ -1211,7 +1272,7 @@ var SoftwareKeyCustody = class _SoftwareKeyCustody {
|
|
|
1211
1272
|
this.priv = priv;
|
|
1212
1273
|
this.pub = pub;
|
|
1213
1274
|
if (!quiet) {
|
|
1214
|
-
|
|
1275
|
+
writeServiceLog("warn", "custody.software_key_unattested");
|
|
1215
1276
|
}
|
|
1216
1277
|
}
|
|
1217
1278
|
static generate(algo) {
|
|
@@ -1321,6 +1382,56 @@ var MalformedGroupRosterError = class extends ConnectSdkError {
|
|
|
1321
1382
|
this.receivedType = receivedType;
|
|
1322
1383
|
}
|
|
1323
1384
|
};
|
|
1385
|
+
var MlsBackendHttpError = class extends ConnectSdkError {
|
|
1386
|
+
status;
|
|
1387
|
+
code;
|
|
1388
|
+
reason;
|
|
1389
|
+
/**
|
|
1390
|
+
* `code` (C2 T0): the backend's error code parsed from the response body
|
|
1391
|
+
* (`{"detail": "x"}`, `{"detail": {"error": "x"}}` or `{"error": "x"}`),
|
|
1392
|
+
* when there is one. The distribute outcome classifier (C2 T3) reads it
|
|
1393
|
+
* against `DISTRIBUTE_DEFINITE_CODES`; absent means "no code", which is
|
|
1394
|
+
* ambiguous by construction. It never enters the message, so the message
|
|
1395
|
+
* stays byte-identical to the pre-C2 one.
|
|
1396
|
+
*
|
|
1397
|
+
* `reason` (C2 T3): the 409 `EPOCH_CONFLICT` body's `reason`
|
|
1398
|
+
* (`stale_epoch`, `epoch_not_monotonic`, `superseded`) when present. The
|
|
1399
|
+
* add and remove sites read it to route a `superseded` refusal away from
|
|
1400
|
+
* the drain-and-retry path (LOW-2). Never in the message either.
|
|
1401
|
+
*/
|
|
1402
|
+
constructor(path, status, code, reason) {
|
|
1403
|
+
super(`${path} failed: ${status}`);
|
|
1404
|
+
this.status = status;
|
|
1405
|
+
this.code = code;
|
|
1406
|
+
this.reason = reason;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
var PendingCommitUnresolvedError = class extends ConnectSdkError {
|
|
1410
|
+
channelId;
|
|
1411
|
+
constructor(channelId) {
|
|
1412
|
+
super("mls.pending_commit.unresolved: a previous commit on this conversation is still being confirmed; retry later");
|
|
1413
|
+
this.channelId = channelId;
|
|
1414
|
+
this.name = "PendingCommitUnresolvedError";
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
var GroupSupersededError = class extends ConnectSdkError {
|
|
1418
|
+
channelId;
|
|
1419
|
+
groupId;
|
|
1420
|
+
constructor(channelId, groupId) {
|
|
1421
|
+
super("mls.group_superseded: the conversation's group was replaced by an owner reset; the change was not applied");
|
|
1422
|
+
this.channelId = channelId;
|
|
1423
|
+
this.groupId = groupId;
|
|
1424
|
+
this.name = "GroupSupersededError";
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
var AttestedOrphanRefusedError = class extends ConnectSdkError {
|
|
1428
|
+
channelId;
|
|
1429
|
+
constructor(channelId) {
|
|
1430
|
+
super("mls.attested_orphan_refused: the conversation's reset did not complete; it needs the owner to reset again");
|
|
1431
|
+
this.channelId = channelId;
|
|
1432
|
+
this.name = "AttestedOrphanRefusedError";
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1324
1435
|
|
|
1325
1436
|
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
1326
1437
|
async function buildOutboundEnvelope(p) {
|
|
@@ -1447,6 +1558,94 @@ function leafIndicesForKeyPackage(mgr, kp) {
|
|
|
1447
1558
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1448
1559
|
import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
1449
1560
|
|
|
1561
|
+
// ../connect-sdk-ts/dist/mls/store.js
|
|
1562
|
+
function assertPendingCommitShape(rec) {
|
|
1563
|
+
if (rec.commitEpoch !== rec.request.commit_epoch) {
|
|
1564
|
+
throw new Error("pending commit: commitEpoch does not equal request.commit_epoch");
|
|
1565
|
+
}
|
|
1566
|
+
if (rec.op === "bootstrap" && rec.prior !== null) {
|
|
1567
|
+
throw new Error("pending commit: a bootstrap record must have prior === null");
|
|
1568
|
+
}
|
|
1569
|
+
if (rec.op === "add" || rec.op === "remove") {
|
|
1570
|
+
if (rec.prior === null) {
|
|
1571
|
+
throw new Error(`pending commit: an ${rec.op} record must have a prior MAIN`);
|
|
1572
|
+
}
|
|
1573
|
+
if (rec.groupId !== rec.prior.groupId) {
|
|
1574
|
+
throw new Error(`pending commit: an ${rec.op} record's groupId must equal prior.groupId`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
function isPendingReservation(rec) {
|
|
1579
|
+
return rec.request.commit === void 0;
|
|
1580
|
+
}
|
|
1581
|
+
function pendingReservation(p) {
|
|
1582
|
+
return {
|
|
1583
|
+
groupId: p.groupId,
|
|
1584
|
+
op: "supersede",
|
|
1585
|
+
request: { device_id: p.deviceId, commit_epoch: 0 },
|
|
1586
|
+
state: p.placeholder,
|
|
1587
|
+
commitEpoch: 0,
|
|
1588
|
+
prior: p.prior,
|
|
1589
|
+
createdAt: p.createdAt
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
function pendingCommitKey(rec) {
|
|
1593
|
+
return { groupId: rec.groupId, commitEpoch: rec.commitEpoch, commit: rec.request.commit ?? "" };
|
|
1594
|
+
}
|
|
1595
|
+
function matchesPendingCommitKey(rec, k) {
|
|
1596
|
+
return rec.groupId === k.groupId && rec.commitEpoch === k.commitEpoch && (rec.request.commit ?? "") === k.commit;
|
|
1597
|
+
}
|
|
1598
|
+
function sameMainState(a, b) {
|
|
1599
|
+
if (!a || !b)
|
|
1600
|
+
return !a && !b;
|
|
1601
|
+
return a.groupState === b.groupState && a.ciphersuite === b.ciphersuite;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ../connect-sdk-ts/dist/mls/distribute-outcome.js
|
|
1605
|
+
var CODES = [
|
|
1606
|
+
{ status: 409, code: "EPOCH_CONFLICT" },
|
|
1607
|
+
{ status: 409, code: "welcome_target_not_approved_member" },
|
|
1608
|
+
{ status: 403, code: "not_group_committer" },
|
|
1609
|
+
{ status: 403, code: "not_authorized_to_remove" },
|
|
1610
|
+
{ status: 403, code: "removal_would_empty_group" },
|
|
1611
|
+
{ status: 403, code: "removal_would_drop_last_committer" },
|
|
1612
|
+
{ status: 422, code: "device_identity_mismatch" },
|
|
1613
|
+
{ status: 422, code: "no_target_devices" },
|
|
1614
|
+
{ status: 400, code: "invalid_commit_hex" },
|
|
1615
|
+
{ status: 400, code: "expected_epoch_required" },
|
|
1616
|
+
{ status: 400, code: "removed_identity_id required" },
|
|
1617
|
+
{ status: 400, code: "commit required" },
|
|
1618
|
+
{ status: 404, code: "not_connect_group" },
|
|
1619
|
+
{ status: 404, code: "group_missing" },
|
|
1620
|
+
{ status: 403, code: "agent_paused" },
|
|
1621
|
+
{ status: 403, code: "forbidden" },
|
|
1622
|
+
{ status: 400, code: "mls.stale_epoch" }
|
|
1623
|
+
];
|
|
1624
|
+
var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
|
|
1625
|
+
var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
|
|
1626
|
+
function parseErrorCode(body) {
|
|
1627
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
1628
|
+
return void 0;
|
|
1629
|
+
const b = body;
|
|
1630
|
+
if (typeof b.detail === "string")
|
|
1631
|
+
return b.detail;
|
|
1632
|
+
if (b.detail !== null && typeof b.detail === "object" && !Array.isArray(b.detail)) {
|
|
1633
|
+
const inner = b.detail.error;
|
|
1634
|
+
if (typeof inner === "string")
|
|
1635
|
+
return inner;
|
|
1636
|
+
}
|
|
1637
|
+
if (typeof b.error === "string")
|
|
1638
|
+
return b.error;
|
|
1639
|
+
return void 0;
|
|
1640
|
+
}
|
|
1641
|
+
function classifyDistributeError(err) {
|
|
1642
|
+
if (!(err instanceof MlsBackendHttpError))
|
|
1643
|
+
return "ambiguous";
|
|
1644
|
+
if (typeof err.code !== "string")
|
|
1645
|
+
return "ambiguous";
|
|
1646
|
+
return DEFINITE_KEYS.has(`${err.status} ${err.code}`) ? "definite" : "ambiguous";
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1450
1649
|
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
1451
1650
|
var MlsOperationQueue = class {
|
|
1452
1651
|
chains = /* @__PURE__ */ new Map();
|
|
@@ -2269,9 +2468,52 @@ function getSignaturePublicKeyFromLeafIndex(ratchetTree, leafIndex) {
|
|
|
2269
2468
|
}
|
|
2270
2469
|
|
|
2271
2470
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
2471
|
+
var PENDING_INLINE_RESENDS = 2;
|
|
2472
|
+
var DEFAULT_PENDING_RESEND_DELAYS_MS = [250, 1e3];
|
|
2473
|
+
var PENDING_UNRESOLVED_LOG_INTERVAL_MS = 6e4;
|
|
2474
|
+
var PENDING_AUTO_RESOLVE_INTERVAL_MS = 6e4;
|
|
2475
|
+
var PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS = 6e4;
|
|
2476
|
+
var PENDING_LEFT_ESCALATION_PULLS = 20;
|
|
2477
|
+
var PENDING_LEFT_ESCALATION_MS = 10 * 6e4;
|
|
2478
|
+
var PendingRowEscalatedError = class extends Error {
|
|
2479
|
+
constructor() {
|
|
2480
|
+
super("mls.pending_commit.left_row_escalated");
|
|
2481
|
+
this.name = "PendingRowEscalatedError";
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
function sleepMs(ms) {
|
|
2485
|
+
return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
|
|
2486
|
+
}
|
|
2487
|
+
function tagOf(mgr) {
|
|
2488
|
+
return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
2489
|
+
}
|
|
2490
|
+
function tagOfState(state) {
|
|
2491
|
+
const m = new MLSGroupManager2(state.ciphersuite);
|
|
2492
|
+
m.importState(state);
|
|
2493
|
+
return tagOf(m);
|
|
2494
|
+
}
|
|
2495
|
+
function sameMainTag(a, b) {
|
|
2496
|
+
if (a === null || b === null)
|
|
2497
|
+
return a === b;
|
|
2498
|
+
return a.groupId === b.groupId && a.epoch === b.epoch;
|
|
2499
|
+
}
|
|
2500
|
+
var RESERVATION_PLACEHOLDER_STATE = {
|
|
2501
|
+
groupState: "",
|
|
2502
|
+
ciphersuite: "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"
|
|
2503
|
+
};
|
|
2504
|
+
function isDefiniteReinitRefusal(e) {
|
|
2505
|
+
return e instanceof MlsBackendHttpError && e.status >= 400 && e.status < 500;
|
|
2506
|
+
}
|
|
2507
|
+
function isSupersededRefusal(e) {
|
|
2508
|
+
return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && e.reason === "superseded";
|
|
2509
|
+
}
|
|
2510
|
+
function isEpochConflict(e) {
|
|
2511
|
+
return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && !isSupersededRefusal(e);
|
|
2512
|
+
}
|
|
2272
2513
|
var LABEL = "agentvault-connect-v1";
|
|
2273
2514
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
2274
2515
|
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
2516
|
+
var DELIVERY_PULL_PAGE = 100;
|
|
2275
2517
|
var BootstrapRaceLostError = class extends Error {
|
|
2276
2518
|
constructor() {
|
|
2277
2519
|
super("bootstrap race lost: another device created the group; join instead");
|
|
@@ -2296,6 +2538,16 @@ var AmbiguousOwnLeafError = class extends Error {
|
|
|
2296
2538
|
this.name = "AmbiguousOwnLeafError";
|
|
2297
2539
|
}
|
|
2298
2540
|
};
|
|
2541
|
+
var OwnLeafRemoveRefusedError = class extends Error {
|
|
2542
|
+
op;
|
|
2543
|
+
reason;
|
|
2544
|
+
constructor(op, reason) {
|
|
2545
|
+
super(`${op}: refusing a Remove that would or could evict this committer's own leaf (${reason})`);
|
|
2546
|
+
this.op = op;
|
|
2547
|
+
this.reason = reason;
|
|
2548
|
+
this.name = "OwnLeafRemoveRefusedError";
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2299
2551
|
var HealAddAfterRemoveError = class extends Error {
|
|
2300
2552
|
constructor(cause) {
|
|
2301
2553
|
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
@@ -2427,8 +2679,67 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2427
2679
|
* past-epoch {@link permanentlySkipped} drain. Read via
|
|
2428
2680
|
* {@link retiredDrainedCount}. */
|
|
2429
2681
|
retiredDrained = 0;
|
|
2682
|
+
/**
|
|
2683
|
+
* C1 (P0 containment): CUSTODY of every application row this provider has
|
|
2684
|
+
* decrypted, keyed by queue_id, until the server shows the row gone.
|
|
2685
|
+
*
|
|
2686
|
+
* Why: a row's message key comes from the LIVE exporter secret only, so once
|
|
2687
|
+
* any drain applies commit N->N+1 the first successful decrypt of an epoch-N
|
|
2688
|
+
* row is the ONLY one there will ever be. Before C1, internal drains
|
|
2689
|
+
* (reconcileForSend, joinAndReconcile, the committer 409 drain) decrypted such
|
|
2690
|
+
* rows, discarded the plaintext and applied the commit; the redelivered row
|
|
2691
|
+
* then failed AEAD, was classified permanentlySkipped and acked: silent loss.
|
|
2692
|
+
* A CONCURRENT receiveOnce that pulled the row before another drain applied
|
|
2693
|
+
* the commit lost it the same way (C1 review, Important #1).
|
|
2694
|
+
*
|
|
2695
|
+
* Rule: an application row whose queue_id is in custody is NEVER decrypted or
|
|
2696
|
+
* classified again, by any call. {@link receiveOnce} returns the stored entry
|
|
2697
|
+
* for it, un-acked, in the row's seq position, on every pull that contains the
|
|
2698
|
+
* row, so the caller's ack stays the only ack. An internal drain's discard is
|
|
2699
|
+
* therefore harmless (the next receiveOnce serves the entry), and a caller
|
|
2700
|
+
* that drops an entry without acking is offered it again on the next pull,
|
|
2701
|
+
* exactly like a row that still decrypts. Eviction happens only when an
|
|
2702
|
+
* own-device pull SHORTER than {@link DELIVERY_PULL_PAGE} does not contain the
|
|
2703
|
+
* row (acked, expired, dead-lettered or fenced; it cannot come back).
|
|
2704
|
+
*
|
|
2705
|
+
* Limit: memory only. A process restart empties custody, and an un-acked row
|
|
2706
|
+
* whose epoch has moved on then fails AEAD and is acked as permanentlySkipped
|
|
2707
|
+
* (C1 report: durable-sink follow-up).
|
|
2708
|
+
*/
|
|
2709
|
+
custody = /* @__PURE__ */ new Map();
|
|
2710
|
+
/*
|
|
2711
|
+
* C2 DEVIATION FROM SPEC §4.2's IN-MEMORY PENDING SET, deliberate: there is
|
|
2712
|
+
* none. Every C2 decision (the send fence, the receive resolver, the
|
|
2713
|
+
* lock-entry resolve, promotion) reads the CHANNEL's DURABLE record, one
|
|
2714
|
+
* store read each. A per-process set can be wrong in BOTH directions across
|
|
2715
|
+
* tabs and processes (MEDIUM-1): it misses a record another tab wrote, and
|
|
2716
|
+
* it keeps fencing a channel another tab already resolved. The durable read
|
|
2717
|
+
* is right in both, and the plan already accepted one store read per send.
|
|
2718
|
+
* Nothing reads `listPendingCommits()` either, so one unreadable record
|
|
2719
|
+
* (T2's SQLite `MlsStoreSealError` refuses the whole list) cannot fence
|
|
2720
|
+
* every channel: it fences only its own, whose per-channel read throws.
|
|
2721
|
+
*/
|
|
2722
|
+
/** Memo for the provider-start re-run of owed follow-ups (MEDIUM-3). */
|
|
2723
|
+
owedRerun = null;
|
|
2724
|
+
/** Last `mls.pending_commit.unresolved` log per channel (rate limit). */
|
|
2725
|
+
unresolvedLoggedAt = /* @__PURE__ */ new Map();
|
|
2726
|
+
/** Channels whose legacy (pre-C2) staging slot was already discarded by
|
|
2727
|
+
* this process (spec §4.5 "Legacy staging residue"). */
|
|
2728
|
+
legacyStagingChecked = /* @__PURE__ */ new Set();
|
|
2729
|
+
/** C2 T3c (H1): last automatic resolve per channel (rate limit). */
|
|
2730
|
+
autoResolveAt = /* @__PURE__ */ new Map();
|
|
2731
|
+
/** C2 T3c (H1): automatic resolves in flight, awaited by drainInFlight. */
|
|
2732
|
+
backgroundResolves = /* @__PURE__ */ new Set();
|
|
2733
|
+
/** C2 T3c (H1): per channel, the run of consecutive pulls that left rows,
|
|
2734
|
+
* and when it started. Cleared by a pull that leaves none. */
|
|
2735
|
+
leftStreak = /* @__PURE__ */ new Map();
|
|
2736
|
+
/** C2 T3c (H2): last receive_resolve_failed log per channel. */
|
|
2737
|
+
receiveFailLoggedAt = /* @__PURE__ */ new Map();
|
|
2430
2738
|
constructor(o) {
|
|
2431
2739
|
this.o = o;
|
|
2740
|
+
if (typeof o.onPendingCommitResolved !== "function") {
|
|
2741
|
+
throw new TypeError("MlsGroupKeyProvider: onPendingCommitResolved is required and must be a function");
|
|
2742
|
+
}
|
|
2432
2743
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
2433
2744
|
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
2434
2745
|
this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
|
|
@@ -2559,7 +2870,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2559
2870
|
timer.unref?.();
|
|
2560
2871
|
});
|
|
2561
2872
|
const drain = (async () => {
|
|
2562
|
-
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
|
|
2873
|
+
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0 || this.backgroundResolves.size > 0) {
|
|
2874
|
+
await Promise.allSettled([...this.backgroundResolves]);
|
|
2563
2875
|
await this.locks.idle();
|
|
2564
2876
|
await this.publishLock.idle();
|
|
2565
2877
|
}
|
|
@@ -2737,7 +3049,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2737
3049
|
orderedDeviceIds
|
|
2738
3050
|
});
|
|
2739
3051
|
} else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
2740
|
-
|
|
3052
|
+
writeServiceLog("warn", "guest.swap_skipped_add_noop");
|
|
2741
3053
|
}
|
|
2742
3054
|
return outcome;
|
|
2743
3055
|
}
|
|
@@ -2805,17 +3117,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2805
3117
|
try {
|
|
2806
3118
|
await this.locks.run(channelId, async () => {
|
|
2807
3119
|
const mgr = await this.manager(channelId);
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3120
|
+
try {
|
|
3121
|
+
for (const c of commits) {
|
|
3122
|
+
if (c.epoch <= Number(mgr.epoch))
|
|
3123
|
+
continue;
|
|
3124
|
+
await mgr.decrypt(hexToBytes(c.payload));
|
|
3125
|
+
applied++;
|
|
3126
|
+
}
|
|
3127
|
+
if (applied > 0)
|
|
3128
|
+
await this.saveMain(channelId, mgr);
|
|
3129
|
+
} catch (e) {
|
|
3130
|
+
if (applied > 0)
|
|
3131
|
+
this.evictIfCached(channelId, mgr);
|
|
3132
|
+
throw e;
|
|
2813
3133
|
}
|
|
2814
|
-
if (applied > 0)
|
|
2815
|
-
await this.o.store.save(channelId, mgr.exportState());
|
|
2816
3134
|
});
|
|
2817
3135
|
} catch (e) {
|
|
2818
|
-
|
|
3136
|
+
writeServiceLog("warn", "resync.replay_commit_failed", { applied }, e);
|
|
2819
3137
|
return false;
|
|
2820
3138
|
}
|
|
2821
3139
|
if (applied > 0) {
|
|
@@ -2875,6 +3193,494 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2875
3193
|
* delete (a re-delete is a no-op). On success the manager stays cached as
|
|
2876
3194
|
* before — no eviction.
|
|
2877
3195
|
*/
|
|
3196
|
+
/**
|
|
3197
|
+
* Phase 3 C4: persist a manager that has ALREADY ADVANCED in memory by applying
|
|
3198
|
+
* a commit the SERVER can hand us again (a delivery row we have not acked, a
|
|
3199
|
+
* /sync replay). If the save throws, evict the cached manager before
|
|
3200
|
+
* rethrowing, so the next access reloads the persisted (pre-commit) state and
|
|
3201
|
+
* re-applies the still-queued commit.
|
|
3202
|
+
*
|
|
3203
|
+
* Why evict rather than keep the advanced cache: with the cache ahead of disk,
|
|
3204
|
+
* the redelivered commit no longer applies to it, the classifier sees a commit
|
|
3205
|
+
* at or below the live epoch and ACKS it as dead, and the disk keeps the old
|
|
3206
|
+
* epoch. The next restart loads that epoch while the server has moved on: a
|
|
3207
|
+
* silent fork (mls.evict-on-save-failure.test.ts).
|
|
3208
|
+
*
|
|
3209
|
+
* ⛔ ONLY for server-redeliverable commits. A committer's OWN commit that the
|
|
3210
|
+
* server already accepted cannot be re-applied from the wire (a member cannot
|
|
3211
|
+
* process its own commit), so evicting there throws away the only copy of the
|
|
3212
|
+
* true state. The committer paths keep their own handling.
|
|
3213
|
+
*
|
|
3214
|
+
* `MLSGroupManager.decrypt` assigns its new state only after processing
|
|
3215
|
+
* succeeds, so a decrypt that THROWS leaves the manager unchanged and needs no
|
|
3216
|
+
* eviction; only the advanced-but-unsaved case does.
|
|
3217
|
+
*/
|
|
3218
|
+
async saveAdvancedOrEvict(channelId, mgr) {
|
|
3219
|
+
try {
|
|
3220
|
+
await this.saveMain(channelId, mgr);
|
|
3221
|
+
} catch (e) {
|
|
3222
|
+
this.evictIfCached(channelId, mgr);
|
|
3223
|
+
throw e;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
3227
|
+
* C2 (spec §4.2, MEDIUM-1): the ONE writer of MAIN outside promotion. Every
|
|
3228
|
+
* MAIN save in this file goes through here (pinned by
|
|
3229
|
+
* `save-main-call-sites.arch.test.ts`), and here goes through the store's
|
|
3230
|
+
* `saveMainGuarded`, which refuses ATOMICALLY when the channel holds a
|
|
3231
|
+
* pending-commit record whose `prior` is not this manager's (group, epoch).
|
|
3232
|
+
* So while a commit's outcome is unknown, MAIN can move only within the
|
|
3233
|
+
* epoch the commit was built on, whether the write comes from this tab, a
|
|
3234
|
+
* second tab, or another process.
|
|
3235
|
+
*
|
|
3236
|
+
* On ANY throw the cached manager is evicted if it is still `mgr`, so the
|
|
3237
|
+
* cache never runs ahead of MAIN: a refusal (`PendingCommitUnresolvedError`)
|
|
3238
|
+
* and a raw store failure (SQLite `SQLITE_BUSY` after its busy timeout, an
|
|
3239
|
+
* IndexedDB abort) are the same fact here, "MAIN did not move". The error
|
|
3240
|
+
* propagates: a receive caller leaves the row un-acked (uncounted, for a
|
|
3241
|
+
* refusal), a committer caller fails.
|
|
3242
|
+
*/
|
|
3243
|
+
async saveMain(channelId, mgr) {
|
|
3244
|
+
try {
|
|
3245
|
+
await this.o.store.saveMainGuarded(channelId, mgr.exportState(), tagOf(mgr));
|
|
3246
|
+
} catch (e) {
|
|
3247
|
+
this.evictIfCached(channelId, mgr);
|
|
3248
|
+
throw e;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
// ── C2: pending-commit records ────────────────────────────────────────────
|
|
3252
|
+
//
|
|
3253
|
+
// A commit is written to a durable record BEFORE /distribute, resolved only
|
|
3254
|
+
// by proof (a 2xx, a coded definite rejection, or a receive-side proof), and
|
|
3255
|
+
// RESENT byte-for-byte, never rebuilt. See spec §4 and the plan's Task T3.
|
|
3256
|
+
//
|
|
3257
|
+
// ⛔ IRREVERSIBILITY. Nothing below builds a commit, adds a member or removes
|
|
3258
|
+
// one. Recovery only resends `rec.request`, whose bytes were built, and
|
|
3259
|
+
// checked by the admission rule (duplicate-leaf) and the own-leaf guard
|
|
3260
|
+
// (C3), at the site that created the record, against the tree they apply
|
|
3261
|
+
// to. A resend that lands can land only on that tree: the server's
|
|
3262
|
+
// expected_epoch compare-and-set (one commit per (group, epoch), H4/T1a) and
|
|
3263
|
+
// the superseded refusal (§3.2) reject it anywhere else.
|
|
3264
|
+
nowMs() {
|
|
3265
|
+
return this.o.now?.() ?? Date.now();
|
|
3266
|
+
}
|
|
3267
|
+
/** The (group, epoch) of the DURABLE MAIN, or null when there is none. Read
|
|
3268
|
+
* from the store, not the cache: promotion compares against what is at
|
|
3269
|
+
* rest. */
|
|
3270
|
+
async durableMainTag(channelId) {
|
|
3271
|
+
const saved = await this.o.store.load(channelId);
|
|
3272
|
+
return saved ? tagOfState(saved) : null;
|
|
3273
|
+
}
|
|
3274
|
+
/** Start the provider-start re-run of owed follow-ups once per process,
|
|
3275
|
+
* detached (see {@link resumePendingCommits}). A failure is logged and the
|
|
3276
|
+
* memo clears, so the next call retries it. Synchronous: callers never wait
|
|
3277
|
+
* on the hook. */
|
|
3278
|
+
kickOwedRerun() {
|
|
3279
|
+
if (this.owedRerun)
|
|
3280
|
+
return;
|
|
3281
|
+
void this.resumePendingCommits().catch((e) => writeServiceLog("warn", "mls.pending_commit.owed_rerun_failed", {}, e));
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* C2 (MEDIUM-3): provider-start work, idempotent per process (a rejected
|
|
3285
|
+
* run clears its memo). Re-runs every owed follow-up (the post-commit work
|
|
3286
|
+
* of a commit that landed and was promoted, whose hook had not yet returned)
|
|
3287
|
+
* through `onPendingCommitResolved` with `via: "resolver"`, clearing each
|
|
3288
|
+
* slot only after its hook returns. The provider starts this itself,
|
|
3289
|
+
* detached, on the first send-fence check or receive pass; a host may call
|
|
3290
|
+
* it at startup to run the owed work before anything else. The hook is
|
|
3291
|
+
* at-least-once, hence required to be idempotent.
|
|
3292
|
+
*/
|
|
3293
|
+
async resumePendingCommits() {
|
|
3294
|
+
this.owedRerun ??= (async () => {
|
|
3295
|
+
for (const { channelId, owed } of await this.o.store.listOwedFollowUps()) {
|
|
3296
|
+
await this.runFollowUpHook(channelId, owed.op, owed.followUp, "resolver", owed.createdAt);
|
|
3297
|
+
}
|
|
3298
|
+
})().catch((e) => {
|
|
3299
|
+
this.owedRerun = null;
|
|
3300
|
+
throw e;
|
|
3301
|
+
});
|
|
3302
|
+
await this.owedRerun;
|
|
3303
|
+
}
|
|
3304
|
+
/** Run the hook for a landed commit that owes a follow-up, then clear the
|
|
3305
|
+
* owed slot. A throwing hook KEEPS the slot (re-run at the next provider
|
|
3306
|
+
* start) and is logged, never propagated: the commit itself landed and
|
|
3307
|
+
* MAIN is promoted, so failing the op would misreport it.
|
|
3308
|
+
*
|
|
3309
|
+
* C2 T3c (M1): the clear is keyed on the slot's `createdAt`, so it removes
|
|
3310
|
+
* only the slot whose hook just ran. The provider-start re-run is detached
|
|
3311
|
+
* and holds no channel lock; a newer commit's slot written meanwhile (it
|
|
3312
|
+
* overwrites the channel's slot) is kept for its own hook. */
|
|
3313
|
+
async runFollowUpHook(channelId, op, followUp, via, slotCreatedAt) {
|
|
3314
|
+
try {
|
|
3315
|
+
await this.o.onPendingCommitResolved(channelId, { op, outcome: "landed", followUp, via });
|
|
3316
|
+
} catch (e) {
|
|
3317
|
+
writeServiceLog("warn", "mls.pending_commit.hook_failed", {}, e);
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
await this.o.store.clearOwedFollowUp(channelId, slotCreatedAt);
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* C2 (coordinator ruling on T2, MEDIUM-3): run and clear any follow-up still
|
|
3324
|
+
* OWED on this channel, and THROW if that fails. Called before a commit that
|
|
3325
|
+
* will itself carry a follow-up is built: every store's `commitPending`
|
|
3326
|
+
* overwrites the channel's owed slot, so promoting the new commit over an
|
|
3327
|
+
* undrained slot would silently drop the older owed work. Fail closed: a
|
|
3328
|
+
* hook that throws (or an unreadable slot) refuses the new commit and keeps
|
|
3329
|
+
* the old slot. Caller holds the channel lock.
|
|
3330
|
+
*
|
|
3331
|
+
* C2 T3c (M2): reads ONLY this channel's slot (`getOwedFollowUp`), so an
|
|
3332
|
+
* unreadable slot of another channel cannot refuse this channel's commit.
|
|
3333
|
+
* With `abandon` (the owner's explicit Reset), an UNREADABLE slot of this
|
|
3334
|
+
* channel is discarded (logged `mls.pending_commit.owed_abandoned`) rather
|
|
3335
|
+
* than blocking the owner's one way out; a hook that throws still refuses.
|
|
3336
|
+
*/
|
|
3337
|
+
async drainOwedFollowUpUnlocked(channelId, abandon = false) {
|
|
3338
|
+
let owed;
|
|
3339
|
+
try {
|
|
3340
|
+
owed = await this.o.store.getOwedFollowUp(channelId);
|
|
3341
|
+
} catch (e) {
|
|
3342
|
+
if (!abandon)
|
|
3343
|
+
throw e;
|
|
3344
|
+
writeServiceLog("warn", "mls.pending_commit.owed_abandoned", {}, e);
|
|
3345
|
+
await this.o.store.clearOwedFollowUp(channelId);
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
if (!owed)
|
|
3349
|
+
return;
|
|
3350
|
+
await this.o.onPendingCommitResolved(channelId, {
|
|
3351
|
+
op: owed.op,
|
|
3352
|
+
outcome: "landed",
|
|
3353
|
+
followUp: owed.followUp,
|
|
3354
|
+
via: "resolver"
|
|
3355
|
+
});
|
|
3356
|
+
await this.o.store.clearOwedFollowUp(channelId, owed.createdAt);
|
|
3357
|
+
}
|
|
3358
|
+
/** Tell the host how a record resolved (no follow-up owed). A throwing hook
|
|
3359
|
+
* is logged and swallowed for the same reason as above. */
|
|
3360
|
+
async notifyPendingResolved(channelId, ev) {
|
|
3361
|
+
try {
|
|
3362
|
+
await this.o.onPendingCommitResolved(channelId, ev);
|
|
3363
|
+
} catch (e) {
|
|
3364
|
+
writeServiceLog("warn", "mls.pending_commit.hook_failed", {}, e);
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
/** Persist the record BEFORE `/distribute`. A throw here propagates and the
|
|
3368
|
+
* commit is never sent (spec §4.1).
|
|
3369
|
+
*
|
|
3370
|
+
* C2 T3c: CREATE-ONLY, atomically in the store. This instance resolved the
|
|
3371
|
+
* channel's record at lock entry, but another tab or process holds its own
|
|
3372
|
+
* lock and may have staged a commit since. Overwriting that record would
|
|
3373
|
+
* orphan a commit that may land (the other instance could then never
|
|
3374
|
+
* promote it). So a present record refuses this commit with
|
|
3375
|
+
* `PendingCommitUnresolvedError` before anything is sent. */
|
|
3376
|
+
async stagePendingCommit(channelId, rec, replacing) {
|
|
3377
|
+
const opts = replacing ? { replacing } : { onlyIfAbsent: true };
|
|
3378
|
+
let wrote;
|
|
3379
|
+
try {
|
|
3380
|
+
wrote = await this.o.store.savePendingCommit(channelId, rec, opts);
|
|
3381
|
+
} catch (e) {
|
|
3382
|
+
if (replacing)
|
|
3383
|
+
await this.o.store.clearPendingCommit(channelId, replacing).catch(() => false);
|
|
3384
|
+
throw e;
|
|
3385
|
+
}
|
|
3386
|
+
if (!wrote)
|
|
3387
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3388
|
+
}
|
|
3389
|
+
/** Send `rec.request` byte-for-byte, then up to `resends` more times while
|
|
3390
|
+
* the outcome stays ambiguous (spec §4.3). Never rebuilds the commit. */
|
|
3391
|
+
async sendPendingCommit(rec, resends) {
|
|
3392
|
+
const delays = this.o.pendingResendDelaysMs ?? DEFAULT_PENDING_RESEND_DELAYS_MS;
|
|
3393
|
+
let last;
|
|
3394
|
+
for (let attempt = 0; attempt <= resends; attempt++) {
|
|
3395
|
+
if (attempt > 0)
|
|
3396
|
+
await sleepMs(delays[Math.min(attempt - 1, delays.length - 1)] ?? 0);
|
|
3397
|
+
try {
|
|
3398
|
+
await this.o.backend.distribute(rec.groupId, rec.request);
|
|
3399
|
+
return { kind: "landed" };
|
|
3400
|
+
} catch (e) {
|
|
3401
|
+
if (classifyDistributeError(e) === "definite")
|
|
3402
|
+
return { kind: "definite", error: e };
|
|
3403
|
+
last = e;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
return { kind: "ambiguous", error: last };
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* Apply a send outcome to the channel's record. Called under the channel lock.
|
|
3410
|
+
* - landed: custody of the epoch-N rows (C1b; skipped when there is no
|
|
3411
|
+
* prior MAIN), then conditional promotion.
|
|
3412
|
+
* - definite: the commit did not land. Clear the record, evict the cache,
|
|
3413
|
+
* tell the hook `not_landed`.
|
|
3414
|
+
* - ambiguous: KEEP the record, evict the cache (MAIN is still `prior`),
|
|
3415
|
+
* log the unresolved age (rate-limited). Nothing auto-discards on age.
|
|
3416
|
+
*/
|
|
3417
|
+
async settlePendingCommit(channelId, rec, out, via, built) {
|
|
3418
|
+
if (out.kind === "landed") {
|
|
3419
|
+
if (rec.prior !== null)
|
|
3420
|
+
await this.custodyAppRowsBeforeOwnCommit(channelId, rec.prior.epoch);
|
|
3421
|
+
const p = await this.promotePendingUnlocked(channelId, via, rec, built);
|
|
3422
|
+
return p === "none" ? "vanished" : p;
|
|
3423
|
+
}
|
|
3424
|
+
if (out.kind === "definite") {
|
|
3425
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec));
|
|
3426
|
+
this.managers.delete(channelId);
|
|
3427
|
+
await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via });
|
|
3428
|
+
return "not_landed";
|
|
3429
|
+
}
|
|
3430
|
+
this.managers.delete(channelId);
|
|
3431
|
+
this.logPendingUnresolved(channelId, rec);
|
|
3432
|
+
return "ambiguous";
|
|
3433
|
+
}
|
|
3434
|
+
logPendingUnresolved(channelId, rec) {
|
|
3435
|
+
const now = this.nowMs();
|
|
3436
|
+
const last = this.unresolvedLoggedAt.get(channelId);
|
|
3437
|
+
if (last !== void 0 && now - last < PENDING_UNRESOLVED_LOG_INTERVAL_MS)
|
|
3438
|
+
return;
|
|
3439
|
+
this.unresolvedLoggedAt.set(channelId, now);
|
|
3440
|
+
writeServiceLog("warn", "mls.pending_commit.unresolved", {
|
|
3441
|
+
age_s: Math.max(0, Math.floor((now - rec.createdAt) / 1e3))
|
|
3442
|
+
});
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* C2 (spec §4.2, H2): promote the record `proven` (whose landing this caller
|
|
3446
|
+
* PROVED: a 2xx for its bytes, or a trial decrypt under its state) ONLY if MAIN
|
|
3447
|
+
* is still the state the commit was built on. Caller holds the channel lock
|
|
3448
|
+
* (the receive path calls this inside the lock it already holds: LOW-4, the
|
|
3449
|
+
* queue is not reentrant).
|
|
3450
|
+
*
|
|
3451
|
+
* `prior` is compared by STRICT equality, and `null` means "no MAIN" (ruling
|
|
3452
|
+
* 2026-09-25): MAIN absent → promote; MAIN present at any group or epoch →
|
|
3453
|
+
* the record is stale. Match → `commitPending` (MAIN := rec.state, record
|
|
3454
|
+
* deleted, owed follow-up slot written, one atomic store step), install the
|
|
3455
|
+
* promoted manager, run the hook. Mismatch → discard the record, keep MAIN,
|
|
3456
|
+
* log `mls.pending_commit.stale_prior`: MAIN moved out of band (a v8 store
|
|
3457
|
+
* write after a downgrade, say), and promoting would rewind it.
|
|
3458
|
+
*
|
|
3459
|
+
* ⛔ C2 T3c (security review C1): EVERY store step here names `proven`'s key
|
|
3460
|
+
* (group, commit epoch, commit bytes) and the store checks it INSIDE the
|
|
3461
|
+
* step, together with "MAIN is exactly the state this caller just checked
|
|
3462
|
+
* against `prior`". A store shared by two tabs or processes may by now hold
|
|
3463
|
+
* ANOTHER record (the other instance resolved ours and staged its own, still
|
|
3464
|
+
* in flight). Promoting that record would make MAIN a state no server
|
|
3465
|
+
* accepted (a fork); clearing it would strand a commit that then lands.
|
|
3466
|
+
*
|
|
3467
|
+
* When `proven` is no longer in the store: it was resolved by another
|
|
3468
|
+
* instance. If MAIN is at or past `proven` in its group, that instance
|
|
3469
|
+
* promoted it (`proven` landed, so no foreign commit can hold its epoch):
|
|
3470
|
+
* report `landed` and drop the cache so MAIN reloads. Otherwise `none`.
|
|
3471
|
+
*
|
|
3472
|
+
* A throw from the store propagates with MAIN and the record unchanged (the
|
|
3473
|
+
* store's all-or-nothing contract).
|
|
3474
|
+
*
|
|
3475
|
+
* `built`: the manager an INLINE commit site built the commit with. Its state
|
|
3476
|
+
* IS `rec.state` (exported from it when the record was written, not mutated
|
|
3477
|
+
* since), so it is installed as the cached manager instead of a re-import,
|
|
3478
|
+
* exactly as the pre-C2 sites cached it after their save.
|
|
3479
|
+
*/
|
|
3480
|
+
async promotePendingUnlocked(channelId, via, proven, built) {
|
|
3481
|
+
const key = pendingCommitKey(proven);
|
|
3482
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3483
|
+
const mainState = await this.o.store.load(channelId);
|
|
3484
|
+
const main = mainState ? tagOfState(mainState) : null;
|
|
3485
|
+
const matches = proven.prior === null ? main === null : main !== null && main.groupId === proven.prior.groupId && main.epoch === proven.prior.epoch;
|
|
3486
|
+
if (!matches) {
|
|
3487
|
+
if (await this.o.store.clearPendingCommit(channelId, key)) {
|
|
3488
|
+
writeServiceLog("warn", "mls.pending_commit.stale_prior");
|
|
3489
|
+
await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "stale_prior", via });
|
|
3490
|
+
return "stale_prior";
|
|
3491
|
+
}
|
|
3492
|
+
return this.provenRecordGone(channelId, proven);
|
|
3493
|
+
}
|
|
3494
|
+
const r = await this.o.store.commitPending(channelId, { ...key, main: mainState });
|
|
3495
|
+
if (r === "record_mismatch")
|
|
3496
|
+
return this.provenRecordGone(channelId, proven);
|
|
3497
|
+
if (r === "main_moved")
|
|
3498
|
+
continue;
|
|
3499
|
+
let mgr = built;
|
|
3500
|
+
if (!mgr) {
|
|
3501
|
+
mgr = new MLSGroupManager2(proven.state.ciphersuite);
|
|
3502
|
+
mgr.importState(proven.state);
|
|
3503
|
+
}
|
|
3504
|
+
this.managers.set(channelId, mgr);
|
|
3505
|
+
if (proven.followUp)
|
|
3506
|
+
await this.runFollowUpHook(channelId, proven.op, proven.followUp, via, proven.createdAt);
|
|
3507
|
+
else
|
|
3508
|
+
await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "landed", via });
|
|
3509
|
+
return "landed";
|
|
3510
|
+
}
|
|
3511
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3512
|
+
}
|
|
3513
|
+
/** C2 T3c (C1): the proven record is not in the store. See
|
|
3514
|
+
* {@link promotePendingUnlocked}. */
|
|
3515
|
+
async provenRecordGone(channelId, proven) {
|
|
3516
|
+
const mainState = await this.o.store.load(channelId);
|
|
3517
|
+
const main = mainState ? tagOfState(mainState) : null;
|
|
3518
|
+
if (main !== null && main.groupId === proven.groupId && main.epoch >= proven.commitEpoch) {
|
|
3519
|
+
this.managers.delete(channelId);
|
|
3520
|
+
writeServiceLog("info", "mls.pending_commit.promoted_elsewhere");
|
|
3521
|
+
return "landed";
|
|
3522
|
+
}
|
|
3523
|
+
return "none";
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* C2 (spec §4.4 "Commit", plan step 3.6): resolve the channel's record at
|
|
3527
|
+
* lock entry, before a committer op, `ensureSession`, a reset or a supersede
|
|
3528
|
+
* builds anything. Resends the stored bytes and settles the outcome with
|
|
3529
|
+
* `via: "resolver"`. Returns `none` when there was no record (after
|
|
3530
|
+
* discarding any legacy pre-C2 staging slot once per channel per process:
|
|
3531
|
+
* MAIN is authoritative, and a staged state with no request cannot be
|
|
3532
|
+
* resent). The CALLER decides what `ambiguous` means; most throw
|
|
3533
|
+
* `PendingCommitUnresolvedError`.
|
|
3534
|
+
*/
|
|
3535
|
+
async resolvePendingCommitUnlocked(channelId, resends = PENDING_INLINE_RESENDS, saw) {
|
|
3536
|
+
const rec = await this.o.store.loadPendingCommit(channelId);
|
|
3537
|
+
saw?.(rec);
|
|
3538
|
+
if (!rec) {
|
|
3539
|
+
if (!this.legacyStagingChecked.has(channelId)) {
|
|
3540
|
+
await this.o.store.discardStaging(channelId);
|
|
3541
|
+
this.legacyStagingChecked.add(channelId);
|
|
3542
|
+
}
|
|
3543
|
+
return "none";
|
|
3544
|
+
}
|
|
3545
|
+
if (isPendingReservation(rec)) {
|
|
3546
|
+
this.logPendingUnresolved(channelId, rec);
|
|
3547
|
+
return "ambiguous";
|
|
3548
|
+
}
|
|
3549
|
+
const out = await this.sendPendingCommit(rec, resends);
|
|
3550
|
+
const r = await this.settlePendingCommit(channelId, rec, out, "resolver");
|
|
3551
|
+
return r === "vanished" ? "none" : r;
|
|
3552
|
+
}
|
|
3553
|
+
/** {@link resolvePendingCommitUnlocked}, then refuse to continue while the
|
|
3554
|
+
* record stays ambiguous. The standard lock-entry guard. */
|
|
3555
|
+
async requirePendingResolvedUnlocked(channelId) {
|
|
3556
|
+
const r = await this.resolvePendingCommitUnlocked(channelId);
|
|
3557
|
+
if (r === "ambiguous")
|
|
3558
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3559
|
+
return r;
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* C2 (OQ-C2-1 ruling): the owner's explicit abandon, reached ONLY through
|
|
3563
|
+
* `resetSessionSupersede(..., { abandonPending: true })`. Discards the
|
|
3564
|
+
* record without knowing whether its commit landed; the attested reset that
|
|
3565
|
+
* follows is the correct recovery either way. Logs
|
|
3566
|
+
* `mls.pending_commit.abandoned` and tells the hook `abandoned`.
|
|
3567
|
+
*/
|
|
3568
|
+
/**
|
|
3569
|
+
* C2 T3d (MEDIUM-1): `seen` is the record the owner's resolve ACTUALLY saw
|
|
3570
|
+
* and found ambiguous, or "unreadable" when that load threw. A seen record
|
|
3571
|
+
* is cleared KEYED: if another instance meanwhile proved it landed, promoted
|
|
3572
|
+
* it and staged a newer, unproven record, that newer record is not the one
|
|
3573
|
+
* the owner abandoned, and clearing it would strand a commit that may land.
|
|
3574
|
+
* Then nothing is cleared and this returns false (the caller refuses; the
|
|
3575
|
+
* owner retries). Only an UNREADABLE record is cleared unkeyed: nothing can
|
|
3576
|
+
* read its key, and this is the owner's one way out of it.
|
|
3577
|
+
*/
|
|
3578
|
+
async abandonPendingCommitUnlocked(channelId, seen) {
|
|
3579
|
+
if (seen === "unreadable") {
|
|
3580
|
+
writeServiceLog("warn", "mls.pending_commit.abandon_unreadable");
|
|
3581
|
+
await this.o.store.clearPendingCommit(channelId);
|
|
3582
|
+
this.managers.delete(channelId);
|
|
3583
|
+
writeServiceLog("warn", "mls.pending_commit.abandoned");
|
|
3584
|
+
return true;
|
|
3585
|
+
}
|
|
3586
|
+
if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(seen))) {
|
|
3587
|
+
writeServiceLog("warn", "mls.pending_commit.abandon_raced");
|
|
3588
|
+
return false;
|
|
3589
|
+
}
|
|
3590
|
+
this.managers.delete(channelId);
|
|
3591
|
+
writeServiceLog("warn", "mls.pending_commit.abandoned");
|
|
3592
|
+
await this.notifyPendingResolved(channelId, { op: seen.op, outcome: "abandoned", via: "resolver" });
|
|
3593
|
+
return true;
|
|
3594
|
+
}
|
|
3595
|
+
/**
|
|
3596
|
+
* The commit tail shared by the six commit sites (spec §4.1): persist the
|
|
3597
|
+
* record, send, settle inline. Returns `{ landed: true }` after promotion,
|
|
3598
|
+
* or the definite rejection for the site's own existing path. Throws
|
|
3599
|
+
* `PendingCommitUnresolvedError` when the outcome stays ambiguous after the
|
|
3600
|
+
* inline resends (the record is kept and the channel fenced), and a plain
|
|
3601
|
+
* content-free `Error` when promotion found MAIN moved out of band.
|
|
3602
|
+
*/
|
|
3603
|
+
async commitWithPendingRecord(channelId, rec, built, replacing) {
|
|
3604
|
+
await this.stagePendingCommit(channelId, rec, replacing);
|
|
3605
|
+
const out = await this.sendPendingCommit(rec, PENDING_INLINE_RESENDS);
|
|
3606
|
+
const r = await this.settlePendingCommit(channelId, rec, out, "inline", built);
|
|
3607
|
+
if (r === "landed")
|
|
3608
|
+
return { landed: true };
|
|
3609
|
+
if (r === "not_landed")
|
|
3610
|
+
return { landed: false, error: out.error };
|
|
3611
|
+
if (r === "ambiguous")
|
|
3612
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3613
|
+
throw new Error(`mls.pending_commit.${r}: the commit's prior state moved before promotion; not applied locally`);
|
|
3614
|
+
}
|
|
3615
|
+
/** Build the record for a commit about to be sent. `prior` is the MAIN the
|
|
3616
|
+
* commit was built on (null: none). */
|
|
3617
|
+
pendingRecord(op, groupId, request, mgr, prior, followUp) {
|
|
3618
|
+
return {
|
|
3619
|
+
groupId,
|
|
3620
|
+
op,
|
|
3621
|
+
request,
|
|
3622
|
+
state: mgr.exportState(),
|
|
3623
|
+
commitEpoch: Number(mgr.epoch),
|
|
3624
|
+
prior,
|
|
3625
|
+
...followUp ? { followUp } : {},
|
|
3626
|
+
createdAt: this.nowMs()
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* C2 (spec §4.4 "Send", HIGH-3, MEDIUM-1): the send fence. Throws
|
|
3631
|
+
* `PendingCommitUnresolvedError` while the channel holds a pending record,
|
|
3632
|
+
* so no key is derived for SEND (message, vault KEK seal, 1:1 voice) at an
|
|
3633
|
+
* epoch a landed Remove may already have rotated away from. Re-reads the
|
|
3634
|
+
* DURABLE record on every call, so a record another tab or process wrote is
|
|
3635
|
+
* seen the first time, and a record another tab resolved no longer fences.
|
|
3636
|
+
* Receive never calls this: it derives through {@link exporterKeyUnfenced}.
|
|
3637
|
+
*/
|
|
3638
|
+
async assertNotPending(channelId) {
|
|
3639
|
+
this.kickOwedRerun();
|
|
3640
|
+
if (await this.o.store.loadPendingCommit(channelId)) {
|
|
3641
|
+
this.scheduleAutoResolve(channelId);
|
|
3642
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* C2 T3c (H1): resolve the channel's pending record in the background, at
|
|
3647
|
+
* most once per minute per channel. Deferred to a later tick and run under
|
|
3648
|
+
* the channel lock like any lock-entry resolve (never inside the receive
|
|
3649
|
+
* lock that noticed it; the queue is not reentrant). It only RESENDS the
|
|
3650
|
+
* stored bytes, exactly as a committer's lock entry does. Failures are
|
|
3651
|
+
* logged, rate-limited, and otherwise ignored: the record stays and the
|
|
3652
|
+
* next trigger tries again.
|
|
3653
|
+
*/
|
|
3654
|
+
scheduleAutoResolve(channelId) {
|
|
3655
|
+
const now = this.nowMs();
|
|
3656
|
+
const last = this.autoResolveAt.get(channelId);
|
|
3657
|
+
if (last !== void 0 && now - last < PENDING_AUTO_RESOLVE_INTERVAL_MS)
|
|
3658
|
+
return;
|
|
3659
|
+
this.autoResolveAt.set(channelId, now);
|
|
3660
|
+
const job = (async () => {
|
|
3661
|
+
await Promise.resolve();
|
|
3662
|
+
try {
|
|
3663
|
+
await this.locks.run(channelId, () => this.resolvePendingCommitUnlocked(channelId));
|
|
3664
|
+
} catch (e) {
|
|
3665
|
+
writeServiceLog("warn", "mls.pending_commit.auto_resolve_failed", {}, e);
|
|
3666
|
+
}
|
|
3667
|
+
})();
|
|
3668
|
+
this.backgroundResolves.add(job);
|
|
3669
|
+
void job.finally(() => this.backgroundResolves.delete(job));
|
|
3670
|
+
}
|
|
3671
|
+
/** C2 (for T6's stuck state): ms since the channel's pending record was
|
|
3672
|
+
* written, or null when it has none. A durable read; never throws on
|
|
3673
|
+
* absence. */
|
|
3674
|
+
async pendingCommitAgeMs(channelId) {
|
|
3675
|
+
const rec = await this.o.store.loadPendingCommit(channelId);
|
|
3676
|
+
return rec ? Math.max(0, this.nowMs() - rec.createdAt) : null;
|
|
3677
|
+
}
|
|
3678
|
+
/** Drop `channelId`'s cached manager only if it is still `mgr` (a concurrent
|
|
3679
|
+
* supersede may already have installed a different one, which must survive). */
|
|
3680
|
+
evictIfCached(channelId, mgr) {
|
|
3681
|
+
if (this.managers.get(channelId) === mgr)
|
|
3682
|
+
this.managers.delete(channelId);
|
|
3683
|
+
}
|
|
2878
3684
|
async evictOnThrow(channelId, op) {
|
|
2879
3685
|
try {
|
|
2880
3686
|
return await op();
|
|
@@ -2883,7 +3689,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2883
3689
|
throw e;
|
|
2884
3690
|
}
|
|
2885
3691
|
}
|
|
3692
|
+
/**
|
|
3693
|
+
* The public exporter key. C2 (HIGH-3): fenced. While the channel holds an
|
|
3694
|
+
* unresolved pending commit this throws `PendingCommitUnresolvedError`, so
|
|
3695
|
+
* nothing is sealed at an epoch a landed Remove may have rotated away from.
|
|
3696
|
+
*
|
|
3697
|
+
* ⚠️ C2 T3c (review M4) CORRECTED THIS COMMENT: not every external caller
|
|
3698
|
+
* SEALS. Its callers are the envelope builder and the fake's
|
|
3699
|
+
* `sendApplication` (seal), the vault KEK seal in connect-web (seal), AND
|
|
3700
|
+
* connect-web's vault OPEN (`useVault.ts` `openVaultKekset`, which peeks and
|
|
3701
|
+
* opens the sealed KEK set with it). The fence cannot tell a seal from an
|
|
3702
|
+
* open, so a pending record also fails the vault open closed (the accepted
|
|
3703
|
+
* HIGH-3 consequence; T6 maps it to the retryable not-ready outcome). The
|
|
3704
|
+
* same holds for {@link voiceMediaKey}: BOTH ends of a 1:1 call derive the
|
|
3705
|
+
* media key through it, so the callee's join fails closed too while a record
|
|
3706
|
+
* is pending. Receive-side derivations inside this file use
|
|
3707
|
+
* {@link exporterKeyUnfenced} and keep decrypting epoch N. A refused call
|
|
3708
|
+
* starts the automatic resolve (T3c, H1), so the fence lifts on its own
|
|
3709
|
+
* once the outcome is known.
|
|
3710
|
+
*/
|
|
2886
3711
|
async exporterKey(enclaveId, channelId, epoch) {
|
|
3712
|
+
await this.assertNotPending(channelId);
|
|
3713
|
+
return this.exporterKeyUnfenced(enclaveId, channelId, epoch);
|
|
3714
|
+
}
|
|
3715
|
+
/** C2: the unfenced exporter read, for RECEIVE derivations only
|
|
3716
|
+
* (`decryptApplicationRow`, `replayMissedApplicationMessages`). Reads MAIN,
|
|
3717
|
+
* which never moves past a pending record. */
|
|
3718
|
+
async exporterKeyUnfenced(enclaveId, channelId, epoch) {
|
|
2887
3719
|
const mgr = await this.manager(channelId);
|
|
2888
3720
|
return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
|
|
2889
3721
|
}
|
|
@@ -2904,6 +3736,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2904
3736
|
* inside another `locks.run(channelId, ...)` for the same channel.
|
|
2905
3737
|
*/
|
|
2906
3738
|
async voiceMediaKey(enclaveId, channelId, epoch, callId) {
|
|
3739
|
+
await this.assertNotPending(channelId);
|
|
2907
3740
|
return this.locks.run(channelId, async () => {
|
|
2908
3741
|
const mgr = await this.manager(channelId);
|
|
2909
3742
|
return mgr.exportSecret(VOICE_MEDIA_EXPORTER_LABEL, buildVoiceExporterContext(enclaveId, channelId, epoch, callId), 32);
|
|
@@ -3060,13 +3893,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3060
3893
|
const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
|
|
3061
3894
|
const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
|
|
3062
3895
|
if (!expired) {
|
|
3063
|
-
|
|
3896
|
+
writeServiceLog("warn", "keypackage.publish_skipped_pending");
|
|
3064
3897
|
return kpId;
|
|
3065
3898
|
}
|
|
3066
|
-
|
|
3899
|
+
writeServiceLog("warn", "keypackage.expired_rotation");
|
|
3067
3900
|
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
3068
3901
|
}
|
|
3069
|
-
|
|
3902
|
+
writeServiceLog("warn", "keypackage.minting_fresh");
|
|
3070
3903
|
const mgr = new MLSGroupManager2();
|
|
3071
3904
|
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
3072
3905
|
const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
|
|
@@ -3081,7 +3914,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3081
3914
|
return this.o.store.loadLastKpId(this.o.deviceId);
|
|
3082
3915
|
}
|
|
3083
3916
|
async ensureSession(channelId, opts) {
|
|
3084
|
-
return this.locks.run(channelId, () => this.
|
|
3917
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
3918
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
3919
|
+
return this.ensureSessionUnlocked(channelId, opts);
|
|
3920
|
+
}));
|
|
3085
3921
|
}
|
|
3086
3922
|
async ensureSessionUnlocked(channelId, opts) {
|
|
3087
3923
|
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
@@ -3115,16 +3951,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3115
3951
|
member_device_ids: [this.o.deviceId, ...others],
|
|
3116
3952
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
3117
3953
|
});
|
|
3118
|
-
await this.
|
|
3954
|
+
await this.saveMain(channelId, mgr);
|
|
3119
3955
|
this.managers.set(channelId, mgr);
|
|
3120
3956
|
return;
|
|
3121
3957
|
}
|
|
3122
3958
|
if (opts.bootstrap) {
|
|
3123
|
-
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
3124
|
-
if (init.created === false)
|
|
3125
|
-
throw new BootstrapRaceLostError();
|
|
3126
|
-
const mgr = new MLSGroupManager2();
|
|
3127
|
-
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
3128
3959
|
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
3129
3960
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
3130
3961
|
const pkgs = others.map((d) => {
|
|
@@ -3133,20 +3964,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3133
3964
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
3134
3965
|
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
3135
3966
|
});
|
|
3967
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
3968
|
+
if (init.created === false) {
|
|
3969
|
+
if (!this.isAdoptableOrphan(init, others)) {
|
|
3970
|
+
if (this.isOrphanShape(init, others))
|
|
3971
|
+
throw new AttestedOrphanRefusedError(channelId);
|
|
3972
|
+
throw new BootstrapRaceLostError();
|
|
3973
|
+
}
|
|
3974
|
+
writeServiceLog("warn", "session.bootstrap_orphan_adopted");
|
|
3975
|
+
}
|
|
3976
|
+
const mgr = new MLSGroupManager2();
|
|
3977
|
+
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
3136
3978
|
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
3137
|
-
|
|
3979
|
+
const rec = this.pendingRecord("bootstrap", init.group_id, {
|
|
3138
3980
|
device_id: this.o.deviceId,
|
|
3139
3981
|
commit: bytesToHex2(commit),
|
|
3140
3982
|
commit_epoch: Number(mgr.epoch),
|
|
3141
3983
|
expected_epoch: init.current_epoch,
|
|
3142
3984
|
member_device_ids: [this.o.deviceId, ...others],
|
|
3143
3985
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
3144
|
-
});
|
|
3145
|
-
this.
|
|
3146
|
-
|
|
3986
|
+
}, mgr, null);
|
|
3987
|
+
const res = await this.commitWithPendingRecord(channelId, rec, mgr);
|
|
3988
|
+
if (!res.landed) {
|
|
3989
|
+
const e = res.error;
|
|
3990
|
+
if (e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT")
|
|
3991
|
+
throw new BootstrapRaceLostError();
|
|
3992
|
+
throw e;
|
|
3993
|
+
}
|
|
3147
3994
|
} else {
|
|
3148
3995
|
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
3149
|
-
const w = rows.find((r) => r.message_type === "welcome" &&
|
|
3996
|
+
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
|
|
3150
3997
|
if (!w)
|
|
3151
3998
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
3152
3999
|
const mgr = new MLSGroupManager2();
|
|
@@ -3154,15 +4001,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3154
4001
|
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
3155
4002
|
} catch (err) {
|
|
3156
4003
|
this.recordDecryptError(w, err);
|
|
3157
|
-
|
|
4004
|
+
writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
|
|
3158
4005
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
3159
4006
|
return;
|
|
3160
4007
|
}
|
|
3161
4008
|
this.managers.set(channelId, mgr);
|
|
3162
|
-
await this.
|
|
4009
|
+
await this.saveMain(channelId, mgr);
|
|
3163
4010
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
3164
4011
|
}
|
|
3165
4012
|
}
|
|
4013
|
+
/**
|
|
4014
|
+
* C5 (Phase 3): may a bootstrap ADOPT the existing server group `init`
|
|
4015
|
+
* returned (created=false)? Only an orphan of the right SHAPE
|
|
4016
|
+
* ({@link isOrphanShape}) that the server reports as NOT owner-attested:
|
|
4017
|
+
* `reset_attested === false`, strictly. C2 T3b: `true` and ABSENT both refuse
|
|
4018
|
+
* (fail closed; an older backend omits the field).
|
|
4019
|
+
*/
|
|
4020
|
+
isAdoptableOrphan(init, others) {
|
|
4021
|
+
return this.isOrphanShape(init, others) && init.reset_attested === false;
|
|
4022
|
+
}
|
|
4023
|
+
/** C5: still at epoch 0 (no commit ever accepted, so nobody holds usable
|
|
4024
|
+
* state), with a roster that is exactly this device plus devices this
|
|
4025
|
+
* bootstrap Adds. Fails closed on a missing roster. Says nothing about
|
|
4026
|
+
* attestation (see {@link isAdoptableOrphan}). */
|
|
4027
|
+
isOrphanShape(init, others) {
|
|
4028
|
+
if (init.current_epoch !== 0)
|
|
4029
|
+
return false;
|
|
4030
|
+
if (!Array.isArray(init.member_device_ids))
|
|
4031
|
+
return false;
|
|
4032
|
+
return init.member_device_ids.every((d) => d === this.o.deviceId || others.includes(d));
|
|
4033
|
+
}
|
|
3166
4034
|
/**
|
|
3167
4035
|
* FileSafe vault owner bootstrap (Task 7, P4/P5 wiring): create the LOCAL
|
|
3168
4036
|
* ts-mls group bound to a group_id the backend ALREADY minted, WITHOUT
|
|
@@ -3211,7 +4079,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3211
4079
|
const mgr = new MLSGroupManager2();
|
|
3212
4080
|
await mgr.createGroup(new TextEncoder().encode(groupId), bundle);
|
|
3213
4081
|
this.managers.set(channelId, mgr);
|
|
3214
|
-
await this.
|
|
4082
|
+
await this.saveMain(channelId, mgr);
|
|
3215
4083
|
}
|
|
3216
4084
|
/**
|
|
3217
4085
|
* Committer-side: add a member to an ALREADY-bootstrapped (live) group.
|
|
@@ -3227,7 +4095,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3227
4095
|
* parallel group_id-by-channel resolver.
|
|
3228
4096
|
*/
|
|
3229
4097
|
async addMemberToLiveGroup(channelId, newDeviceId, opts) {
|
|
3230
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4098
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4099
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4100
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts);
|
|
4101
|
+
}));
|
|
3231
4102
|
}
|
|
3232
4103
|
/**
|
|
3233
4104
|
* Committer-side (owner): add a member to an already-bootstrapped VAULT
|
|
@@ -3248,7 +4119,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3248
4119
|
if (!opts.expectedCredentialPubKey) {
|
|
3249
4120
|
throw new VaultCredentialMismatchError(opts.expectedCredentialPubKey, "");
|
|
3250
4121
|
}
|
|
3251
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4122
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4123
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4124
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts });
|
|
4125
|
+
}));
|
|
3252
4126
|
}
|
|
3253
4127
|
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
3254
4128
|
const isVault = !!opts && "vaultAdd" in opts && opts.vaultAdd === true;
|
|
@@ -3303,13 +4177,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3303
4177
|
deltaVouches
|
|
3304
4178
|
});
|
|
3305
4179
|
if (hasMalformedBinding(identityLeaves)) {
|
|
3306
|
-
|
|
4180
|
+
writeServiceLog("warn", "admission.malformed_leaf_binding");
|
|
3307
4181
|
}
|
|
3308
4182
|
if (targetLeaves.length > 0 && deltaVouches) {
|
|
3309
|
-
|
|
4183
|
+
writeServiceLog("warn", "admission.possible_keypackage_substitution", {
|
|
4184
|
+
leaf_count: targetLeaves.length
|
|
4185
|
+
});
|
|
3310
4186
|
}
|
|
3311
4187
|
if (targetLeaves.length > 1) {
|
|
3312
|
-
|
|
4188
|
+
writeServiceLog("warn", "admission.duplicate_device_leaves", {
|
|
4189
|
+
leaf_count: targetLeaves.length
|
|
4190
|
+
});
|
|
3313
4191
|
}
|
|
3314
4192
|
switch (verdict.decision) {
|
|
3315
4193
|
case "admit":
|
|
@@ -3336,21 +4214,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3336
4214
|
groupId = init.group_id;
|
|
3337
4215
|
members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
3338
4216
|
}
|
|
4217
|
+
if (new TextDecoder().decode(mgr.groupId) !== groupId) {
|
|
4218
|
+
throw new Error("addMemberToLiveGroup: group binding mismatch");
|
|
4219
|
+
}
|
|
3339
4220
|
const priorEpoch = Number(mgr.epoch);
|
|
4221
|
+
const prior = tagOf(mgr);
|
|
3340
4222
|
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
4223
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("add", groupId, {
|
|
4224
|
+
device_id: this.o.deviceId,
|
|
4225
|
+
commit: bytesToHex2(commit),
|
|
4226
|
+
commit_epoch: Number(mgr.epoch),
|
|
4227
|
+
expected_epoch: priorEpoch,
|
|
4228
|
+
member_device_ids: members,
|
|
4229
|
+
op: "add",
|
|
4230
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
|
|
4231
|
+
}, mgr, prior), mgr);
|
|
4232
|
+
if (!res.landed) {
|
|
4233
|
+
const e = res.error;
|
|
4234
|
+
if (isSupersededRefusal(e))
|
|
4235
|
+
throw new GroupSupersededError(channelId, groupId);
|
|
4236
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3354
4237
|
throw e;
|
|
3355
4238
|
try {
|
|
3356
4239
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3358,7 +4241,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3358
4241
|
}
|
|
3359
4242
|
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
3360
4243
|
}
|
|
3361
|
-
await this.o.store.save(channelId, mgr.exportState());
|
|
3362
4244
|
return "committed";
|
|
3363
4245
|
}
|
|
3364
4246
|
/**
|
|
@@ -3381,36 +4263,43 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3381
4263
|
* of this group.
|
|
3382
4264
|
*/
|
|
3383
4265
|
async removeMemberFromLiveGroup(channelId, identityId) {
|
|
3384
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4266
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4267
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4268
|
+
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0);
|
|
4269
|
+
}));
|
|
3385
4270
|
}
|
|
3386
4271
|
async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
|
|
3387
4272
|
const mgr = await this.manager(channelId);
|
|
3388
4273
|
const indices = mgr.leafIndicesForIdentity(identityId);
|
|
3389
4274
|
if (indices.length === 0)
|
|
3390
4275
|
return;
|
|
4276
|
+
this.refuseOwnLeafRemove(mgr, indices, "removeMember");
|
|
3391
4277
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
4278
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
4279
|
+
throw new Error("removeMemberFromLiveGroup: group binding mismatch");
|
|
4280
|
+
}
|
|
3392
4281
|
const priorEpoch = Number(mgr.epoch);
|
|
4282
|
+
const prior = tagOf(mgr);
|
|
3393
4283
|
const { commit } = await mgr.removeMembers(indices);
|
|
3394
4284
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3395
4285
|
throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
|
|
3396
4286
|
}
|
|
3397
|
-
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
3398
4287
|
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
if (_attempt >= 2)
|
|
4288
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
4289
|
+
device_id: this.o.deviceId,
|
|
4290
|
+
commit: bytesToHex2(commit),
|
|
4291
|
+
commit_epoch: Number(mgr.epoch),
|
|
4292
|
+
expected_epoch: priorEpoch,
|
|
4293
|
+
member_device_ids: members,
|
|
4294
|
+
op: "remove",
|
|
4295
|
+
removed_identity_id: identityId,
|
|
4296
|
+
welcomes: []
|
|
4297
|
+
}, mgr, prior), mgr);
|
|
4298
|
+
if (!res.landed) {
|
|
4299
|
+
const e = res.error;
|
|
4300
|
+
if (isSupersededRefusal(e))
|
|
4301
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
4302
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3414
4303
|
throw e;
|
|
3415
4304
|
try {
|
|
3416
4305
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3418,7 +4307,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3418
4307
|
}
|
|
3419
4308
|
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
|
|
3420
4309
|
}
|
|
3421
|
-
await this.o.store.promoteStaging(channelId);
|
|
3422
4310
|
}
|
|
3423
4311
|
/**
|
|
3424
4312
|
* Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
|
|
@@ -3457,27 +4345,38 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3457
4345
|
* should delegate to it.
|
|
3458
4346
|
*
|
|
3459
4347
|
* Returns null rather than throwing so every caller must decide explicitly what
|
|
3460
|
-
* an unknown own-leaf means.
|
|
3461
|
-
*
|
|
3462
|
-
*
|
|
3463
|
-
*
|
|
3464
|
-
* `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
|
|
3465
|
-
* identities at all, so a null return sends an OWN-identity removal down the
|
|
3466
|
-
* positional `leaves[pos]` path, which is exactly the add-order guess the
|
|
3467
|
-
* self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
|
|
3468
|
-
* permutes that mapping after any Remove). Null is therefore currently
|
|
3469
|
-
* indistinguishable from "foreign identity", and only the cardinality guard
|
|
3470
|
-
* stands between it and evicting the wrong device of the same human.
|
|
3471
|
-
* Correcting that is Phase 1b, not this comment.
|
|
4348
|
+
* an unknown own-leaf means. Every remove path now decides the same way: null
|
|
4349
|
+
* FAILS CLOSED (Phase 3 C3, {@link refuseOwnLeafRemove}). Before C3,
|
|
4350
|
+
* {@link removeDeviceFromLiveGroupUnlocked} treated null as "foreign identity"
|
|
4351
|
+
* and sent an own-identity removal down the positional add-order guess.
|
|
3472
4352
|
*/
|
|
3473
4353
|
ownLeafIndex(mgr) {
|
|
3474
4354
|
const idx = mgr.state?.privatePath?.leafIndex;
|
|
3475
4355
|
return typeof idx === "number" ? idx : null;
|
|
3476
4356
|
}
|
|
3477
|
-
|
|
3478
|
-
|
|
4357
|
+
/**
|
|
4358
|
+
* Phase 3 C3: the shared own-leaf refusal for every remove path. Throws
|
|
4359
|
+
* {@link OwnLeafRemoveRefusedError} when this committer's own leaf index is
|
|
4360
|
+
* unknown (fail closed) or is among `targetLeaves`. Returns the own leaf index
|
|
4361
|
+
* so a caller that needs it (self-exclusion) reads it from the same check.
|
|
4362
|
+
* Synchronous and called before any commit is built: a refusal distributes
|
|
4363
|
+
* nothing and leaves the cached manager untouched.
|
|
4364
|
+
*/
|
|
4365
|
+
refuseOwnLeafRemove(mgr, targetLeaves, op) {
|
|
4366
|
+
const ownLeaf = this.ownLeafIndex(mgr);
|
|
4367
|
+
if (ownLeaf === null)
|
|
4368
|
+
throw new OwnLeafRemoveRefusedError(op, "own_leaf_unknown");
|
|
4369
|
+
if (targetLeaves.includes(ownLeaf))
|
|
4370
|
+
throw new OwnLeafRemoveRefusedError(op, "own_leaf");
|
|
4371
|
+
return ownLeaf;
|
|
3479
4372
|
}
|
|
3480
|
-
async
|
|
4373
|
+
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
4374
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4375
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4376
|
+
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0);
|
|
4377
|
+
}));
|
|
4378
|
+
}
|
|
4379
|
+
async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
|
|
3481
4380
|
const mgr = await this.manager(channelId);
|
|
3482
4381
|
let shadowResolutionTaken;
|
|
3483
4382
|
try {
|
|
@@ -3541,9 +4440,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3541
4440
|
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
3542
4441
|
throw new LeafRosterMismatchError();
|
|
3543
4442
|
}
|
|
4443
|
+
if (deviceId === this.o.deviceId)
|
|
4444
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
|
|
3544
4445
|
let leafIndex;
|
|
3545
4446
|
const ownLeaf = this.ownLeafIndex(mgr);
|
|
3546
|
-
if (ownLeaf
|
|
4447
|
+
if (ownLeaf === null)
|
|
4448
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf_unknown");
|
|
4449
|
+
if (leaves.includes(ownLeaf)) {
|
|
3547
4450
|
const others = leaves.filter((i) => i !== ownLeaf);
|
|
3548
4451
|
if (others.length !== 1) {
|
|
3549
4452
|
throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
|
|
@@ -3553,30 +4456,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3553
4456
|
leafIndex = leaves[pos];
|
|
3554
4457
|
}
|
|
3555
4458
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
4459
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
4460
|
+
throw new Error("removeDeviceFromLiveGroup: group binding mismatch");
|
|
4461
|
+
}
|
|
3556
4462
|
const priorEpoch = Number(mgr.epoch);
|
|
4463
|
+
const prior = tagOf(mgr);
|
|
3557
4464
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
3558
4465
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3559
4466
|
throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
|
|
3560
4467
|
}
|
|
3561
|
-
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
3562
4468
|
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
3563
4469
|
const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex2(opts.identity);
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
if (_attempt >= 2)
|
|
4470
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
4471
|
+
device_id: this.o.deviceId,
|
|
4472
|
+
commit: bytesToHex2(commit),
|
|
4473
|
+
commit_epoch: Number(mgr.epoch),
|
|
4474
|
+
expected_epoch: priorEpoch,
|
|
4475
|
+
member_device_ids: members,
|
|
4476
|
+
op: "remove",
|
|
4477
|
+
removed_identity_id: removedIdentity,
|
|
4478
|
+
removed_device_id: deviceId,
|
|
4479
|
+
welcomes: []
|
|
4480
|
+
}, mgr, prior), mgr);
|
|
4481
|
+
if (!res.landed) {
|
|
4482
|
+
const e = res.error;
|
|
4483
|
+
if (isSupersededRefusal(e))
|
|
4484
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
4485
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3580
4486
|
throw e;
|
|
3581
4487
|
try {
|
|
3582
4488
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3584,7 +4490,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3584
4490
|
}
|
|
3585
4491
|
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
3586
4492
|
}
|
|
3587
|
-
await this.o.store.promoteStaging(channelId);
|
|
3588
4493
|
}
|
|
3589
4494
|
/**
|
|
3590
4495
|
* Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
|
|
@@ -3642,7 +4547,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3642
4547
|
* re-runs on every attempt.
|
|
3643
4548
|
*/
|
|
3644
4549
|
async removeVaultLeafFromLiveGroup(channelId, expectedLeafSignaturePubKeyHex, opts) {
|
|
3645
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4550
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4551
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4552
|
+
return this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0);
|
|
4553
|
+
}));
|
|
3646
4554
|
}
|
|
3647
4555
|
async removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt) {
|
|
3648
4556
|
const mgr = await this.manager(channelId);
|
|
@@ -3668,33 +4576,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3668
4576
|
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
3669
4577
|
if (claimed !== opts.expectedIdentityId)
|
|
3670
4578
|
throw new VaultLeafTOCTOUError();
|
|
4579
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeVaultLeaf");
|
|
4580
|
+
const followUp = opts.deviceId && opts.enclaveId ? { kind: "vault-remove", enclaveId: opts.enclaveId, deviceId: opts.deviceId } : void 0;
|
|
4581
|
+
if (followUp)
|
|
4582
|
+
await this.drainOwedFollowUpUnlocked(channelId);
|
|
3671
4583
|
const priorEpoch = Number(mgr.epoch);
|
|
4584
|
+
const prior = tagOf(mgr);
|
|
3672
4585
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
3673
4586
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3674
4587
|
throw new Error("removeVaultLeafFromLiveGroup: unexpected epoch drift before distribute");
|
|
3675
4588
|
}
|
|
3676
4589
|
const groupId = new TextDecoder().decode(mgr.groupId);
|
|
3677
|
-
await this.
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
if (_attempt >= 2)
|
|
4590
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", groupId, {
|
|
4591
|
+
device_id: this.o.deviceId,
|
|
4592
|
+
commit: bytesToHex2(commit),
|
|
4593
|
+
commit_epoch: Number(mgr.epoch),
|
|
4594
|
+
expected_epoch: priorEpoch,
|
|
4595
|
+
member_device_ids: opts.vaultContext.memberDeviceIds,
|
|
4596
|
+
op: "remove",
|
|
4597
|
+
removed_identity_id: opts.expectedIdentityId,
|
|
4598
|
+
// Device-granular distribute (C1): when opts.deviceId is empty (no
|
|
4599
|
+
// owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
|
|
4600
|
+
// the server falls back to identity-level eviction. The crypto
|
|
4601
|
+
// removal above already stands regardless; only delivery degrades.
|
|
4602
|
+
...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
|
|
4603
|
+
welcomes: []
|
|
4604
|
+
}, mgr, prior, followUp), mgr);
|
|
4605
|
+
if (!res.landed) {
|
|
4606
|
+
const e = res.error;
|
|
4607
|
+
if (isSupersededRefusal(e))
|
|
4608
|
+
throw new GroupSupersededError(channelId, groupId);
|
|
4609
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3698
4610
|
throw e;
|
|
3699
4611
|
try {
|
|
3700
4612
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3702,7 +4614,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3702
4614
|
}
|
|
3703
4615
|
return this.removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt + 1);
|
|
3704
4616
|
}
|
|
3705
|
-
await this.o.store.promoteStaging(channelId);
|
|
3706
4617
|
}
|
|
3707
4618
|
/**
|
|
3708
4619
|
* Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
|
|
@@ -3793,6 +4704,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3793
4704
|
*/
|
|
3794
4705
|
async healDivergedMember(channelId, memberDeviceId, opts) {
|
|
3795
4706
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4707
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
3796
4708
|
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
3797
4709
|
const hex = kps[memberDeviceId];
|
|
3798
4710
|
if (!hex)
|
|
@@ -3819,7 +4731,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3819
4731
|
}
|
|
3820
4732
|
}
|
|
3821
4733
|
if (leaves.length === 1) {
|
|
3822
|
-
|
|
4734
|
+
writeServiceLog("warn", "heal.single_leaf_device_unattributed");
|
|
3823
4735
|
}
|
|
3824
4736
|
let removeLanded = false;
|
|
3825
4737
|
if (leaves.length === 1) {
|
|
@@ -3921,6 +4833,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3921
4833
|
*/
|
|
3922
4834
|
async resetChannelForRejoin(channelId) {
|
|
3923
4835
|
await this.locks.run(channelId, async () => {
|
|
4836
|
+
const r = await this.resolvePendingCommitUnlocked(channelId);
|
|
4837
|
+
if (r === "ambiguous")
|
|
4838
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
4839
|
+
if (r === "landed") {
|
|
4840
|
+
writeServiceLog("info", "mls.pending_commit.resolved_pending");
|
|
4841
|
+
return;
|
|
4842
|
+
}
|
|
3924
4843
|
this.evictChannelState(channelId);
|
|
3925
4844
|
await this.o.store.delete(channelId);
|
|
3926
4845
|
await this.o.store.discardStaging(channelId);
|
|
@@ -3945,7 +4864,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3945
4864
|
* (required subset of supplied) is still satisfied even though it never
|
|
3946
4865
|
* goes through addMembers. */
|
|
3947
4866
|
async resetSessionSupersede(channelId, opts) {
|
|
3948
|
-
return this.locks.run(channelId, async () => {
|
|
4867
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4868
|
+
let resolved;
|
|
4869
|
+
let seen = "unreadable";
|
|
4870
|
+
try {
|
|
4871
|
+
resolved = await this.resolvePendingCommitUnlocked(channelId, PENDING_INLINE_RESENDS, (r) => {
|
|
4872
|
+
if (r)
|
|
4873
|
+
seen = r;
|
|
4874
|
+
});
|
|
4875
|
+
} catch (e) {
|
|
4876
|
+
if (opts.abandonPending !== true)
|
|
4877
|
+
throw e;
|
|
4878
|
+
resolved = "ambiguous";
|
|
4879
|
+
}
|
|
4880
|
+
if (resolved === "ambiguous") {
|
|
4881
|
+
if (opts.abandonPending !== true)
|
|
4882
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
4883
|
+
if (!await this.abandonPendingCommitUnlocked(channelId, seen))
|
|
4884
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
4885
|
+
}
|
|
4886
|
+
await this.drainOwedFollowUpUnlocked(channelId, opts.abandonPending === true);
|
|
3949
4887
|
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
3950
4888
|
if (!bundle) {
|
|
3951
4889
|
await this.publishKeyPackage();
|
|
@@ -3954,9 +4892,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3954
4892
|
if (!bundle)
|
|
3955
4893
|
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
3956
4894
|
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
3957
|
-
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
3958
|
-
const mgr = new MLSGroupManager2();
|
|
3959
|
-
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
3960
4895
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
3961
4896
|
const pkgs = others.map((d) => {
|
|
3962
4897
|
const hex = kps[d];
|
|
@@ -3964,19 +4899,62 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3964
4899
|
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
3965
4900
|
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
3966
4901
|
});
|
|
3967
|
-
const
|
|
3968
|
-
|
|
4902
|
+
const mainState0 = await this.o.store.load(channelId);
|
|
4903
|
+
const prior0 = mainState0 ? tagOfState(mainState0) : null;
|
|
4904
|
+
const reservation = pendingReservation({
|
|
4905
|
+
groupId: opts.newGroupId,
|
|
4906
|
+
deviceId: this.o.deviceId,
|
|
4907
|
+
prior: prior0,
|
|
4908
|
+
placeholder: mainState0 ?? RESERVATION_PLACEHOLDER_STATE,
|
|
4909
|
+
createdAt: this.nowMs()
|
|
4910
|
+
});
|
|
4911
|
+
if (!await this.o.store.savePendingCommit(channelId, reservation, { onlyIfAbsent: true })) {
|
|
4912
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
4913
|
+
}
|
|
4914
|
+
const reservationKey = pendingCommitKey(reservation);
|
|
4915
|
+
const mainState1 = await this.o.store.load(channelId);
|
|
4916
|
+
const prior1 = mainState1 ? tagOfState(mainState1) : null;
|
|
4917
|
+
let prior = prior0;
|
|
4918
|
+
if (!sameMainTag(prior0, prior1)) {
|
|
4919
|
+
const corrected = { ...reservation, prior: prior1, state: mainState1 ?? RESERVATION_PLACEHOLDER_STATE };
|
|
4920
|
+
if (!await this.o.store.savePendingCommit(channelId, corrected, { replacing: reservationKey })) {
|
|
4921
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey).catch(() => false);
|
|
4922
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
4923
|
+
}
|
|
4924
|
+
prior = prior1;
|
|
4925
|
+
}
|
|
4926
|
+
let reinit;
|
|
4927
|
+
try {
|
|
4928
|
+
reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
4929
|
+
} catch (e) {
|
|
4930
|
+
if (isDefiniteReinitRefusal(e))
|
|
4931
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey);
|
|
4932
|
+
throw e;
|
|
4933
|
+
}
|
|
4934
|
+
let mgr;
|
|
4935
|
+
let commit;
|
|
4936
|
+
let welcome;
|
|
4937
|
+
try {
|
|
4938
|
+
mgr = new MLSGroupManager2();
|
|
4939
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
4940
|
+
({ commit, welcome } = await mgr.addMembers(pkgs));
|
|
4941
|
+
} catch (e) {
|
|
4942
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey);
|
|
4943
|
+
throw e;
|
|
4944
|
+
}
|
|
4945
|
+
const staleMemberDeviceIds = reinit.stale_member_device_ids ?? [];
|
|
4946
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("supersede", reinit.group_id, {
|
|
3969
4947
|
device_id: this.o.deviceId,
|
|
3970
4948
|
commit: bytesToHex2(commit),
|
|
3971
4949
|
commit_epoch: Number(mgr.epoch),
|
|
3972
4950
|
expected_epoch: reinit.current_epoch,
|
|
3973
4951
|
member_device_ids: [this.o.deviceId, ...others],
|
|
3974
4952
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
3975
|
-
});
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
return { staleMemberDeviceIds
|
|
3979
|
-
});
|
|
4953
|
+
}, mgr, prior, { kind: "supersede", staleMemberDeviceIds }), mgr, reservationKey);
|
|
4954
|
+
if (!res.landed)
|
|
4955
|
+
throw res.error;
|
|
4956
|
+
return { staleMemberDeviceIds };
|
|
4957
|
+
}));
|
|
3980
4958
|
}
|
|
3981
4959
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
3982
4960
|
* Cheap synchronous check; does NOT consult the store. */
|
|
@@ -4041,7 +5019,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4041
5019
|
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(m.payload)));
|
|
4042
5020
|
if (envelope.channel_id !== channelId)
|
|
4043
5021
|
continue;
|
|
4044
|
-
const key = await this.locks.run(channelId, () => this.
|
|
5022
|
+
const key = await this.locks.run(channelId, () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
4045
5023
|
const aad = await buildAssociatedData({
|
|
4046
5024
|
message_id: envelope.message_id,
|
|
4047
5025
|
channel_id: envelope.channel_id,
|
|
@@ -4238,7 +5216,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4238
5216
|
} catch (err) {
|
|
4239
5217
|
this.recordDecryptError(row, err);
|
|
4240
5218
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4241
|
-
|
|
5219
|
+
writeServiceLog("debug", "supersede.welcome_open_failed", {}, err);
|
|
4242
5220
|
}
|
|
4243
5221
|
let requested = false;
|
|
4244
5222
|
try {
|
|
@@ -4256,6 +5234,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4256
5234
|
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
4257
5235
|
return refuse("supersede refused: post-join group id != attested new_group_id");
|
|
4258
5236
|
}
|
|
5237
|
+
let dead = null;
|
|
5238
|
+
try {
|
|
5239
|
+
dead = await this.o.store.loadPendingCommit(channelId);
|
|
5240
|
+
await this.resolvePendingCommitUnlocked(channelId, 0);
|
|
5241
|
+
} catch (e) {
|
|
5242
|
+
writeServiceLog("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
|
|
5243
|
+
}
|
|
5244
|
+
if (dead)
|
|
5245
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(dead));
|
|
5246
|
+
try {
|
|
5247
|
+
const after = await this.o.store.loadPendingCommit(channelId);
|
|
5248
|
+
if (after && after.groupId !== row.group_id) {
|
|
5249
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(after));
|
|
5250
|
+
}
|
|
5251
|
+
} catch (e) {
|
|
5252
|
+
writeServiceLog("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
|
|
5253
|
+
}
|
|
4259
5254
|
this.evictChannelState(channelId);
|
|
4260
5255
|
await this.o.store.delete(channelId);
|
|
4261
5256
|
await this.o.store.discardStaging(channelId);
|
|
@@ -4263,13 +5258,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4263
5258
|
this.errors.delete(channelId);
|
|
4264
5259
|
this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
|
|
4265
5260
|
this.managers.set(channelId, mgr);
|
|
4266
|
-
await this.
|
|
5261
|
+
await this.saveMain(channelId, mgr);
|
|
4267
5262
|
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
4268
5263
|
return true;
|
|
4269
5264
|
});
|
|
4270
5265
|
}
|
|
4271
5266
|
async receiveOnce(deviceId) {
|
|
5267
|
+
this.kickOwedRerun();
|
|
4272
5268
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
5269
|
+
if (deviceId === this.o.deviceId && this.custody.size && rows.length < DELIVERY_PULL_PAGE) {
|
|
5270
|
+
const pulledIds = new Set(rows.map((r) => r.queue_id));
|
|
5271
|
+
for (const qid of this.custody.keys()) {
|
|
5272
|
+
if (!pulledIds.has(qid))
|
|
5273
|
+
this.custody.delete(qid);
|
|
5274
|
+
}
|
|
5275
|
+
}
|
|
4273
5276
|
const appEntries = [];
|
|
4274
5277
|
const handshakeAcked = [];
|
|
4275
5278
|
const nackIds = [];
|
|
@@ -4278,7 +5281,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4278
5281
|
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
4279
5282
|
const ordered = [...welcomes, ...nonWelcomes];
|
|
4280
5283
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
|
|
4281
|
-
|
|
5284
|
+
writeServiceLog("debug", "delivery.rows_pulled", { count: ordered.length });
|
|
4282
5285
|
}
|
|
4283
5286
|
const classCtx = /* @__PURE__ */ new Map();
|
|
4284
5287
|
const classCtxFor = async (cid) => {
|
|
@@ -4305,10 +5308,58 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4305
5308
|
}
|
|
4306
5309
|
return ctx;
|
|
4307
5310
|
};
|
|
5311
|
+
const pendingAtPull = /* @__PURE__ */ new Map();
|
|
5312
|
+
const leftThisPull = /* @__PURE__ */ new Map();
|
|
5313
|
+
const pullNow = this.nowMs();
|
|
5314
|
+
const escalatedAtPull = (cid) => {
|
|
5315
|
+
const st = this.leftStreak.get(cid);
|
|
5316
|
+
return st !== void 0 && st.pulls >= PENDING_LEFT_ESCALATION_PULLS && pullNow - st.since >= PENDING_LEFT_ESCALATION_MS;
|
|
5317
|
+
};
|
|
5318
|
+
const hadPendingAtPull = async (cid) => {
|
|
5319
|
+
let v = pendingAtPull.get(cid);
|
|
5320
|
+
if (v === void 0) {
|
|
5321
|
+
try {
|
|
5322
|
+
v = await this.o.store.loadPendingCommit(cid) !== null;
|
|
5323
|
+
} catch {
|
|
5324
|
+
v = true;
|
|
5325
|
+
}
|
|
5326
|
+
pendingAtPull.set(cid, v);
|
|
5327
|
+
}
|
|
5328
|
+
return v;
|
|
5329
|
+
};
|
|
4308
5330
|
for (const row of ordered) {
|
|
4309
5331
|
try {
|
|
4310
5332
|
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
4311
5333
|
await classCtxFor(row.connect_channel_id);
|
|
5334
|
+
if (await hadPendingAtPull(row.connect_channel_id)) {
|
|
5335
|
+
const pcid = row.connect_channel_id;
|
|
5336
|
+
let verdict;
|
|
5337
|
+
try {
|
|
5338
|
+
verdict = await this.resolveRowAgainstPending(pcid, row, classCtx.get(pcid));
|
|
5339
|
+
} catch (e) {
|
|
5340
|
+
const now = this.nowMs();
|
|
5341
|
+
const last = this.receiveFailLoggedAt.get(pcid);
|
|
5342
|
+
if (last === void 0 || now - last >= PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS) {
|
|
5343
|
+
this.receiveFailLoggedAt.set(pcid, now);
|
|
5344
|
+
writeServiceLog("warn", "mls.pending_commit.receive_resolve_failed", {}, e);
|
|
5345
|
+
}
|
|
5346
|
+
verdict = { kind: "leave" };
|
|
5347
|
+
}
|
|
5348
|
+
if (verdict.kind === "leave") {
|
|
5349
|
+
leftThisPull.set(pcid, (leftThisPull.get(pcid) ?? 0) + 1);
|
|
5350
|
+
if (escalatedAtPull(pcid))
|
|
5351
|
+
throw new PendingRowEscalatedError();
|
|
5352
|
+
continue;
|
|
5353
|
+
}
|
|
5354
|
+
if (verdict.kind === "acked") {
|
|
5355
|
+
handshakeAcked.push(row.queue_id);
|
|
5356
|
+
continue;
|
|
5357
|
+
}
|
|
5358
|
+
if (verdict.kind === "entry") {
|
|
5359
|
+
appEntries.push(verdict.entry);
|
|
5360
|
+
continue;
|
|
5361
|
+
}
|
|
5362
|
+
}
|
|
4312
5363
|
}
|
|
4313
5364
|
if (row.message_type === "welcome") {
|
|
4314
5365
|
const wcid = row.connect_channel_id;
|
|
@@ -4323,14 +5374,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4323
5374
|
heldGroupId = null;
|
|
4324
5375
|
}
|
|
4325
5376
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4326
|
-
|
|
5377
|
+
if (heldGroupId === null)
|
|
5378
|
+
writeServiceLog("debug", "delivery.welcome_manager_unloadable");
|
|
5379
|
+
else if (heldGroupId === row.group_id)
|
|
5380
|
+
writeServiceLog("debug", "delivery.welcome_stray");
|
|
5381
|
+
else
|
|
5382
|
+
writeServiceLog("debug", "delivery.welcome_supersede_candidate");
|
|
4327
5383
|
}
|
|
4328
5384
|
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
4329
5385
|
handshakeAcked.push(row.queue_id);
|
|
4330
5386
|
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
4331
5387
|
if (this.supersedeBackoffActive(wcid, row.group_id)) {
|
|
4332
5388
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4333
|
-
|
|
5389
|
+
writeServiceLog("debug", "delivery.welcome_refusal_backoff");
|
|
4334
5390
|
}
|
|
4335
5391
|
} else {
|
|
4336
5392
|
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
@@ -4339,7 +5395,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4339
5395
|
}
|
|
4340
5396
|
}
|
|
4341
5397
|
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4342
|
-
|
|
5398
|
+
writeServiceLog("debug", "delivery.welcome_no_local_state");
|
|
4343
5399
|
}
|
|
4344
5400
|
}
|
|
4345
5401
|
} else if (row.message_type === "commit") {
|
|
@@ -4350,7 +5406,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4350
5406
|
await this.locks.run(cid, async () => {
|
|
4351
5407
|
const mgr = await this.manager(cid);
|
|
4352
5408
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
4353
|
-
await this.
|
|
5409
|
+
await this.saveAdvancedOrEvict(cid, mgr);
|
|
4354
5410
|
advancedEpoch = Number(mgr.epoch);
|
|
4355
5411
|
});
|
|
4356
5412
|
this.recordDecryptSuccess(cid);
|
|
@@ -4361,36 +5417,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4361
5417
|
}
|
|
4362
5418
|
handshakeAcked.push(row.queue_id);
|
|
4363
5419
|
} else {
|
|
4364
|
-
const
|
|
4365
|
-
|
|
4366
|
-
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
4367
|
-
}
|
|
4368
|
-
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
4369
|
-
const aad = await buildAssociatedData({
|
|
4370
|
-
message_id: envelope.message_id,
|
|
4371
|
-
channel_id: envelope.channel_id,
|
|
4372
|
-
sender_id: envelope.sender_id,
|
|
4373
|
-
mls_group_epoch: envelope.mls_group_epoch,
|
|
4374
|
-
replay_nonce: envelope.replay_nonce,
|
|
4375
|
-
policy_binding: envelope.policy_binding
|
|
4376
|
-
});
|
|
4377
|
-
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
4378
|
-
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
4379
|
-
this.recordDecryptSuccess(appChannelId);
|
|
4380
|
-
appEntries.push({
|
|
4381
|
-
queueId: row.queue_id,
|
|
4382
|
-
messageId: envelope.message_id,
|
|
4383
|
-
channelId: appChannelId,
|
|
4384
|
-
plaintext,
|
|
4385
|
-
envelope,
|
|
4386
|
-
receivedAt: Date.now(),
|
|
4387
|
-
retryCount: 0,
|
|
4388
|
-
expires_at: row.expires_at,
|
|
4389
|
-
voiceMode: row.voice_mode ?? void 0,
|
|
4390
|
-
voiceDefaultResponder: row.voice_default_responder ?? void 0
|
|
4391
|
-
});
|
|
5420
|
+
const held = this.custody.get(row.queue_id);
|
|
5421
|
+
appEntries.push(held ? { ...held } : await this.decryptApplicationRow(row, { lockHeld: false }));
|
|
4392
5422
|
}
|
|
4393
5423
|
} catch (err) {
|
|
5424
|
+
const heldAfterWait = row.message_type === "application" ? this.custody.get(row.queue_id) : void 0;
|
|
5425
|
+
if (heldAfterWait) {
|
|
5426
|
+
appEntries.push({ ...heldAfterWait });
|
|
5427
|
+
continue;
|
|
5428
|
+
}
|
|
5429
|
+
if (err instanceof PendingCommitUnresolvedError) {
|
|
5430
|
+
continue;
|
|
5431
|
+
}
|
|
4394
5432
|
this.recordDecryptError(row, err);
|
|
4395
5433
|
const cid = row.connect_channel_id;
|
|
4396
5434
|
let drained = false;
|
|
@@ -4435,6 +5473,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4435
5473
|
}
|
|
4436
5474
|
}
|
|
4437
5475
|
}
|
|
5476
|
+
for (const [cid, had] of pendingAtPull) {
|
|
5477
|
+
if (!had)
|
|
5478
|
+
continue;
|
|
5479
|
+
if ((leftThisPull.get(cid) ?? 0) === 0) {
|
|
5480
|
+
this.leftStreak.delete(cid);
|
|
5481
|
+
continue;
|
|
5482
|
+
}
|
|
5483
|
+
const st = this.leftStreak.get(cid);
|
|
5484
|
+
if (st)
|
|
5485
|
+
st.pulls += 1;
|
|
5486
|
+
else
|
|
5487
|
+
this.leftStreak.set(cid, { pulls: 1, since: pullNow });
|
|
5488
|
+
this.scheduleAutoResolve(cid);
|
|
5489
|
+
}
|
|
4438
5490
|
if (handshakeAcked.length)
|
|
4439
5491
|
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
4440
5492
|
if (nackIds.length) {
|
|
@@ -4448,6 +5500,195 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4448
5500
|
}
|
|
4449
5501
|
return appEntries;
|
|
4450
5502
|
}
|
|
5503
|
+
/**
|
|
5504
|
+
* C2 (spec §4.4 "Receive resolves the record from the queue"; plan 3.8):
|
|
5505
|
+
* decide one delivery row of a channel that holds a pending record. Runs
|
|
5506
|
+
* under the channel lock and RE-READS the record inside it (a row earlier in
|
|
5507
|
+
* the same pull may have resolved it).
|
|
5508
|
+
*
|
|
5509
|
+
* Device-wide pulls arrive in `seq` order, and C1c puts every epoch-N row
|
|
5510
|
+
* before the commit that moves to N+1, so the first row for this channel past
|
|
5511
|
+
* epoch N is decisive:
|
|
5512
|
+
* 1. A row of the record's group at or below `prior.epoch` (an epoch-N app
|
|
5513
|
+
* row, an old duplicate) → `normal`: decrypt at MAIN and ack as usual.
|
|
5514
|
+
* Nothing at or below N can move MAIN.
|
|
5515
|
+
* 2. A COMMIT at `commitEpoch` in the record's group is someone else's (the
|
|
5516
|
+
* server never fans a commit back to its sender). If it applies at MAIN,
|
|
5517
|
+
* that proves ours was lost: clear the record, THEN save MAIN (a crash
|
|
5518
|
+
* between them redelivers the commit), hook `not_landed`.
|
|
5519
|
+
* 3. A row framed at `commitEpoch` (an app row at `commitEpoch`, or a commit
|
|
5520
|
+
* at `commitEpoch + 1`): trial it on a throwaway manager imported from
|
|
5521
|
+
* `rec.state`. If it applies, the group is provably on OUR epoch (a
|
|
5522
|
+
* cryptographic proof, stronger than a 2xx): promote with the UNLOCKED
|
|
5523
|
+
* promotion inside this held lock (LOW-4: the queue is not reentrant),
|
|
5524
|
+
* then process the row normally.
|
|
5525
|
+
* 4. Anything else of the record's group (fits neither state, or is past
|
|
5526
|
+
* both) → `leave`: un-acked, no nack, no error count, no dead-letter.
|
|
5527
|
+
*
|
|
5528
|
+
* LOW-5: cases 2 and 3 key on `row.group_id === rec.groupId`. A supersede
|
|
5529
|
+
* record targets the NEW group and a bootstrap record has no MAIN, so no row
|
|
5530
|
+
* of ANOTHER group ever resolves either; case 2 needs MAIN in the record's
|
|
5531
|
+
* group, so it applies to add and remove records only, and a supersede or
|
|
5532
|
+
* bootstrap record resolves by case 3 or by resend. Rows of another group
|
|
5533
|
+
* (the supersede's old group, a retired group) take the `normal` paths:
|
|
5534
|
+
* `saveMainGuarded` keeps any of them from moving MAIN past the record.
|
|
5535
|
+
*
|
|
5536
|
+
* Throws propagate to the caller, which leaves the row un-acked and
|
|
5537
|
+
* uncounted (a failed promotion included).
|
|
5538
|
+
*/
|
|
5539
|
+
async resolveRowAgainstPending(channelId, row, ctx) {
|
|
5540
|
+
return this.locks.run(channelId, async () => {
|
|
5541
|
+
let rec;
|
|
5542
|
+
try {
|
|
5543
|
+
rec = await this.o.store.loadPendingCommit(channelId);
|
|
5544
|
+
} catch (e) {
|
|
5545
|
+
if (row.message_type === "application")
|
|
5546
|
+
return { kind: "normal" };
|
|
5547
|
+
throw e;
|
|
5548
|
+
}
|
|
5549
|
+
if (!rec) {
|
|
5550
|
+
return { kind: "normal" };
|
|
5551
|
+
}
|
|
5552
|
+
if (row.group_id !== rec.groupId)
|
|
5553
|
+
return { kind: "normal" };
|
|
5554
|
+
if (isPendingReservation(rec))
|
|
5555
|
+
return { kind: "leave" };
|
|
5556
|
+
const rowEpoch = typeof row.epoch === "number" ? row.epoch : void 0;
|
|
5557
|
+
if (rowEpoch === void 0)
|
|
5558
|
+
return { kind: "leave" };
|
|
5559
|
+
const mainInRecGroup = rec.prior !== null && rec.prior.groupId === rec.groupId;
|
|
5560
|
+
if (row.message_type === "commit" && rowEpoch === rec.commitEpoch) {
|
|
5561
|
+
if (!mainInRecGroup)
|
|
5562
|
+
return { kind: "leave" };
|
|
5563
|
+
const mgr = await this.manager(channelId);
|
|
5564
|
+
const at = tagOf(mgr);
|
|
5565
|
+
if (at.groupId !== rec.prior.groupId || at.epoch !== rec.prior.epoch)
|
|
5566
|
+
return { kind: "leave" };
|
|
5567
|
+
try {
|
|
5568
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
5569
|
+
} catch {
|
|
5570
|
+
return { kind: "leave" };
|
|
5571
|
+
}
|
|
5572
|
+
if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec))) {
|
|
5573
|
+
this.managers.delete(channelId);
|
|
5574
|
+
return { kind: "leave" };
|
|
5575
|
+
}
|
|
5576
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
5577
|
+
this.recordDecryptSuccess(channelId);
|
|
5578
|
+
if (ctx)
|
|
5579
|
+
ctx.liveCommitEpoch = Number(mgr.epoch);
|
|
5580
|
+
await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via: "resolver" });
|
|
5581
|
+
return { kind: "acked" };
|
|
5582
|
+
}
|
|
5583
|
+
const framedAtOurs = row.message_type === "application" && rowEpoch === rec.commitEpoch || row.message_type === "commit" && rowEpoch === rec.commitEpoch + 1;
|
|
5584
|
+
if (framedAtOurs) {
|
|
5585
|
+
const trial = new MLSGroupManager2(rec.state.ciphersuite);
|
|
5586
|
+
trial.importState(rec.state);
|
|
5587
|
+
let applies;
|
|
5588
|
+
if (row.message_type === "application") {
|
|
5589
|
+
applies = await this.trialOpenApplication(row, trial);
|
|
5590
|
+
} else {
|
|
5591
|
+
try {
|
|
5592
|
+
await trial.decrypt(hexToBytes(row.payload));
|
|
5593
|
+
applies = true;
|
|
5594
|
+
} catch {
|
|
5595
|
+
applies = false;
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
if (!applies)
|
|
5599
|
+
return { kind: "leave" };
|
|
5600
|
+
const p = await this.promotePendingUnlocked(channelId, "resolver", rec);
|
|
5601
|
+
if (p !== "landed")
|
|
5602
|
+
return { kind: "normal" };
|
|
5603
|
+
if (row.message_type === "application") {
|
|
5604
|
+
return { kind: "entry", entry: await this.decryptApplicationRow(row, { lockHeld: true }) };
|
|
5605
|
+
}
|
|
5606
|
+
const mgr = await this.manager(channelId);
|
|
5607
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
5608
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
5609
|
+
this.recordDecryptSuccess(channelId);
|
|
5610
|
+
if (ctx)
|
|
5611
|
+
ctx.liveCommitEpoch = Number(mgr.epoch);
|
|
5612
|
+
return { kind: "acked" };
|
|
5613
|
+
}
|
|
5614
|
+
if (mainInRecGroup && rowEpoch <= rec.prior.epoch)
|
|
5615
|
+
return { kind: "normal" };
|
|
5616
|
+
return { kind: "leave" };
|
|
5617
|
+
});
|
|
5618
|
+
}
|
|
5619
|
+
/** C2 case 3: would this application row open under `mgr`'s exporter? A
|
|
5620
|
+
* pure trial: no custody, no success record, no ack. The SDK-5 binding
|
|
5621
|
+
* (envelope channel == server-attributed channel) is checked first. */
|
|
5622
|
+
async trialOpenApplication(row, mgr) {
|
|
5623
|
+
try {
|
|
5624
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
5625
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id)
|
|
5626
|
+
return false;
|
|
5627
|
+
const key = mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
|
|
5628
|
+
const aad = await buildAssociatedData({
|
|
5629
|
+
message_id: envelope.message_id,
|
|
5630
|
+
channel_id: envelope.channel_id,
|
|
5631
|
+
sender_id: envelope.sender_id,
|
|
5632
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
5633
|
+
replay_nonce: envelope.replay_nonce,
|
|
5634
|
+
policy_binding: envelope.policy_binding
|
|
5635
|
+
});
|
|
5636
|
+
openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key: await key, associatedData: aad, suite: this.suite });
|
|
5637
|
+
return true;
|
|
5638
|
+
} catch {
|
|
5639
|
+
return false;
|
|
5640
|
+
}
|
|
5641
|
+
}
|
|
5642
|
+
/**
|
|
5643
|
+
* Decrypt one application delivery row into an {@link InboxEntry} and take
|
|
5644
|
+
* {@link custody} of it. Throws on any parse / binding / AEAD failure; the
|
|
5645
|
+
* caller decides what a failure means.
|
|
5646
|
+
*
|
|
5647
|
+
* `lockHeld`: the committer 409 drain and the own-commit drain run INSIDE the
|
|
5648
|
+
* channel lock already (the queue is not reentrant), so they derive the key
|
|
5649
|
+
* without re-taking it. The SDK-5 binding check below guarantees
|
|
5650
|
+
* envelope.channel_id equals the row's server-attributed channel, which is
|
|
5651
|
+
* the channel whose lock is held.
|
|
5652
|
+
*
|
|
5653
|
+
* `keyFrom`: derive the key from THIS manager instead of the cached live one.
|
|
5654
|
+
* Only {@link custodyAppRowsBeforeOwnCommit} passes it: a transient manager
|
|
5655
|
+
* rebuilt from the store's committed (pre-commit, epoch N) state while the
|
|
5656
|
+
* cached manager is already advanced to N+1.
|
|
5657
|
+
*/
|
|
5658
|
+
async decryptApplicationRow(row, opts) {
|
|
5659
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
5660
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
|
|
5661
|
+
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
5662
|
+
}
|
|
5663
|
+
const keyFrom = opts.keyFrom;
|
|
5664
|
+
const derive = keyFrom ? () => keyFrom.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32) : () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch);
|
|
5665
|
+
const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
|
|
5666
|
+
const aad = await buildAssociatedData({
|
|
5667
|
+
message_id: envelope.message_id,
|
|
5668
|
+
channel_id: envelope.channel_id,
|
|
5669
|
+
sender_id: envelope.sender_id,
|
|
5670
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
5671
|
+
replay_nonce: envelope.replay_nonce,
|
|
5672
|
+
policy_binding: envelope.policy_binding
|
|
5673
|
+
});
|
|
5674
|
+
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
5675
|
+
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
5676
|
+
this.recordDecryptSuccess(appChannelId);
|
|
5677
|
+
const entry = {
|
|
5678
|
+
queueId: row.queue_id,
|
|
5679
|
+
messageId: envelope.message_id,
|
|
5680
|
+
channelId: appChannelId,
|
|
5681
|
+
plaintext,
|
|
5682
|
+
envelope,
|
|
5683
|
+
receivedAt: Date.now(),
|
|
5684
|
+
retryCount: 0,
|
|
5685
|
+
expires_at: row.expires_at,
|
|
5686
|
+
voiceMode: row.voice_mode ?? void 0,
|
|
5687
|
+
voiceDefaultResponder: row.voice_default_responder ?? void 0
|
|
5688
|
+
};
|
|
5689
|
+
this.custody.set(row.queue_id, entry);
|
|
5690
|
+
return entry;
|
|
5691
|
+
}
|
|
4451
5692
|
/**
|
|
4452
5693
|
* Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
|
|
4453
5694
|
*
|
|
@@ -4457,22 +5698,39 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4457
5698
|
* (which would re-acquire the same channel lock for that channel's commit rows
|
|
4458
5699
|
* and self-deadlock — the queue is not reentrant). This helper does the commit
|
|
4459
5700
|
* half of receiveOnce for ONLY the committer's own channel, without taking the
|
|
4460
|
-
* lock (the caller already holds it).
|
|
4461
|
-
*
|
|
4462
|
-
*
|
|
5701
|
+
* lock (the caller already holds it). Welcome rows are left for the join path,
|
|
5702
|
+
* exactly as receiveOnce leaves them. Rows of OTHER channels are untouched:
|
|
5703
|
+
* this drain never moves their epoch, so they stay decryptable.
|
|
5704
|
+
*
|
|
5705
|
+
* C1: application rows of THIS channel are decrypted in seq order, before
|
|
5706
|
+
* any commit behind them applies, which puts them in {@link custody} (never
|
|
5707
|
+
* acked here); the next receiveOnce serves them. Walking past them, as this
|
|
5708
|
+
* drain used to, then applying the commit behind them destroyed their key:
|
|
5709
|
+
* the redelivered row failed AEAD and was acked as permanentlySkipped. A row
|
|
5710
|
+
* that fails to decrypt here is left exactly as before (un-acked, not
|
|
5711
|
+
* counted), for receiveOnce to classify.
|
|
4463
5712
|
*/
|
|
4464
5713
|
async drainChannelCommitsUnlocked(deviceId, channelId) {
|
|
4465
5714
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
4466
5715
|
const acked = [];
|
|
4467
5716
|
for (const row of rows) {
|
|
4468
|
-
if (row.message_type !== "commit")
|
|
4469
|
-
continue;
|
|
4470
5717
|
if (row.connect_channel_id !== channelId)
|
|
4471
5718
|
continue;
|
|
5719
|
+
if (row.message_type === "application") {
|
|
5720
|
+
if (this.custody.has(row.queue_id))
|
|
5721
|
+
continue;
|
|
5722
|
+
try {
|
|
5723
|
+
await this.decryptApplicationRow(row, { lockHeld: true });
|
|
5724
|
+
} catch {
|
|
5725
|
+
}
|
|
5726
|
+
continue;
|
|
5727
|
+
}
|
|
5728
|
+
if (row.message_type !== "commit")
|
|
5729
|
+
continue;
|
|
4472
5730
|
try {
|
|
4473
5731
|
const mgr = await this.manager(channelId);
|
|
4474
5732
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
4475
|
-
await this.
|
|
5733
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
4476
5734
|
this.recordDecryptSuccess(channelId);
|
|
4477
5735
|
acked.push(row.queue_id);
|
|
4478
5736
|
} catch (err) {
|
|
@@ -4482,6 +5740,60 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4482
5740
|
if (acked.length)
|
|
4483
5741
|
await this.o.backend.ackDelivery(deviceId, acked);
|
|
4484
5742
|
}
|
|
5743
|
+
/**
|
|
5744
|
+
* C1b: take custody of this channel's still-queued application rows at the
|
|
5745
|
+
* COMMITTED epoch, before a committer's OWN commit is persisted locally.
|
|
5746
|
+
*
|
|
5747
|
+
* The message key comes from the live exporter secret only. A committer that
|
|
5748
|
+
* persists its own commit N->N+1 while a peer's epoch-N application row sits
|
|
5749
|
+
* un-pulled in its queue can never decrypt that row again: its next
|
|
5750
|
+
* receiveOnce fails AEAD, classifies it permanentlySkipped and acks it.
|
|
5751
|
+
*
|
|
5752
|
+
* ORDERING (the committer-safety rule, B11): called only AFTER distribute has
|
|
5753
|
+
* succeeded and BEFORE the local save/promote, still inside the channel lock.
|
|
5754
|
+
* - After distribute: the server's group epoch is N+1, and the send path
|
|
5755
|
+
* rejects any application message claiming epoch < N+1
|
|
5756
|
+
* (validate_application_epoch, connect_messages.py step 6). So the set of
|
|
5757
|
+
* epoch-N rows is closed; one pull sees all of them (residual: a send whose
|
|
5758
|
+
* request read epoch N before our commit landed but commits after this pull;
|
|
5759
|
+
* see the C1 report).
|
|
5760
|
+
* - It is NOT a receiveOnce and applies NO commit: it only reads the queue
|
|
5761
|
+
* and derives exporter keys. The in-flight commit is already accepted, so
|
|
5762
|
+
* nothing here can race it or move the tree between resolve and
|
|
5763
|
+
* distribute; the cached (advanced) manager is never touched.
|
|
5764
|
+
* - The key comes from a TRANSIENT manager rebuilt from the store's MAIN
|
|
5765
|
+
* slot, which still holds the pre-commit epoch-N state (add: saved only
|
|
5766
|
+
* after this; removes: load() never returns staging). It is dropped when
|
|
5767
|
+
* this returns; no epoch-N secret is retained beyond the state that is
|
|
5768
|
+
* already at rest until the save/promote below.
|
|
5769
|
+
*
|
|
5770
|
+
* Best effort: a failed pull is retried once, then the commit is persisted
|
|
5771
|
+
* anyway (not persisting an accepted commit would diverge this device). Rows
|
|
5772
|
+
* that do not decrypt at N are left untouched for receiveOnce.
|
|
5773
|
+
*/
|
|
5774
|
+
async custodyAppRowsBeforeOwnCommit(channelId, committedEpoch) {
|
|
5775
|
+
try {
|
|
5776
|
+
const saved = await this.o.store.load(channelId);
|
|
5777
|
+
if (!saved)
|
|
5778
|
+
return;
|
|
5779
|
+
const atCommitted = new MLSGroupManager2(saved.ciphersuite);
|
|
5780
|
+
atCommitted.importState(saved);
|
|
5781
|
+
if (Number(atCommitted.epoch) !== committedEpoch)
|
|
5782
|
+
return;
|
|
5783
|
+
let rows;
|
|
5784
|
+
for (let attempt = 0; attempt < 2 && !rows; attempt++) {
|
|
5785
|
+
rows = await this.o.backend.pullDelivery(this.o.deviceId).catch(() => void 0);
|
|
5786
|
+
}
|
|
5787
|
+
for (const row of rows ?? []) {
|
|
5788
|
+
if (row.message_type !== "application" || row.connect_channel_id !== channelId)
|
|
5789
|
+
continue;
|
|
5790
|
+
if (this.custody.has(row.queue_id))
|
|
5791
|
+
continue;
|
|
5792
|
+
await this.decryptApplicationRow(row, { lockHeld: true, keyFrom: atCommitted }).catch(() => void 0);
|
|
5793
|
+
}
|
|
5794
|
+
} catch {
|
|
5795
|
+
}
|
|
5796
|
+
}
|
|
4485
5797
|
/** Delegate so the receive pump can ack without reaching the private backend. */
|
|
4486
5798
|
async ackDelivery(deviceId, queueIds) {
|
|
4487
5799
|
await this.o.backend.ackDelivery(deviceId, queueIds);
|
|
@@ -5005,6 +6317,12 @@ function describeType(v) {
|
|
|
5005
6317
|
return "array";
|
|
5006
6318
|
return typeof v;
|
|
5007
6319
|
}
|
|
6320
|
+
function isAbsence404(err) {
|
|
6321
|
+
return err instanceof MlsBackendHttpError && err.status === 404;
|
|
6322
|
+
}
|
|
6323
|
+
function failureFields(err) {
|
|
6324
|
+
return err instanceof MlsBackendHttpError ? { status: err.status } : {};
|
|
6325
|
+
}
|
|
5008
6326
|
function parseGroupRoster(endpoint, channelId, value) {
|
|
5009
6327
|
if (!Array.isArray(value)) {
|
|
5010
6328
|
throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
|
|
@@ -5040,7 +6358,7 @@ var RestMlsBackend = class {
|
|
|
5040
6358
|
async get(path) {
|
|
5041
6359
|
const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
|
|
5042
6360
|
if (!res.ok)
|
|
5043
|
-
throw new
|
|
6361
|
+
throw new MlsBackendHttpError(path, res.status);
|
|
5044
6362
|
return await res.json();
|
|
5045
6363
|
}
|
|
5046
6364
|
async getRegisterChallenge() {
|
|
@@ -5095,25 +6413,68 @@ var RestMlsBackend = class {
|
|
|
5095
6413
|
member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
|
|
5096
6414
|
// Back-compat: servers that predate the created field return nothing;
|
|
5097
6415
|
// treat absence as true (they always created a fresh group).
|
|
5098
|
-
created: j.created === void 0 ? true : Boolean(j.created)
|
|
6416
|
+
created: j.created === void 0 ? true : Boolean(j.created),
|
|
6417
|
+
// C2 T3b: passed through ONLY as a real boolean. Absent (a backend that
|
|
6418
|
+
// predates the field) or any other type stays undefined, which the
|
|
6419
|
+
// provider reads as ATTESTED (fail closed). Never coerced: `Boolean("false")`
|
|
6420
|
+
// would read a malformed value as attested-true, and a default of `false`
|
|
6421
|
+
// would read a missing one as adoptable.
|
|
6422
|
+
...typeof j.reset_attested === "boolean" ? { reset_attested: j.reset_attested } : {}
|
|
5099
6423
|
};
|
|
5100
6424
|
}
|
|
5101
6425
|
async reinitConnectChannelGroup() {
|
|
5102
6426
|
throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
|
|
5103
6427
|
}
|
|
6428
|
+
/**
|
|
6429
|
+
* C2 T3e (item 1): does its own fetch, mirroring {@link distribute}, so a
|
|
6430
|
+
* non-2xx becomes a typed {@link MlsBackendHttpError} instead of a plain
|
|
6431
|
+
* `Error` whose status lives only in a `failed: <status>` message. Before
|
|
6432
|
+
* this, `resetSessionSupersede`'s `isDefiniteReinitRefusal` classified the
|
|
6433
|
+
* outcome by regex-parsing that text, which is safe only as long as
|
|
6434
|
+
* neither `post()` nor the web's `makeAuthedFetch` ever retries this call —
|
|
6435
|
+
* a retry would replay the request and the classifier has no way to know
|
|
6436
|
+
* that happened. Typing the error lets the classifier read `instanceof
|
|
6437
|
+
* MlsBackendHttpError` + `.status` directly, matching every other C2
|
|
6438
|
+
* definite-refusal site (`isSupersededRefusal`, `isEpochConflict`). The
|
|
6439
|
+
* message is `<path> failed: <status>` — no body text (ids stay out of
|
|
6440
|
+
* logs) — which is SHORTER than what `post()` used to throw (that included
|
|
6441
|
+
* the raw response body). PR #681 review: the web's `friendlyResetError`
|
|
6442
|
+
* used to substring-match `surviving_device_exists` /
|
|
6443
|
+
* `reset_member_set_incomplete` / `bad_reset_attestation` out of that body
|
|
6444
|
+
* text; it now reads them off this error's typed `.code` field instead
|
|
6445
|
+
* (with the old substring match kept as a fallback for other error shapes).
|
|
6446
|
+
*/
|
|
5104
6447
|
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
5105
6448
|
const committer = memberDeviceIds[0];
|
|
5106
6449
|
if (!committer)
|
|
5107
6450
|
throw new Error("resetReinitConnectChannelGroup: empty member set");
|
|
5108
|
-
const
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
6451
|
+
const path = `/api/v1/mls/connect-channels/${channelId}/reinit`;
|
|
6452
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
6453
|
+
method: "POST",
|
|
6454
|
+
headers: this.headers(),
|
|
6455
|
+
body: JSON.stringify({
|
|
6456
|
+
member_device_ids: memberDeviceIds,
|
|
6457
|
+
committer_device_id: committer,
|
|
6458
|
+
mode: "reset",
|
|
6459
|
+
new_group_id: att.newGroupId,
|
|
6460
|
+
reset_attestation_signature: att.signatureHex,
|
|
6461
|
+
reset_attestation_nonce: att.nonceHex,
|
|
6462
|
+
reset_attestation_content: att.signedContentHex
|
|
6463
|
+
})
|
|
5116
6464
|
});
|
|
6465
|
+
if (!res.ok) {
|
|
6466
|
+
let code;
|
|
6467
|
+
let reason;
|
|
6468
|
+
try {
|
|
6469
|
+
const body = JSON.parse(await res.text());
|
|
6470
|
+
code = parseErrorCode(body);
|
|
6471
|
+
const r = body !== null && typeof body === "object" ? body.reason : void 0;
|
|
6472
|
+
reason = typeof r === "string" ? r : void 0;
|
|
6473
|
+
} catch {
|
|
6474
|
+
}
|
|
6475
|
+
throw new MlsBackendHttpError(path, res.status, code, reason);
|
|
6476
|
+
}
|
|
6477
|
+
const j = res.status === 204 ? {} : await res.json();
|
|
5117
6478
|
return {
|
|
5118
6479
|
group_id: String(j.group_id),
|
|
5119
6480
|
current_epoch: Number(j.current_epoch),
|
|
@@ -5136,7 +6497,10 @@ var RestMlsBackend = class {
|
|
|
5136
6497
|
try {
|
|
5137
6498
|
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
|
|
5138
6499
|
return String(j.key_package);
|
|
5139
|
-
} catch {
|
|
6500
|
+
} catch (err) {
|
|
6501
|
+
if (!isAbsence404(err)) {
|
|
6502
|
+
writeServiceLog("warn", "keypackage.by_id_fetch_failed", failureFields(err), err);
|
|
6503
|
+
}
|
|
5140
6504
|
return null;
|
|
5141
6505
|
}
|
|
5142
6506
|
}
|
|
@@ -5152,15 +6516,44 @@ var RestMlsBackend = class {
|
|
|
5152
6516
|
if (Number.isNaN(ms))
|
|
5153
6517
|
return null;
|
|
5154
6518
|
return new Date(ms);
|
|
5155
|
-
} catch {
|
|
6519
|
+
} catch (err) {
|
|
6520
|
+
if (!isAbsence404(err)) {
|
|
6521
|
+
writeServiceLog("warn", "keypackage.expiry_fetch_failed", failureFields(err), err);
|
|
6522
|
+
}
|
|
5156
6523
|
return null;
|
|
5157
6524
|
}
|
|
5158
6525
|
}
|
|
6526
|
+
/**
|
|
6527
|
+
* C2 T3 (spec §4.3): `/distribute` does its own fetch so a non-2xx becomes a
|
|
6528
|
+
* typed {@link MlsBackendHttpError} carrying the body's error `code` (and a
|
|
6529
|
+
* 409's `reason`), which the pending-commit outcome classifier reads. `post()`
|
|
6530
|
+
* is left alone: its other callers keep their current plain-`Error` shape.
|
|
6531
|
+
*
|
|
6532
|
+
* The message is `<path> failed: <status>` with NO body text (ids stay out of
|
|
6533
|
+
* logs; `friendlyResetError` matches the status substring). A network failure
|
|
6534
|
+
* or abort propagates as the fetch threw it, which the classifier reads as
|
|
6535
|
+
* ambiguous. A 2xx resolves whatever its body is (`replayed: true` included):
|
|
6536
|
+
* 2xx means landed, and the SDK never reads the success body.
|
|
6537
|
+
*/
|
|
5159
6538
|
async distribute(groupId, req) {
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
6539
|
+
const path = `/api/v1/mls/groups/${groupId}/distribute`;
|
|
6540
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
6541
|
+
method: "POST",
|
|
6542
|
+
headers: this.headers(),
|
|
6543
|
+
body: JSON.stringify({ ...req, group_id: groupId })
|
|
5163
6544
|
});
|
|
6545
|
+
if (res.ok)
|
|
6546
|
+
return;
|
|
6547
|
+
let code;
|
|
6548
|
+
let reason;
|
|
6549
|
+
try {
|
|
6550
|
+
const body = JSON.parse(await res.text());
|
|
6551
|
+
code = parseErrorCode(body);
|
|
6552
|
+
const r = body !== null && typeof body === "object" ? body.reason : void 0;
|
|
6553
|
+
reason = typeof r === "string" ? r : void 0;
|
|
6554
|
+
} catch {
|
|
6555
|
+
}
|
|
6556
|
+
throw new MlsBackendHttpError(path, res.status, code, reason);
|
|
5164
6557
|
}
|
|
5165
6558
|
async pullDelivery(deviceId) {
|
|
5166
6559
|
const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
|
|
@@ -5212,7 +6605,8 @@ var RestMlsBackend = class {
|
|
|
5212
6605
|
return String(j.token);
|
|
5213
6606
|
}
|
|
5214
6607
|
async releaseCommitLease(channelId, token) {
|
|
5215
|
-
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(() => {
|
|
6608
|
+
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((err) => {
|
|
6609
|
+
writeServiceLog("warn", "committer.lease_release_failed", {}, err);
|
|
5216
6610
|
});
|
|
5217
6611
|
}
|
|
5218
6612
|
async fetchResetAttestation(groupId) {
|
|
@@ -5223,7 +6617,10 @@ var RestMlsBackend = class {
|
|
|
5223
6617
|
signatureHex: String(j.signature_hex),
|
|
5224
6618
|
signerIdentityId: String(j.signer_identity_id)
|
|
5225
6619
|
};
|
|
5226
|
-
} catch {
|
|
6620
|
+
} catch (err) {
|
|
6621
|
+
if (!isAbsence404(err)) {
|
|
6622
|
+
writeServiceLog("warn", "supersede.attestation_fetch_failed", failureFields(err), err);
|
|
6623
|
+
}
|
|
5227
6624
|
return null;
|
|
5228
6625
|
}
|
|
5229
6626
|
}
|
|
@@ -5231,6 +6628,7 @@ var RestMlsBackend = class {
|
|
|
5231
6628
|
|
|
5232
6629
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
5233
6630
|
import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
6631
|
+
var DELIVERY_PULL_LIMIT = 100;
|
|
5234
6632
|
var FakeConnectBackend = class _FakeConnectBackend {
|
|
5235
6633
|
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
5236
6634
|
kps = /* @__PURE__ */ new Map();
|
|
@@ -5361,7 +6759,10 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5361
6759
|
// Real semantics: created=true on first call (group was just created),
|
|
5362
6760
|
// created=false on idempotent return (group already existed). Without
|
|
5363
6761
|
// this, race tests silently pass wrong (fake is laxer than the server).
|
|
5364
|
-
created: !alreadyExists
|
|
6762
|
+
created: !alreadyExists,
|
|
6763
|
+
// C2 T3b: mirrors T1b's `reset_attested` (the group carries an owner
|
|
6764
|
+
// reset attestation). Always present, as T1b's field has no default.
|
|
6765
|
+
reset_attested: this.resetAttestations.has(groupId)
|
|
5365
6766
|
};
|
|
5366
6767
|
}
|
|
5367
6768
|
async reinitConnectChannelGroup(channelId, memberDeviceIds) {
|
|
@@ -5429,16 +6830,58 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5429
6830
|
}))
|
|
5430
6831
|
};
|
|
5431
6832
|
}
|
|
6833
|
+
/** C2 T3: every `/distribute` request, in order, including resends and
|
|
6834
|
+
* injected failures, so a test can assert exactly what was sent. */
|
|
6835
|
+
distributeCalls = [];
|
|
6836
|
+
/** C2 T3: one fault per upcoming `distribute` call, FIFO. See
|
|
6837
|
+
* {@link failNextDistribute}. */
|
|
6838
|
+
distributeFaults = [];
|
|
6839
|
+
/**
|
|
6840
|
+
* C2 T3 test seam: make the NEXT `distribute` call fail once.
|
|
6841
|
+
* - `{status, code?, reason?}`: a typed `MlsBackendHttpError`, exactly what
|
|
6842
|
+
* `RestMlsBackend.distribute` throws for that response body.
|
|
6843
|
+
* - `{network: true}`: a `TypeError`, what `fetch` throws on a network
|
|
6844
|
+
* failure.
|
|
6845
|
+
* - `afterApply: true` on either: the commit is APPLIED first (it landed)
|
|
6846
|
+
* and the failure is what the client sees, i.e. a lost 2xx.
|
|
6847
|
+
* Call it several times to queue several faults.
|
|
6848
|
+
*/
|
|
6849
|
+
failNextDistribute(fault) {
|
|
6850
|
+
this.distributeFaults.push(fault);
|
|
6851
|
+
}
|
|
6852
|
+
static distributePath(groupId) {
|
|
6853
|
+
return `/api/v1/mls/groups/${groupId}/distribute`;
|
|
6854
|
+
}
|
|
5432
6855
|
async distribute(groupId, req) {
|
|
6856
|
+
this.distributeCalls.push({ groupId, req: structuredClone(req) });
|
|
6857
|
+
const fault = this.distributeFaults.shift();
|
|
6858
|
+
if (fault && !fault.afterApply)
|
|
6859
|
+
throw _FakeConnectBackend.faultError(groupId, fault);
|
|
6860
|
+
await this.distributeApply(groupId, req);
|
|
6861
|
+
if (fault)
|
|
6862
|
+
throw _FakeConnectBackend.faultError(groupId, fault);
|
|
6863
|
+
}
|
|
6864
|
+
static faultError(groupId, fault) {
|
|
6865
|
+
if ("network" in fault)
|
|
6866
|
+
return new TypeError("fetch failed");
|
|
6867
|
+
return new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), fault.status, fault.code, fault.reason);
|
|
6868
|
+
}
|
|
6869
|
+
async distributeApply(groupId, req) {
|
|
5433
6870
|
const channelId = this.channelByGroup.get(groupId);
|
|
5434
6871
|
if (!channelId)
|
|
5435
6872
|
throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
|
|
6873
|
+
if (req.commit) {
|
|
6874
|
+
const hit = (this.commitLog.get(groupId) ?? []).some((c) => c.epoch === Number(req.commit_epoch ?? 0) && c.sender_device_id === String(req.device_id ?? "") && c.payload === String(req.commit));
|
|
6875
|
+
if (hit)
|
|
6876
|
+
return;
|
|
6877
|
+
}
|
|
6878
|
+
if (req.commit && this.groupIdByChannel.get(channelId) !== groupId) {
|
|
6879
|
+
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "superseded");
|
|
6880
|
+
}
|
|
5436
6881
|
if (req.commit && req.expected_epoch != null) {
|
|
5437
6882
|
const current = this.epochByChannel.get(channelId) ?? 0;
|
|
5438
6883
|
if (req.expected_epoch !== current) {
|
|
5439
|
-
|
|
5440
|
-
e.status = 409;
|
|
5441
|
-
throw e;
|
|
6884
|
+
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
|
|
5442
6885
|
}
|
|
5443
6886
|
}
|
|
5444
6887
|
if (req.commit) {
|
|
@@ -5501,7 +6944,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5501
6944
|
}
|
|
5502
6945
|
}
|
|
5503
6946
|
async pullDelivery(deviceId) {
|
|
5504
|
-
return this.queues.get(deviceId) ?? [];
|
|
6947
|
+
return (this.queues.get(deviceId) ?? []).slice(0, DELIVERY_PULL_LIMIT);
|
|
5505
6948
|
}
|
|
5506
6949
|
async ackDelivery(deviceId, queueIds) {
|
|
5507
6950
|
const queue = this.queues.get(deviceId);
|
|
@@ -5684,8 +7127,10 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
5684
7127
|
import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
|
|
5685
7128
|
|
|
5686
7129
|
// ../connect-mls-store/dist/schema.js
|
|
5687
|
-
var SCHEMA_VERSION =
|
|
7130
|
+
var SCHEMA_VERSION = 9;
|
|
7131
|
+
var BUSY_TIMEOUT_MS = 5e3;
|
|
5688
7132
|
function openSchema(db) {
|
|
7133
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
5689
7134
|
db.exec("PRAGMA journal_mode=WAL");
|
|
5690
7135
|
const row = db.prepare("PRAGMA user_version").get();
|
|
5691
7136
|
if (row.user_version === 0) {
|
|
@@ -5787,8 +7232,58 @@ function openSchema(db) {
|
|
|
5787
7232
|
);
|
|
5788
7233
|
`);
|
|
5789
7234
|
}
|
|
7235
|
+
if (row.user_version < 8) {
|
|
7236
|
+
db.exec(`
|
|
7237
|
+
CREATE TABLE IF NOT EXISTS runtime_inbox (
|
|
7238
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7239
|
+
identity_id TEXT NOT NULL,
|
|
7240
|
+
message_id TEXT NOT NULL,
|
|
7241
|
+
sealed_message BLOB NOT NULL,
|
|
7242
|
+
received_at INTEGER NOT NULL,
|
|
7243
|
+
UNIQUE (identity_id, message_id)
|
|
7244
|
+
);
|
|
7245
|
+
`);
|
|
7246
|
+
db.exec(`
|
|
7247
|
+
CREATE TABLE IF NOT EXISTS runtime_reply_receipt (
|
|
7248
|
+
identity_id TEXT NOT NULL,
|
|
7249
|
+
in_reply_to TEXT NOT NULL,
|
|
7250
|
+
channel_id TEXT NOT NULL,
|
|
7251
|
+
reply_message_id TEXT NOT NULL,
|
|
7252
|
+
replied_at INTEGER NOT NULL,
|
|
7253
|
+
PRIMARY KEY (identity_id, in_reply_to)
|
|
7254
|
+
);
|
|
7255
|
+
`);
|
|
7256
|
+
}
|
|
7257
|
+
if (row.user_version < 9) {
|
|
7258
|
+
db.exec(`
|
|
7259
|
+
CREATE TABLE IF NOT EXISTS mls_pending_commit (
|
|
7260
|
+
identity_id TEXT NOT NULL,
|
|
7261
|
+
channel_id TEXT NOT NULL,
|
|
7262
|
+
sealed_state BLOB NOT NULL,
|
|
7263
|
+
sealed_request BLOB NOT NULL,
|
|
7264
|
+
created_at INTEGER NOT NULL,
|
|
7265
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
7266
|
+
);
|
|
7267
|
+
`);
|
|
7268
|
+
db.exec(`
|
|
7269
|
+
CREATE TABLE IF NOT EXISTS mls_owed_followup (
|
|
7270
|
+
identity_id TEXT NOT NULL,
|
|
7271
|
+
channel_id TEXT NOT NULL,
|
|
7272
|
+
sealed_followup BLOB NOT NULL,
|
|
7273
|
+
created_at INTEGER NOT NULL,
|
|
7274
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
7275
|
+
);
|
|
7276
|
+
`);
|
|
7277
|
+
}
|
|
7278
|
+
ensureRuntimeInboxDeliveryAttempts(db);
|
|
5790
7279
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
5791
7280
|
}
|
|
7281
|
+
function ensureRuntimeInboxDeliveryAttempts(db) {
|
|
7282
|
+
const cols = db.prepare("PRAGMA table_info(runtime_inbox)").all();
|
|
7283
|
+
if (cols.length === 0 || cols.some((c) => c.name === "delivery_attempts"))
|
|
7284
|
+
return;
|
|
7285
|
+
db.exec("ALTER TABLE runtime_inbox ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
|
|
7286
|
+
}
|
|
5792
7287
|
|
|
5793
7288
|
// ../connect-mls-store/dist/errors.js
|
|
5794
7289
|
var MlsStoreCorruptError = class extends Error {
|
|
@@ -5826,10 +7321,24 @@ function decodeBundle(bytes) {
|
|
|
5826
7321
|
}
|
|
5827
7322
|
};
|
|
5828
7323
|
}
|
|
7324
|
+
function sameRow(a, b) {
|
|
7325
|
+
if (!a || !b)
|
|
7326
|
+
return a === b;
|
|
7327
|
+
return Buffer.compare(a.sealed_state, b.sealed_state) === 0 && Buffer.compare(a.sealed_request, b.sealed_request) === 0 && Number(a.created_at) === Number(b.created_at);
|
|
7328
|
+
}
|
|
7329
|
+
function sameBytes(a, b) {
|
|
7330
|
+
if (!a || !b)
|
|
7331
|
+
return a === b;
|
|
7332
|
+
return Buffer.compare(a, b) === 0;
|
|
7333
|
+
}
|
|
7334
|
+
function sameTag(prior, tag) {
|
|
7335
|
+
return prior !== null && prior.groupId === tag.groupId && prior.epoch === tag.epoch;
|
|
7336
|
+
}
|
|
5829
7337
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
5830
7338
|
var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
|
|
5831
7339
|
var DEFAULT_MEMORY_FACT_RETENTION_K = 200;
|
|
5832
7340
|
var DEFAULT_MEMORY_FACT_RETENTION_MS = 90 * DAY_MS;
|
|
7341
|
+
var RUNTIME_REPLY_RECEIPT_MAX = 5e3;
|
|
5833
7342
|
var SqliteMlsStateStore = class {
|
|
5834
7343
|
db;
|
|
5835
7344
|
id;
|
|
@@ -5894,6 +7403,9 @@ var SqliteMlsStateStore = class {
|
|
|
5894
7403
|
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
|
|
5895
7404
|
}
|
|
5896
7405
|
async unseal(blob) {
|
|
7406
|
+
return this.unsealJson(blob);
|
|
7407
|
+
}
|
|
7408
|
+
async unsealJson(blob) {
|
|
5897
7409
|
let bytes;
|
|
5898
7410
|
try {
|
|
5899
7411
|
bytes = await this.cipher.open(blob);
|
|
@@ -5963,7 +7475,7 @@ var SqliteMlsStateStore = class {
|
|
|
5963
7475
|
try {
|
|
5964
7476
|
bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
|
|
5965
7477
|
} catch (e) {
|
|
5966
|
-
|
|
7478
|
+
writeServiceLog("warn", "store.pending_bundle_open_failed", {}, e);
|
|
5967
7479
|
return null;
|
|
5968
7480
|
}
|
|
5969
7481
|
this.bundles.set(ref, bundle);
|
|
@@ -6078,6 +7590,96 @@ var SqliteMlsStateStore = class {
|
|
|
6078
7590
|
const rows = this.db.prepare("SELECT DISTINCT enclave_id FROM channel_meta WHERE identity_id=? AND enclave_id IS NOT NULL").all(this.id);
|
|
6079
7591
|
return rows.map((r) => r.enclave_id);
|
|
6080
7592
|
}
|
|
7593
|
+
/**
|
|
7594
|
+
* Commit one decrypted daemon→runtime frame before the backend delivery row
|
|
7595
|
+
* is acknowledged. The payload is sealed at rest. A redelivery is opened and
|
|
7596
|
+
* compared so a same-id/different-payload collision becomes a loud refusal
|
|
7597
|
+
* without persisting a plaintext-derived digest.
|
|
7598
|
+
*/
|
|
7599
|
+
async stageRuntimeInbox(messageId, payload, receivedAt = Date.now()) {
|
|
7600
|
+
const prior = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
|
|
7601
|
+
if (prior) {
|
|
7602
|
+
const priorPayload = await this.openRuntimeInboxPayload(prior.sealed_message);
|
|
7603
|
+
if (!Buffer.from(priorPayload).equals(Buffer.from(payload))) {
|
|
7604
|
+
throw new Error("runtime inbox message-id payload conflict");
|
|
7605
|
+
}
|
|
7606
|
+
return false;
|
|
7607
|
+
}
|
|
7608
|
+
const sealed = await this.cipher.seal(payload);
|
|
7609
|
+
const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
|
|
7610
|
+
(identity_id, message_id, sealed_message, received_at)
|
|
7611
|
+
VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
|
|
7612
|
+
if (info.changes === 0) {
|
|
7613
|
+
const raced = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
|
|
7614
|
+
const racedPayload = await this.openRuntimeInboxPayload(raced.sealed_message);
|
|
7615
|
+
if (!Buffer.from(racedPayload).equals(Buffer.from(payload))) {
|
|
7616
|
+
throw new Error("runtime inbox message-id payload conflict");
|
|
7617
|
+
}
|
|
7618
|
+
return false;
|
|
7619
|
+
}
|
|
7620
|
+
return true;
|
|
7621
|
+
}
|
|
7622
|
+
async loadRuntimeInbox() {
|
|
7623
|
+
const rows = this.db.prepare(`SELECT message_id, sealed_message, received_at, delivery_attempts
|
|
7624
|
+
FROM runtime_inbox WHERE identity_id=? ORDER BY seq ASC`).all(this.id);
|
|
7625
|
+
const out = [];
|
|
7626
|
+
for (const row of rows) {
|
|
7627
|
+
const payload = await this.openRuntimeInboxPayload(row.sealed_message);
|
|
7628
|
+
out.push({
|
|
7629
|
+
messageId: row.message_id,
|
|
7630
|
+
payload,
|
|
7631
|
+
receivedAt: row.received_at,
|
|
7632
|
+
deliveryAttempts: Number(row.delivery_attempts)
|
|
7633
|
+
});
|
|
7634
|
+
}
|
|
7635
|
+
return out;
|
|
7636
|
+
}
|
|
7637
|
+
/**
|
|
7638
|
+
* Count one delivery of a staged runtime frame and return the new total.
|
|
7639
|
+
* Returns 0 when the message is not staged (already acknowledged, or never
|
|
7640
|
+
* durably handed off), so the caller can fall back to a process-local count.
|
|
7641
|
+
* Content-free: only the counter changes, never the sealed payload.
|
|
7642
|
+
*/
|
|
7643
|
+
noteRuntimeInboxDelivery(messageId) {
|
|
7644
|
+
const row = this.db.prepare(`UPDATE runtime_inbox SET delivery_attempts = delivery_attempts + 1
|
|
7645
|
+
WHERE identity_id=? AND message_id=?
|
|
7646
|
+
RETURNING delivery_attempts`).get(this.id, messageId);
|
|
7647
|
+
return row ? Number(row.delivery_attempts) : 0;
|
|
7648
|
+
}
|
|
7649
|
+
async openRuntimeInboxPayload(sealed) {
|
|
7650
|
+
try {
|
|
7651
|
+
return await this.cipher.open(sealed);
|
|
7652
|
+
} catch (error) {
|
|
7653
|
+
throw new MlsStoreSealError(String(error?.message ?? error));
|
|
7654
|
+
}
|
|
7655
|
+
}
|
|
7656
|
+
ackRuntimeInbox(messageId) {
|
|
7657
|
+
return this.db.prepare("DELETE FROM runtime_inbox WHERE identity_id=? AND message_id=?").run(this.id, messageId).changes === 1;
|
|
7658
|
+
}
|
|
7659
|
+
recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId, repliedAt = Date.now()) {
|
|
7660
|
+
this.db.prepare(`INSERT INTO runtime_reply_receipt
|
|
7661
|
+
(identity_id, in_reply_to, channel_id, reply_message_id, replied_at)
|
|
7662
|
+
VALUES (?, ?, ?, ?, ?)
|
|
7663
|
+
ON CONFLICT(identity_id, in_reply_to) DO UPDATE SET
|
|
7664
|
+
channel_id=excluded.channel_id,
|
|
7665
|
+
reply_message_id=excluded.reply_message_id,
|
|
7666
|
+
replied_at=excluded.replied_at`).run(this.id, inReplyTo, channelId, replyMessageId, repliedAt);
|
|
7667
|
+
const receiptCount = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_receipt WHERE identity_id=?").get(this.id);
|
|
7668
|
+
if (receiptCount.n > RUNTIME_REPLY_RECEIPT_MAX) {
|
|
7669
|
+
this.db.prepare(`DELETE FROM runtime_reply_receipt
|
|
7670
|
+
WHERE identity_id=? AND rowid IN (
|
|
7671
|
+
SELECT rowid FROM runtime_reply_receipt
|
|
7672
|
+
WHERE identity_id=?
|
|
7673
|
+
ORDER BY replied_at DESC, rowid DESC
|
|
7674
|
+
LIMIT -1 OFFSET ?
|
|
7675
|
+
)`).run(this.id, this.id, RUNTIME_REPLY_RECEIPT_MAX);
|
|
7676
|
+
}
|
|
7677
|
+
return { in_reply_to: inReplyTo, channel_id: channelId, reply_message_id: replyMessageId, replied_at: repliedAt };
|
|
7678
|
+
}
|
|
7679
|
+
runtimeReplyReceipt(inReplyTo) {
|
|
7680
|
+
return this.db.prepare(`SELECT in_reply_to, channel_id, reply_message_id, replied_at
|
|
7681
|
+
FROM runtime_reply_receipt WHERE identity_id=? AND in_reply_to=?`).get(this.id, inReplyTo) ?? null;
|
|
7682
|
+
}
|
|
6081
7683
|
async recordMemory(input) {
|
|
6082
7684
|
const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
|
|
6083
7685
|
if (!dedupKey)
|
|
@@ -6203,6 +7805,231 @@ var SqliteMlsStateStore = class {
|
|
|
6203
7805
|
}
|
|
6204
7806
|
}
|
|
6205
7807
|
}
|
|
7808
|
+
// ── C2 pending commit (schema v9) ─────────────────────────────────────
|
|
7809
|
+
// One sealed record per (identity, channel) in mls_pending_commit, and the
|
|
7810
|
+
// owed follow-up slot of a landed commit in mls_owed_followup. The
|
|
7811
|
+
// reference semantics are InMemoryMlsStateStore (connect-sdk-ts
|
|
7812
|
+
// src/mls/store.ts); the shared conformance suite pins them.
|
|
7813
|
+
//
|
|
7814
|
+
// THE AWAIT TRAP. seal/open are async. An await inside an open
|
|
7815
|
+
// DatabaseSync transaction lets another async call on this connection run
|
|
7816
|
+
// statements inside it, and another process can write between any read
|
|
7817
|
+
// and BEGIN. So every guarded method reads the record bytes, does ALL of
|
|
7818
|
+
// its crypto, and only then runs BEGIN IMMEDIATE, re-reads the record and
|
|
7819
|
+
// compares BYTES with what it read before the await (a reseal of the same
|
|
7820
|
+
// content has new bytes, so any rewrite is seen). Mismatch → ROLLBACK and
|
|
7821
|
+
// retry the whole method once; a second mismatch refuses. Inside the
|
|
7822
|
+
// transaction there is no await, so the decision and the write are one
|
|
7823
|
+
// step against every writer (MEDIUM-1).
|
|
7824
|
+
async sealJson(v) {
|
|
7825
|
+
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(v)));
|
|
7826
|
+
}
|
|
7827
|
+
readPendingRow(channelId) {
|
|
7828
|
+
return this.db.prepare("SELECT sealed_state, sealed_request, created_at FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
7829
|
+
}
|
|
7830
|
+
async openPendingRow(row) {
|
|
7831
|
+
const meta = await this.unsealJson(row.sealed_request);
|
|
7832
|
+
const state = await this.unseal(row.sealed_state);
|
|
7833
|
+
const rec = {
|
|
7834
|
+
groupId: meta.groupId,
|
|
7835
|
+
op: meta.op,
|
|
7836
|
+
request: meta.request,
|
|
7837
|
+
state,
|
|
7838
|
+
commitEpoch: meta.commitEpoch,
|
|
7839
|
+
prior: meta.prior,
|
|
7840
|
+
createdAt: Number(row.created_at)
|
|
7841
|
+
};
|
|
7842
|
+
if (meta.followUp !== void 0)
|
|
7843
|
+
rec.followUp = meta.followUp;
|
|
7844
|
+
return rec;
|
|
7845
|
+
}
|
|
7846
|
+
/** Run `fn` inside BEGIN IMMEDIATE. `fn` is synchronous by type: nothing
|
|
7847
|
+
* can interleave with it. Any throw rolls back and propagates. */
|
|
7848
|
+
immediate(fn) {
|
|
7849
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
7850
|
+
try {
|
|
7851
|
+
const result = fn();
|
|
7852
|
+
this.db.exec("COMMIT");
|
|
7853
|
+
return result;
|
|
7854
|
+
} catch (e) {
|
|
7855
|
+
this.db.exec("ROLLBACK");
|
|
7856
|
+
throw e;
|
|
7857
|
+
}
|
|
7858
|
+
}
|
|
7859
|
+
async savePendingCommit(channelId, rec, opts) {
|
|
7860
|
+
assertPendingCommitShape(rec);
|
|
7861
|
+
const meta = {
|
|
7862
|
+
groupId: rec.groupId,
|
|
7863
|
+
op: rec.op,
|
|
7864
|
+
request: rec.request,
|
|
7865
|
+
commitEpoch: rec.commitEpoch,
|
|
7866
|
+
prior: rec.prior,
|
|
7867
|
+
followUp: rec.followUp
|
|
7868
|
+
};
|
|
7869
|
+
const sealedState = await this.seal(rec.state);
|
|
7870
|
+
const sealedRequest = await this.sealJson(meta);
|
|
7871
|
+
if (opts?.replacing) {
|
|
7872
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7873
|
+
const row = this.readPendingRow(channelId);
|
|
7874
|
+
if (!row)
|
|
7875
|
+
return false;
|
|
7876
|
+
if (!matchesPendingCommitKey(await this.openPendingRow(row), opts.replacing))
|
|
7877
|
+
return false;
|
|
7878
|
+
const outcome = this.immediate(() => {
|
|
7879
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
7880
|
+
return "retry";
|
|
7881
|
+
this.db.prepare(`UPDATE mls_pending_commit SET sealed_state=?, sealed_request=?, created_at=?
|
|
7882
|
+
WHERE identity_id=? AND channel_id=?`).run(sealedState, sealedRequest, rec.createdAt, this.id, channelId);
|
|
7883
|
+
return "written";
|
|
7884
|
+
});
|
|
7885
|
+
if (outcome === "written")
|
|
7886
|
+
return true;
|
|
7887
|
+
}
|
|
7888
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
7889
|
+
}
|
|
7890
|
+
const res = this.db.prepare(opts?.onlyIfAbsent ? `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
|
|
7891
|
+
VALUES (?,?,?,?,?)
|
|
7892
|
+
ON CONFLICT(identity_id, channel_id) DO NOTHING` : `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
|
|
7893
|
+
VALUES (?,?,?,?,?)
|
|
7894
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET
|
|
7895
|
+
sealed_state=excluded.sealed_state, sealed_request=excluded.sealed_request, created_at=excluded.created_at`).run(this.id, channelId, sealedState, sealedRequest, rec.createdAt);
|
|
7896
|
+
return Number(res.changes) > 0;
|
|
7897
|
+
}
|
|
7898
|
+
async loadPendingCommit(channelId) {
|
|
7899
|
+
const row = this.readPendingRow(channelId);
|
|
7900
|
+
return row ? this.openPendingRow(row) : null;
|
|
7901
|
+
}
|
|
7902
|
+
async listPendingCommits() {
|
|
7903
|
+
const rows = this.db.prepare(`SELECT channel_id, sealed_state, sealed_request, created_at FROM mls_pending_commit
|
|
7904
|
+
WHERE identity_id=? ORDER BY channel_id`).all(this.id);
|
|
7905
|
+
const out = [];
|
|
7906
|
+
for (const row of rows)
|
|
7907
|
+
out.push({ channelId: row.channel_id, rec: await this.openPendingRow(row) });
|
|
7908
|
+
return out;
|
|
7909
|
+
}
|
|
7910
|
+
readMainBytes(channelId) {
|
|
7911
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
7912
|
+
return row?.sealed_main ?? void 0;
|
|
7913
|
+
}
|
|
7914
|
+
/**
|
|
7915
|
+
* C2 T3c (security review C1): promote ONLY the expected record, and only
|
|
7916
|
+
* while MAIN is exactly the state the caller checked against its `prior`.
|
|
7917
|
+
* Both checks are decided on bytes read BEFORE the await (the record opened
|
|
7918
|
+
* and matched on its key; MAIN unsealed and compared to `expect.main`) and
|
|
7919
|
+
* then re-proven INSIDE `BEGIN IMMEDIATE` by byte-comparing both rows, so
|
|
7920
|
+
* no writer in this or another process can slip between check and write.
|
|
7921
|
+
*/
|
|
7922
|
+
async commitPending(channelId, expect) {
|
|
7923
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7924
|
+
const row = this.readPendingRow(channelId);
|
|
7925
|
+
if (!row)
|
|
7926
|
+
return "record_mismatch";
|
|
7927
|
+
const rec = await this.openPendingRow(row);
|
|
7928
|
+
if (!matchesPendingCommitKey(rec, expect))
|
|
7929
|
+
return "record_mismatch";
|
|
7930
|
+
const mainBytes = this.readMainBytes(channelId);
|
|
7931
|
+
const main = mainBytes ? await this.unseal(mainBytes) : null;
|
|
7932
|
+
if (!sameMainState(main, expect.main))
|
|
7933
|
+
return "main_moved";
|
|
7934
|
+
const sealedOwed = rec.followUp ? await this.sealJson({ op: rec.op, followUp: rec.followUp }) : null;
|
|
7935
|
+
const done = this.immediate(() => {
|
|
7936
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
7937
|
+
return false;
|
|
7938
|
+
if (!sameBytes(mainBytes, this.readMainBytes(channelId)))
|
|
7939
|
+
return false;
|
|
7940
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
7941
|
+
VALUES (?,?,?,?)
|
|
7942
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, row.sealed_state, Date.now());
|
|
7943
|
+
this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
7944
|
+
if (sealedOwed) {
|
|
7945
|
+
this.db.prepare(`INSERT INTO mls_owed_followup (identity_id, channel_id, sealed_followup, created_at)
|
|
7946
|
+
VALUES (?,?,?,?)
|
|
7947
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET
|
|
7948
|
+
sealed_followup=excluded.sealed_followup, created_at=excluded.created_at`).run(this.id, channelId, sealedOwed, rec.createdAt);
|
|
7949
|
+
}
|
|
7950
|
+
return true;
|
|
7951
|
+
});
|
|
7952
|
+
if (done)
|
|
7953
|
+
return "committed";
|
|
7954
|
+
}
|
|
7955
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
7956
|
+
}
|
|
7957
|
+
/** C2 T3c (C1): with `expect`, delete only the record carrying that key
|
|
7958
|
+
* (matched on the opened record, re-proven by byte-compare inside
|
|
7959
|
+
* `BEGIN IMMEDIATE`); without it, delete whatever is there (the owner's
|
|
7960
|
+
* abandon, which must work even when the record does not unseal). */
|
|
7961
|
+
async clearPendingCommit(channelId, expect) {
|
|
7962
|
+
if (!expect) {
|
|
7963
|
+
const res = this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
7964
|
+
return Number(res.changes) > 0;
|
|
7965
|
+
}
|
|
7966
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7967
|
+
const row = this.readPendingRow(channelId);
|
|
7968
|
+
if (!row)
|
|
7969
|
+
return false;
|
|
7970
|
+
const rec = await this.openPendingRow(row);
|
|
7971
|
+
if (!matchesPendingCommitKey(rec, expect))
|
|
7972
|
+
return false;
|
|
7973
|
+
const outcome = this.immediate(() => {
|
|
7974
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
7975
|
+
return "retry";
|
|
7976
|
+
this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
7977
|
+
return "deleted";
|
|
7978
|
+
});
|
|
7979
|
+
if (outcome === "deleted")
|
|
7980
|
+
return true;
|
|
7981
|
+
}
|
|
7982
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
7983
|
+
}
|
|
7984
|
+
async saveMainGuarded(channelId, state, tag) {
|
|
7985
|
+
let row = this.readPendingRow(channelId);
|
|
7986
|
+
const sealedMain = await this.seal(state);
|
|
7987
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
7988
|
+
if (attempt > 0)
|
|
7989
|
+
row = this.readPendingRow(channelId);
|
|
7990
|
+
const prior = row ? (await this.unsealJson(row.sealed_request)).prior : void 0;
|
|
7991
|
+
const outcome = this.immediate(() => {
|
|
7992
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
7993
|
+
return "retry";
|
|
7994
|
+
if (row && !sameTag(prior ?? null, tag))
|
|
7995
|
+
return "refused";
|
|
7996
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
7997
|
+
VALUES (?,?,?,?)
|
|
7998
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, sealedMain, Date.now());
|
|
7999
|
+
return "written";
|
|
8000
|
+
});
|
|
8001
|
+
if (outcome === "written")
|
|
8002
|
+
return;
|
|
8003
|
+
if (outcome === "refused")
|
|
8004
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8005
|
+
}
|
|
8006
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8007
|
+
}
|
|
8008
|
+
async listOwedFollowUps() {
|
|
8009
|
+
const rows = this.db.prepare(`SELECT channel_id, sealed_followup, created_at FROM mls_owed_followup
|
|
8010
|
+
WHERE identity_id=? ORDER BY channel_id`).all(this.id);
|
|
8011
|
+
const out = [];
|
|
8012
|
+
for (const row of rows) {
|
|
8013
|
+
const o = await this.unsealJson(row.sealed_followup);
|
|
8014
|
+
out.push({ channelId: row.channel_id, owed: { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) } });
|
|
8015
|
+
}
|
|
8016
|
+
return out;
|
|
8017
|
+
}
|
|
8018
|
+
/** C2 T3c (M2): one channel's slot, so another channel's unreadable slot
|
|
8019
|
+
* cannot fail this read. */
|
|
8020
|
+
async getOwedFollowUp(channelId) {
|
|
8021
|
+
const row = this.db.prepare("SELECT sealed_followup, created_at FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
8022
|
+
if (!row)
|
|
8023
|
+
return null;
|
|
8024
|
+
const o = await this.unsealJson(row.sealed_followup);
|
|
8025
|
+
return { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) };
|
|
8026
|
+
}
|
|
8027
|
+
/** C2 T3c (M1): with `expectCreatedAt`, delete only that slot, in ONE
|
|
8028
|
+
* statement (a newer slot written meanwhile has another created_at). */
|
|
8029
|
+
async clearOwedFollowUp(channelId, expectCreatedAt) {
|
|
8030
|
+
const res = expectCreatedAt === void 0 ? this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").run(this.id, channelId) : this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=? AND created_at=?").run(this.id, channelId, expectCreatedAt);
|
|
8031
|
+
return Number(res.changes) > 0;
|
|
8032
|
+
}
|
|
6206
8033
|
/** @internal test-only */
|
|
6207
8034
|
_rawMainForTest(identityId, channelId) {
|
|
6208
8035
|
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
|
|
@@ -6299,12 +8126,13 @@ var LatencyRecorder = class {
|
|
|
6299
8126
|
};
|
|
6300
8127
|
|
|
6301
8128
|
// src/coordination.ts
|
|
8129
|
+
var TRAILING_PUNCT = /[^\w]+$/;
|
|
6302
8130
|
function parseMentions(text) {
|
|
6303
8131
|
const mentions = [];
|
|
6304
|
-
const re = /@(\w[\w]*)/
|
|
8132
|
+
const re = /@(\w[\w.-]*)/g;
|
|
6305
8133
|
let match;
|
|
6306
8134
|
while ((match = re.exec(text)) !== null) {
|
|
6307
|
-
mentions.push(match[1].toLowerCase());
|
|
8135
|
+
mentions.push(match[1].replace(TRAILING_PUNCT, "").toLowerCase());
|
|
6308
8136
|
}
|
|
6309
8137
|
return mentions;
|
|
6310
8138
|
}
|
|
@@ -6314,21 +8142,84 @@ function ownRoomNames(agentName, accountId, roomDisplayName) {
|
|
|
6314
8142
|
if (!n) return;
|
|
6315
8143
|
const lower = n.toLowerCase();
|
|
6316
8144
|
names.add(lower);
|
|
6317
|
-
|
|
8145
|
+
const first = lower.split(/[\s(]/)[0];
|
|
8146
|
+
names.add(first);
|
|
8147
|
+
const trimmed = first.replace(TRAILING_PUNCT, "");
|
|
8148
|
+
if (trimmed) names.add(trimmed);
|
|
6318
8149
|
};
|
|
6319
8150
|
add(agentName);
|
|
6320
8151
|
add(accountId);
|
|
6321
8152
|
add(roomDisplayName);
|
|
6322
8153
|
return names;
|
|
6323
8154
|
}
|
|
8155
|
+
var DEFAULT_MAX_AGENT_HOPS = 3;
|
|
8156
|
+
function agentHopAllowed(agentHops, maxAgentHops = DEFAULT_MAX_AGENT_HOPS) {
|
|
8157
|
+
return agentHops < maxAgentHops;
|
|
8158
|
+
}
|
|
6324
8159
|
function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
|
|
6325
8160
|
const mentions = parseMentions(plaintext);
|
|
8161
|
+
const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
|
|
8162
|
+
const mentionsMe = mentions.some((m) => ownNames.has(m));
|
|
8163
|
+
if (opts?.senderIsAgent === true) {
|
|
8164
|
+
if (!mentionsMe) return false;
|
|
8165
|
+
return agentHopAllowed(opts.agentHops ?? 1, opts.maxAgentHops);
|
|
8166
|
+
}
|
|
6326
8167
|
if (mentions.length === 0) return true;
|
|
6327
8168
|
if (mentions.includes("all") || mentions.includes("everyone")) return true;
|
|
6328
|
-
|
|
6329
|
-
if (mentions.some((m) => ownNames.has(m))) return true;
|
|
8169
|
+
if (mentionsMe) return true;
|
|
6330
8170
|
return false;
|
|
6331
8171
|
}
|
|
8172
|
+
var AGENT_HOP_QUIET_MS = 10 * 6e4;
|
|
8173
|
+
var AgentHopTracker = class {
|
|
8174
|
+
constructor(maxChannels = 1e4, opts = {}) {
|
|
8175
|
+
this.maxChannels = maxChannels;
|
|
8176
|
+
this.now = opts.now ?? (() => Date.now());
|
|
8177
|
+
this.quietMs = opts.quietMs ?? AGENT_HOP_QUIET_MS;
|
|
8178
|
+
}
|
|
8179
|
+
hops = /* @__PURE__ */ new Map();
|
|
8180
|
+
now;
|
|
8181
|
+
quietMs;
|
|
8182
|
+
/** Record an inbound message; returns the consecutive agent hops including it. */
|
|
8183
|
+
noteInbound(channelId, senderIsAgent) {
|
|
8184
|
+
if (senderIsAgent !== true) {
|
|
8185
|
+
this.set(channelId, { count: 0, lastAgentAt: 0 });
|
|
8186
|
+
return 0;
|
|
8187
|
+
}
|
|
8188
|
+
return this.bump(channelId);
|
|
8189
|
+
}
|
|
8190
|
+
/**
|
|
8191
|
+
* Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
|
|
8192
|
+
* channel). Callers do not report status frames, daily-limit or dead-letter
|
|
8193
|
+
* notices, or continuation parts of one multi-part reply.
|
|
8194
|
+
*/
|
|
8195
|
+
noteOwnSend(channelId) {
|
|
8196
|
+
this.bump(channelId);
|
|
8197
|
+
}
|
|
8198
|
+
size() {
|
|
8199
|
+
return this.hops.size;
|
|
8200
|
+
}
|
|
8201
|
+
bump(channelId) {
|
|
8202
|
+
const now = this.now();
|
|
8203
|
+
const prev = this.hops.get(channelId);
|
|
8204
|
+
const base = prev && prev.count > 0 && now - prev.lastAgentAt < this.quietMs ? prev.count : 0;
|
|
8205
|
+
const count = base + 1;
|
|
8206
|
+
this.set(channelId, { count, lastAgentAt: now });
|
|
8207
|
+
return count;
|
|
8208
|
+
}
|
|
8209
|
+
set(channelId, value) {
|
|
8210
|
+
this.hops.delete(channelId);
|
|
8211
|
+
this.hops.set(channelId, value);
|
|
8212
|
+
if (this.hops.size > this.maxChannels) {
|
|
8213
|
+
const oldest = this.hops.keys().next();
|
|
8214
|
+
if (!oldest.done) this.hops.delete(oldest.value);
|
|
8215
|
+
}
|
|
8216
|
+
}
|
|
8217
|
+
};
|
|
8218
|
+
function isAgentTurn(receipts, frame) {
|
|
8219
|
+
if (frame.notice === true) return false;
|
|
8220
|
+
if (typeof frame.in_reply_to === "string" && receipts.runtimeReplyReceipt(frame.in_reply_to)) return false;
|
|
8221
|
+
return true;
|
|
8222
|
+
}
|
|
6332
8223
|
|
|
6333
8224
|
// src/rate-limit.ts
|
|
6334
8225
|
var TokenBucketFloor = class {
|
|
@@ -6389,12 +8280,49 @@ var TokenBucketFloor = class {
|
|
|
6389
8280
|
}
|
|
6390
8281
|
};
|
|
6391
8282
|
|
|
8283
|
+
// src/service-log.ts
|
|
8284
|
+
var TOKEN2 = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
8285
|
+
var CLASS2 = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
8286
|
+
function errorClass2(error) {
|
|
8287
|
+
if (error === null) return "NullThrown";
|
|
8288
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
8289
|
+
const primitive = {
|
|
8290
|
+
string: "StringThrown",
|
|
8291
|
+
number: "NumberThrown",
|
|
8292
|
+
boolean: "BooleanThrown",
|
|
8293
|
+
bigint: "BigIntThrown",
|
|
8294
|
+
symbol: "SymbolThrown",
|
|
8295
|
+
undefined: "UndefinedThrown"
|
|
8296
|
+
};
|
|
8297
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
8298
|
+
}
|
|
8299
|
+
try {
|
|
8300
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
8301
|
+
return typeof name === "string" && CLASS2.test(name) ? name : "Error";
|
|
8302
|
+
} catch {
|
|
8303
|
+
return "Error";
|
|
8304
|
+
}
|
|
8305
|
+
}
|
|
8306
|
+
function formatServiceLog2(level, event, fields = {}, error) {
|
|
8307
|
+
const safeEvent = TOKEN2.test(event) ? event : "invalid_event";
|
|
8308
|
+
const parts = [`[bridge] ${level} ${safeEvent}`];
|
|
8309
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
8310
|
+
if (!TOKEN2.test(key)) continue;
|
|
8311
|
+
if (typeof value === "boolean") parts.push(`${key}=${value}`);
|
|
8312
|
+
else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
|
|
8313
|
+
}
|
|
8314
|
+
if (error !== void 0) parts.push(`error_class=${errorClass2(error)}`);
|
|
8315
|
+
return parts.join(" ");
|
|
8316
|
+
}
|
|
8317
|
+
function writeServiceLog2(level, event, fields, error) {
|
|
8318
|
+
const line = formatServiceLog2(level, event, fields, error);
|
|
8319
|
+
process.stderr.write(`${line}
|
|
8320
|
+
`);
|
|
8321
|
+
}
|
|
8322
|
+
|
|
6392
8323
|
// src/decrypt-error-log-limiter.ts
|
|
6393
8324
|
var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
|
|
6394
8325
|
var MAX_DISTINCT_PER_WINDOW = 4;
|
|
6395
|
-
function normalizeMessage(m) {
|
|
6396
|
-
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>");
|
|
6397
|
-
}
|
|
6398
8326
|
var DecryptErrorLogLimiter = class {
|
|
6399
8327
|
windowMs;
|
|
6400
8328
|
now;
|
|
@@ -6403,63 +8331,56 @@ var DecryptErrorLogLimiter = class {
|
|
|
6403
8331
|
constructor(opts = {}) {
|
|
6404
8332
|
this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
|
|
6405
8333
|
this.now = opts.now ?? Date.now;
|
|
6406
|
-
this.log = opts.log ??
|
|
8334
|
+
this.log = opts.log ?? writeServiceLog2;
|
|
6407
8335
|
}
|
|
6408
|
-
/**
|
|
6409
|
-
* Record one decrypt error for `channelId`. Logs immediately if this is
|
|
6410
|
-
* the channel's first error since its window opened (or its prior window
|
|
6411
|
-
* had already elapsed, in which case a summary for that just-closed
|
|
6412
|
-
* window is flushed first); otherwise the error is counted silently
|
|
6413
|
-
* toward the window's eventual summary line.
|
|
6414
|
-
*/
|
|
6415
8336
|
record(channelId, info) {
|
|
6416
8337
|
const t = this.now();
|
|
8338
|
+
const kind = errorClass2(info.error);
|
|
6417
8339
|
const w = this.windows.get(channelId);
|
|
6418
8340
|
if (!w) {
|
|
6419
|
-
this.logFirst(
|
|
6420
|
-
this.windows.set(channelId, {
|
|
8341
|
+
this.logFirst(info);
|
|
8342
|
+
this.windows.set(channelId, {
|
|
8343
|
+
windowStart: t,
|
|
8344
|
+
count: 1,
|
|
8345
|
+
lastEpoch: info.epoch,
|
|
8346
|
+
loggedClasses: /* @__PURE__ */ new Set([kind])
|
|
8347
|
+
});
|
|
6421
8348
|
return;
|
|
6422
8349
|
}
|
|
6423
8350
|
if (t - w.windowStart >= this.windowMs) {
|
|
6424
|
-
this.flushSummary(
|
|
6425
|
-
this.logFirst(
|
|
6426
|
-
this.windows.set(channelId, {
|
|
8351
|
+
this.flushSummary(w);
|
|
8352
|
+
this.logFirst(info);
|
|
8353
|
+
this.windows.set(channelId, {
|
|
8354
|
+
windowStart: t,
|
|
8355
|
+
count: 1,
|
|
8356
|
+
lastEpoch: info.epoch,
|
|
8357
|
+
loggedClasses: /* @__PURE__ */ new Set([kind])
|
|
8358
|
+
});
|
|
6427
8359
|
return;
|
|
6428
8360
|
}
|
|
6429
8361
|
w.count += 1;
|
|
6430
8362
|
w.lastEpoch = info.epoch;
|
|
6431
|
-
w.
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
this.logFirst(channelId, info);
|
|
6435
|
-
w.loggedKeys.add(key);
|
|
8363
|
+
if (!w.loggedClasses.has(kind) && w.loggedClasses.size < MAX_DISTINCT_PER_WINDOW) {
|
|
8364
|
+
this.logFirst(info);
|
|
8365
|
+
w.loggedClasses.add(kind);
|
|
6436
8366
|
}
|
|
6437
8367
|
}
|
|
6438
|
-
/**
|
|
6439
|
-
* Clear a channel's window (e.g. once the caller learns the channel
|
|
6440
|
-
* cleared/healed) so its NEXT error logs immediately as a fresh "first"
|
|
6441
|
-
* rather than being folded into a stale window's summary. There is
|
|
6442
|
-
* currently no onDecryptSuccess-style hook from the SDK to wire this to
|
|
6443
|
-
* automatically (see bridge-agent.ts's onDecryptError wiring comment) —
|
|
6444
|
-
* exposed here so a future success signal (or a caller with its own
|
|
6445
|
-
* channel-cleared signal) can reset explicitly. In its absence, the
|
|
6446
|
-
* window simply rolls over on interval elapse, which is an accepted
|
|
6447
|
-
* simplification (see report).
|
|
6448
|
-
*/
|
|
6449
8368
|
reset(channelId) {
|
|
6450
8369
|
this.windows.delete(channelId);
|
|
6451
8370
|
}
|
|
6452
|
-
logFirst(
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
);
|
|
8371
|
+
logFirst(info) {
|
|
8372
|
+
const fields = {};
|
|
8373
|
+
if (typeof info.epoch === "number" && Number.isFinite(info.epoch)) fields.epoch = info.epoch;
|
|
8374
|
+
this.log("warn", "decrypt.failed", fields, info.error);
|
|
6456
8375
|
}
|
|
6457
|
-
flushSummary(
|
|
8376
|
+
flushSummary(w) {
|
|
6458
8377
|
if (w.count <= 1) return;
|
|
6459
|
-
const
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
8378
|
+
const fields = {
|
|
8379
|
+
count: w.count,
|
|
8380
|
+
window_seconds: Math.round(this.windowMs / 1e3)
|
|
8381
|
+
};
|
|
8382
|
+
if (typeof w.lastEpoch === "number" && Number.isFinite(w.lastEpoch)) fields.epoch = w.lastEpoch;
|
|
8383
|
+
this.log("warn", "decrypt.suppressed", fields);
|
|
6463
8384
|
}
|
|
6464
8385
|
};
|
|
6465
8386
|
|
|
@@ -6663,8 +8584,7 @@ var HermesMemoryProvider = class {
|
|
|
6663
8584
|
try {
|
|
6664
8585
|
this.store.probeWrite();
|
|
6665
8586
|
} catch (e) {
|
|
6666
|
-
|
|
6667
|
-
`);
|
|
8587
|
+
writeServiceLog2("error", "memory.hermes_writeback_unavailable", {}, e);
|
|
6668
8588
|
}
|
|
6669
8589
|
}
|
|
6670
8590
|
store;
|
|
@@ -7357,9 +9277,7 @@ function parseSummarizeBody(parsed, selfIdentityId, log = () => {
|
|
|
7357
9277
|
return null;
|
|
7358
9278
|
}
|
|
7359
9279
|
if (typeof body.requested_by !== "string" || body.requested_by !== transcript.host_identity_id) {
|
|
7360
|
-
log(
|
|
7361
|
-
`summarize_not_host: ${String(body.requested_by)} is not the host of ${body.transcript_message_id}`
|
|
7362
|
-
);
|
|
9280
|
+
log("warn", "summarize.requester_not_host");
|
|
7363
9281
|
return null;
|
|
7364
9282
|
}
|
|
7365
9283
|
return {
|
|
@@ -7414,9 +9332,8 @@ async function runSummarize(opts) {
|
|
|
7414
9332
|
);
|
|
7415
9333
|
} catch (e) {
|
|
7416
9334
|
const aborted = controller.signal.aborted;
|
|
7417
|
-
opts.log(
|
|
7418
|
-
|
|
7419
|
-
);
|
|
9335
|
+
if (aborted) opts.log("warn", "summarize.timeout", { timeout_ms: timeoutMs });
|
|
9336
|
+
else opts.log("warn", "summarize.failed", {}, e);
|
|
7420
9337
|
return null;
|
|
7421
9338
|
} finally {
|
|
7422
9339
|
clearTimeout(timer);
|
|
@@ -7690,51 +9607,24 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
|
|
|
7690
9607
|
|
|
7691
9608
|
// src/bridge-agent.ts
|
|
7692
9609
|
import { homedir as homedir2 } from "node:os";
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
record(inReplyTo, channelId, replyMessageId) {
|
|
7710
|
-
const receipt = Object.freeze({
|
|
7711
|
-
in_reply_to: inReplyTo,
|
|
7712
|
-
reply_message_id: replyMessageId,
|
|
7713
|
-
channel_id: channelId,
|
|
7714
|
-
replied_at: this.now()
|
|
7715
|
-
});
|
|
7716
|
-
this.prune(receipt.replied_at);
|
|
7717
|
-
this.receipts.delete(inReplyTo);
|
|
7718
|
-
this.receipts.set(inReplyTo, receipt);
|
|
7719
|
-
while (this.receipts.size > this.capacity) {
|
|
7720
|
-
const oldest = this.receipts.keys().next().value;
|
|
7721
|
-
if (oldest === void 0) break;
|
|
7722
|
-
this.receipts.delete(oldest);
|
|
7723
|
-
}
|
|
7724
|
-
return receipt;
|
|
7725
|
-
}
|
|
7726
|
-
get(inReplyTo) {
|
|
7727
|
-
this.prune(this.now());
|
|
7728
|
-
return this.receipts.get(inReplyTo) ?? null;
|
|
7729
|
-
}
|
|
7730
|
-
prune(now) {
|
|
7731
|
-
for (const [key, receipt] of this.receipts) {
|
|
7732
|
-
if (now - receipt.replied_at >= this.ttlMs) this.receipts.delete(key);
|
|
7733
|
-
}
|
|
9610
|
+
function logPendingCommitResolved(outcome) {
|
|
9611
|
+
switch (outcome) {
|
|
9612
|
+
case "landed":
|
|
9613
|
+
writeServiceLog2("info", "mls.pending_commit.resolved.landed");
|
|
9614
|
+
return;
|
|
9615
|
+
case "not_landed":
|
|
9616
|
+
writeServiceLog2("info", "mls.pending_commit.resolved.not_landed");
|
|
9617
|
+
return;
|
|
9618
|
+
case "stale_prior":
|
|
9619
|
+
writeServiceLog2("info", "mls.pending_commit.resolved.stale_prior");
|
|
9620
|
+
return;
|
|
9621
|
+
case "abandoned":
|
|
9622
|
+
writeServiceLog2("info", "mls.pending_commit.resolved.abandoned");
|
|
9623
|
+
return;
|
|
9624
|
+
default:
|
|
9625
|
+
writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
|
|
7734
9626
|
}
|
|
7735
|
-
}
|
|
7736
|
-
|
|
7737
|
-
// src/bridge-agent.ts
|
|
9627
|
+
}
|
|
7738
9628
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
7739
9629
|
var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
|
|
7740
9630
|
var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
|
|
@@ -7746,6 +9636,14 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
|
|
|
7746
9636
|
return VOICE_MODE_GUIDANCE;
|
|
7747
9637
|
}
|
|
7748
9638
|
var DEFAULT_MAX_BUFFER = 1e3;
|
|
9639
|
+
var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
|
|
9640
|
+
var REDELIVERY_BACKOFF_BASE_MS = 5e3;
|
|
9641
|
+
function redeliveryDelayMs(attempts) {
|
|
9642
|
+
return 2 ** Math.min(Math.max(attempts, 0), 16) * REDELIVERY_BACKOFF_BASE_MS;
|
|
9643
|
+
}
|
|
9644
|
+
var DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
|
|
9645
|
+
var DEAD_LETTER_NOTICE_INTERVAL_MS = 60 * 6e4;
|
|
9646
|
+
var PAUSE_RECHECK_MS = 3e4;
|
|
7749
9647
|
var CHANNEL_META_TTL_MS = 5 * 6e4;
|
|
7750
9648
|
var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
|
|
7751
9649
|
var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
|
|
@@ -7825,6 +9723,13 @@ var AgentPausedSendError = class extends Error {
|
|
|
7825
9723
|
this.name = "AgentPausedSendError";
|
|
7826
9724
|
}
|
|
7827
9725
|
};
|
|
9726
|
+
var SendForbiddenError = class extends Error {
|
|
9727
|
+
constructor(channelId) {
|
|
9728
|
+
super(`send refused for channel ${channelId} (credential revoked or not a member); no retry`);
|
|
9729
|
+
this.channelId = channelId;
|
|
9730
|
+
this.name = "SendForbiddenError";
|
|
9731
|
+
}
|
|
9732
|
+
};
|
|
7828
9733
|
var UnknownActionRunError = class extends Error {
|
|
7829
9734
|
constructor(runId) {
|
|
7830
9735
|
super(`unknown or already-settled agent action run: ${runId}`);
|
|
@@ -7855,8 +9760,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7855
9760
|
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
7856
9761
|
// SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
|
|
7857
9762
|
hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
|
|
7858
|
-
onWsLifecycle: (event
|
|
7859
|
-
|
|
9763
|
+
onWsLifecycle: (event) => {
|
|
9764
|
+
if (event === "wake") writeServiceLog2("warn", "transport.delivery_ping_wake");
|
|
9765
|
+
else if (event === "halfopen") writeServiceLog2("warn", "transport.delivery_ping_halfopen");
|
|
9766
|
+
else if (event === "terminal") writeServiceLog2("error", "transport.delivery_ping_terminal");
|
|
9767
|
+
else if (event === "reconnect") writeServiceLog2("warn", "transport.delivery_ping_reconnect");
|
|
9768
|
+
else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
|
|
9769
|
+
}
|
|
7860
9770
|
});
|
|
7861
9771
|
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
7862
9772
|
this.store = new SqliteMlsStateStore({
|
|
@@ -7873,11 +9783,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7873
9783
|
backend: this.backend,
|
|
7874
9784
|
deviceId: cfg.deviceId,
|
|
7875
9785
|
identity: new TextEncoder().encode(cfg.identityId),
|
|
9786
|
+
// C2 T0: required by the SDK. This host never commits (no committer
|
|
9787
|
+
// entry is reachable from its src/), so the hook only records the
|
|
9788
|
+
// outcome (see logPendingCommitResolved).
|
|
9789
|
+
onPendingCommitResolved: (_channelId, ev) => {
|
|
9790
|
+
logPendingCommitResolved(ev.outcome);
|
|
9791
|
+
},
|
|
7876
9792
|
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
7877
9793
|
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
7878
9794
|
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
7879
9795
|
// P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
|
|
7880
|
-
// rather than a raw
|
|
9796
|
+
// rather than a raw diagnostic per failure — a wedged channel used to
|
|
7881
9797
|
// redeliver the same undecryptable row on every pullDelivery cycle and
|
|
7882
9798
|
// produced a 114 MB / 765,000-line daemon log in a single day. The
|
|
7883
9799
|
// limiter still logs the first failure immediately and collapses every
|
|
@@ -7887,8 +9803,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7887
9803
|
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
7888
9804
|
this.decryptErrorLogLimiter.record(cid, {
|
|
7889
9805
|
epoch: row.epoch,
|
|
7890
|
-
|
|
7891
|
-
message: err instanceof Error ? err.message : String(err)
|
|
9806
|
+
error: err
|
|
7892
9807
|
});
|
|
7893
9808
|
},
|
|
7894
9809
|
// P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
|
|
@@ -7899,9 +9814,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7899
9814
|
// healWedgedChannel below) — never throws back into the SDK caller.
|
|
7900
9815
|
onWedged: (channelId) => {
|
|
7901
9816
|
try {
|
|
7902
|
-
|
|
7903
|
-
`[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
|
|
7904
|
-
);
|
|
9817
|
+
writeServiceLog2("warn", "decrypt.channel_quarantined");
|
|
7905
9818
|
void this.healWedgedChannel(channelId);
|
|
7906
9819
|
} catch {
|
|
7907
9820
|
}
|
|
@@ -7926,6 +9839,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7926
9839
|
this.voiceSettingsSource = cfg.voice ? "file" : "none";
|
|
7927
9840
|
this.latency = deps.latency ?? new LatencyRecorder();
|
|
7928
9841
|
this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
9842
|
+
this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
|
|
9843
|
+
this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
|
|
7929
9844
|
this.rateFloor = new TokenBucketFloor({
|
|
7930
9845
|
capacity: cfg.rateLimitCapacity ?? 20,
|
|
7931
9846
|
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
@@ -7993,6 +9908,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
7993
9908
|
* per channel on a miss.
|
|
7994
9909
|
*/
|
|
7995
9910
|
channelRoster = /* @__PURE__ */ new Map();
|
|
9911
|
+
/**
|
|
9912
|
+
* 2026-09-25: this agent's own display_name (`ConnectIdentity.display_name`,
|
|
9913
|
+
* identity-level, so the same in every channel) from the MOST RECENT roster
|
|
9914
|
+
* refresh that listed it. Read by the room @mention filter as its
|
|
9915
|
+
* `roomDisplayName`. Kept across a later failed refresh, so a transient
|
|
9916
|
+
* members-fetch error does not make the agent deaf to its own name; a
|
|
9917
|
+
* successful refresh after a rename replaces it.
|
|
9918
|
+
*/
|
|
9919
|
+
lastOwnDisplayName;
|
|
7996
9920
|
/**
|
|
7997
9921
|
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
7998
9922
|
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
@@ -8003,13 +9927,37 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8003
9927
|
*/
|
|
8004
9928
|
channelOwner = /* @__PURE__ */ new Map();
|
|
8005
9929
|
buffer = [];
|
|
9930
|
+
/** Message ids durably staged for the attached runtime but not yet acked. */
|
|
9931
|
+
runtimePendingIds = /* @__PURE__ */ new Set();
|
|
8006
9932
|
maxBuffer;
|
|
9933
|
+
/** Phase 3 F1: deliveries so far per pending runtime message (seeded from the store). */
|
|
9934
|
+
deliveryAttempts = /* @__PURE__ */ new Map();
|
|
9935
|
+
maxDeliveryAttempts;
|
|
9936
|
+
/** Phase 3 F1: cumulative messages dead-lettered at the delivery cap. */
|
|
9937
|
+
deadLetteredMessages = 0;
|
|
9938
|
+
/** Final-wave I1(a): message id → earliest redelivery time (epoch ms). */
|
|
9939
|
+
redeliverNotBefore = /* @__PURE__ */ new Map();
|
|
9940
|
+
/** Final-wave I1(a): one timer poking consumers when the earliest backoff ends. */
|
|
9941
|
+
redeliveryTimer = null;
|
|
9942
|
+
redeliveryTimerAt = Number.POSITIVE_INFINITY;
|
|
9943
|
+
/** Final-wave I1(c): channel → when its last dead-letter notice was posted. */
|
|
9944
|
+
deadLetterNoticeAt = /* @__PURE__ */ new Map();
|
|
9945
|
+
/** Phase 3 F1: channel → when a pause was last confirmed (send 403 or probe). */
|
|
9946
|
+
pausedChannels = /* @__PURE__ */ new Map();
|
|
8007
9947
|
/**
|
|
8008
|
-
*
|
|
8009
|
-
*
|
|
8010
|
-
*
|
|
9948
|
+
* Phase 3 F1: runtime messages retired by a TERMINAL reply failure reported
|
|
9949
|
+
* outside the /v1/ws socket that holds them in flight (HTTP /v1/send). The
|
|
9950
|
+
* socket consults this so it neither redelivers them nor stays busy on them.
|
|
9951
|
+
* Bounded; oldest forgotten first.
|
|
8011
9952
|
*/
|
|
8012
|
-
|
|
9953
|
+
retiredRuntimeIds = /* @__PURE__ */ new Set();
|
|
9954
|
+
/** Phase 3 F1: one pending membership recheck per paused channel holding work. */
|
|
9955
|
+
pauseRecheckTimers = /* @__PURE__ */ new Map();
|
|
9956
|
+
/** Phase 3 F3: consecutive agent turns per channel. */
|
|
9957
|
+
agentHops = new AgentHopTracker();
|
|
9958
|
+
maxAgentHops;
|
|
9959
|
+
/** Phase 3 F3: cumulative agent-authored messages not answered (no explicit mention / hop limit). */
|
|
9960
|
+
agentLoopSuppressed = 0;
|
|
8013
9961
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
8014
9962
|
droppedMessages = 0;
|
|
8015
9963
|
/**
|
|
@@ -8147,7 +10095,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8147
10095
|
// H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
|
|
8148
10096
|
static GRACEFUL_DRAIN_MS = 5e3;
|
|
8149
10097
|
/**
|
|
8150
|
-
* P0b (daemon-stability): collapses the onDecryptError
|
|
10098
|
+
* P0b (daemon-stability): collapses the onDecryptError diagnostic from
|
|
8151
10099
|
* "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
|
|
8152
10100
|
* most one line immediately + one summary line per 60s per channel. LOG
|
|
8153
10101
|
* OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
|
|
@@ -8311,16 +10259,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8311
10259
|
if (!this.injectedFastLlm) {
|
|
8312
10260
|
this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
|
|
8313
10261
|
}
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
);
|
|
10262
|
+
writeServiceLog2("info", "voice.settings_loaded", {
|
|
10263
|
+
version: this.voiceSettingsVersion,
|
|
10264
|
+
source_backend: this.voiceSettingsSource === "backend"
|
|
10265
|
+
});
|
|
8318
10266
|
} catch (e) {
|
|
8319
10267
|
if (this.voiceSettingsSource === "backend") return;
|
|
8320
|
-
|
|
8321
|
-
`[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
|
|
8322
|
-
`
|
|
8323
|
-
);
|
|
10268
|
+
writeServiceLog2("warn", "voice.settings_unavailable", {}, e);
|
|
8324
10269
|
}
|
|
8325
10270
|
}
|
|
8326
10271
|
/**
|
|
@@ -8346,23 +10291,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8346
10291
|
if (!res.ok) throw new Error(`runtime-config ${res.status}`);
|
|
8347
10292
|
const payload = await res.json();
|
|
8348
10293
|
if (!payload) {
|
|
8349
|
-
|
|
10294
|
+
writeServiceLog2("warn", "runtime_config.empty_response");
|
|
8350
10295
|
return;
|
|
8351
10296
|
}
|
|
8352
10297
|
if (typeof payload.version !== "number") {
|
|
8353
|
-
|
|
8354
|
-
`[bridge] runtime config not applied (version ${JSON.stringify(payload.version ?? null)} \u2014 invalid response)
|
|
8355
|
-
`
|
|
8356
|
-
);
|
|
10298
|
+
writeServiceLog2("warn", "runtime_config.invalid_version");
|
|
8357
10299
|
return;
|
|
8358
10300
|
}
|
|
8359
10301
|
const version = payload.version;
|
|
8360
10302
|
if (!version) {
|
|
8361
10303
|
writeManagedFiles(this.home, this.cfg.identityId, {});
|
|
8362
|
-
|
|
8363
|
-
`[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
|
|
8364
|
-
`
|
|
8365
|
-
);
|
|
10304
|
+
writeServiceLog2("info", "runtime_config.not_saved");
|
|
8366
10305
|
}
|
|
8367
10306
|
const skillsJson = payload.skills_json ?? {};
|
|
8368
10307
|
if (version) {
|
|
@@ -8395,10 +10334,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8395
10334
|
}
|
|
8396
10335
|
);
|
|
8397
10336
|
if (result.sourcesDeleted > 0) {
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
);
|
|
10337
|
+
writeServiceLog2("info", "runtime_config.legacy_memory_disposition_applied", {
|
|
10338
|
+
assigned: result.factsAssigned,
|
|
10339
|
+
sources: result.sourcesDeleted
|
|
10340
|
+
});
|
|
8402
10341
|
}
|
|
8403
10342
|
}
|
|
8404
10343
|
const local = readLocalReport(this.home, this.cfg.identityId);
|
|
@@ -8437,22 +10376,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8437
10376
|
body: JSON.stringify(report)
|
|
8438
10377
|
});
|
|
8439
10378
|
if (!posted.ok) {
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8444
|
-
|
|
10379
|
+
if (version) {
|
|
10380
|
+
writeServiceLog2("warn", "runtime_config.report_failed", { status: posted.status, version });
|
|
10381
|
+
} else {
|
|
10382
|
+
writeServiceLog2("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
|
|
10383
|
+
}
|
|
8445
10384
|
return;
|
|
8446
10385
|
}
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
` : "[bridge] legacy-memory inventory reported; no saved runtime config was applied\n"
|
|
8450
|
-
);
|
|
10386
|
+
if (version) writeServiceLog2("info", "runtime_config.applied", { version });
|
|
10387
|
+
else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
|
|
8451
10388
|
} catch (e) {
|
|
8452
|
-
|
|
8453
|
-
`[bridge] runtime config refetch failed: ${e?.message ?? e}
|
|
8454
|
-
`
|
|
8455
|
-
);
|
|
10389
|
+
writeServiceLog2("warn", "runtime_config.refetch_failed", {}, e);
|
|
8456
10390
|
}
|
|
8457
10391
|
}
|
|
8458
10392
|
/**
|
|
@@ -8470,12 +10404,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8470
10404
|
if (this.maxBuffer <= 0) return;
|
|
8471
10405
|
const overflow = this.buffer.length - this.maxBuffer;
|
|
8472
10406
|
if (overflow > 0) {
|
|
8473
|
-
this.buffer.splice(0, overflow);
|
|
10407
|
+
for (const shed of this.buffer.splice(0, overflow)) this.deliveryAttempts.delete(shed.message_id);
|
|
8474
10408
|
this.droppedMessages += overflow;
|
|
8475
10409
|
}
|
|
8476
10410
|
}
|
|
8477
10411
|
async start() {
|
|
8478
10412
|
this.startedAt = Date.now();
|
|
10413
|
+
await this.restoreRuntimeInbox();
|
|
8479
10414
|
try {
|
|
8480
10415
|
await this.ingestOnce();
|
|
8481
10416
|
} catch {
|
|
@@ -8494,7 +10429,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8494
10429
|
// See the safetyTimer/selfHealTimer comment near close(): swallow the
|
|
8495
10430
|
// expected post-close store-access rejection, log anything else.
|
|
8496
10431
|
void this.selfHealSweep().catch((e) => {
|
|
8497
|
-
if (!this.closingDown)
|
|
10432
|
+
if (!this.closingDown) writeServiceLog2("error", "self_heal.sweep_failed", {}, e);
|
|
8498
10433
|
})
|
|
8499
10434
|
),
|
|
8500
10435
|
this.sweepJitter()
|
|
@@ -8521,8 +10456,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8521
10456
|
if (mapped) return mapped;
|
|
8522
10457
|
if (!this.enclaveFallbackWarned.has(channelId)) {
|
|
8523
10458
|
this.enclaveFallbackWarned.add(channelId);
|
|
8524
|
-
|
|
8525
|
-
`);
|
|
10459
|
+
writeServiceLog2("warn", "send.enclave_fallback");
|
|
8526
10460
|
}
|
|
8527
10461
|
return this.cfg.enclaveId;
|
|
8528
10462
|
}
|
|
@@ -8533,7 +10467,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8533
10467
|
this.joinState.set(channelId, "JOINED");
|
|
8534
10468
|
return true;
|
|
8535
10469
|
} catch (e) {
|
|
8536
|
-
|
|
10470
|
+
writeServiceLog2("warn", "join.reconcile_failed", {}, e);
|
|
8537
10471
|
this.joinState.set(channelId, "PENDING");
|
|
8538
10472
|
return false;
|
|
8539
10473
|
}
|
|
@@ -8606,15 +10540,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8606
10540
|
}
|
|
8607
10541
|
this.lastWipe.set(channelId, now);
|
|
8608
10542
|
} else {
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
10543
|
+
writeServiceLog2("warn", "self_heal.wipe_suppressed", {
|
|
10544
|
+
proven_forked: provenForked,
|
|
10545
|
+
remaining_seconds: Math.round((floor - (now - lastWipe)) / 1e3)
|
|
10546
|
+
});
|
|
8612
10547
|
}
|
|
8613
10548
|
this.joinState.delete(channelId);
|
|
8614
10549
|
if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
|
|
8615
|
-
|
|
8616
|
-
`[bridge] healWedgedChannel ${channelId}: wipe suppressed and state still held \u2014 NOT requesting (the answer would be stray-acked and would cost the group two commits)`
|
|
8617
|
-
);
|
|
10550
|
+
writeServiceLog2("warn", "self_heal.request_suppressed_state_held");
|
|
8618
10551
|
return false;
|
|
8619
10552
|
}
|
|
8620
10553
|
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
@@ -8623,7 +10556,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8623
10556
|
await this.signalHeldKpWelcome(g.group_id);
|
|
8624
10557
|
return repudiated;
|
|
8625
10558
|
} catch (e) {
|
|
8626
|
-
|
|
10559
|
+
writeServiceLog2("warn", "self_heal.channel_failed", {}, e);
|
|
8627
10560
|
return false;
|
|
8628
10561
|
}
|
|
8629
10562
|
}
|
|
@@ -8650,7 +10583,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8650
10583
|
this.lastWelcomeRequest.set(channelId, now);
|
|
8651
10584
|
await this.signalHeldKpWelcome(groupId);
|
|
8652
10585
|
return true;
|
|
8653
|
-
} catch {
|
|
10586
|
+
} catch (e) {
|
|
10587
|
+
writeServiceLog2("warn", "self_heal.supersede_signal_failed", {}, e);
|
|
8654
10588
|
return false;
|
|
8655
10589
|
}
|
|
8656
10590
|
}
|
|
@@ -8722,40 +10656,50 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8722
10656
|
* reads `null` and therefore degraded.
|
|
8723
10657
|
*/
|
|
8724
10658
|
keypackageExpired = null;
|
|
10659
|
+
/** #335: dedupes the expiry-measurement warning to once per failure STREAK —
|
|
10660
|
+
* any non-throwing resolution (including the known-unknown `null` returns,
|
|
10661
|
+
* which are answers, not failures) re-arms it. Mirrors `enclaveListWarned`. */
|
|
10662
|
+
keypackageExpiryUnknownWarned = false;
|
|
8725
10663
|
/** Resolve the flag from the SAME two inputs the guard uses, with every unknown
|
|
8726
10664
|
* mapped to `null`. Never throws: a failure to measure is itself unknown. */
|
|
8727
10665
|
async resolveKeypackageExpired() {
|
|
8728
10666
|
try {
|
|
8729
10667
|
const kpId = await this.provider.getLastPublishedKpId();
|
|
8730
|
-
if (kpId === null || kpId === "") return null;
|
|
10668
|
+
if (kpId === null || kpId === "") return this.kpExpiryResolved(null);
|
|
8731
10669
|
const exp = await this.backend.fetchKeyPackageExpiry(kpId);
|
|
8732
|
-
if (exp === null) return null;
|
|
8733
|
-
return isServerExpired(exp, Date.now());
|
|
8734
|
-
} catch {
|
|
10670
|
+
if (exp === null) return this.kpExpiryResolved(null);
|
|
10671
|
+
return this.kpExpiryResolved(isServerExpired(exp, Date.now()));
|
|
10672
|
+
} catch (e) {
|
|
10673
|
+
if (!this.keypackageExpiryUnknownWarned) {
|
|
10674
|
+
this.keypackageExpiryUnknownWarned = true;
|
|
10675
|
+
writeServiceLog2("warn", "self_heal.keypackage_expiry_unknown", {}, e);
|
|
10676
|
+
}
|
|
8735
10677
|
return null;
|
|
8736
10678
|
}
|
|
8737
10679
|
}
|
|
10680
|
+
/** Every non-throwing resolution ends the failure streak (the next throw is
|
|
10681
|
+
* a NEW streak and warns again) and passes the value through unchanged. */
|
|
10682
|
+
kpExpiryResolved(value) {
|
|
10683
|
+
this.keypackageExpiryUnknownWarned = false;
|
|
10684
|
+
return value;
|
|
10685
|
+
}
|
|
8738
10686
|
async rotateDeviceKeyPackage() {
|
|
8739
10687
|
try {
|
|
8740
10688
|
await this.provider.publishKeyPackage();
|
|
8741
10689
|
} catch (e) {
|
|
8742
|
-
|
|
10690
|
+
writeServiceLog2("error", "self_heal.keypackage_rotation_failed", {}, e);
|
|
8743
10691
|
}
|
|
8744
10692
|
}
|
|
8745
10693
|
async selfHealSweep() {
|
|
8746
10694
|
if (this.closingDown) return;
|
|
8747
10695
|
if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
|
|
8748
10696
|
this.providerCapabilityMissing = true;
|
|
8749
|
-
|
|
8750
|
-
"[bridge] selfHealSweep ABORTED: provider is missing required capabilities (resyncByReplay/heldGroupIdFor) \u2014 stale SDK dist? Rebuild SDK dist THEN bridge dist. Healing is NOT running; observations are reported STALE."
|
|
8751
|
-
);
|
|
10697
|
+
writeServiceLog2("error", "self_heal.provider_capability_missing");
|
|
8752
10698
|
return;
|
|
8753
10699
|
}
|
|
8754
10700
|
if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
|
|
8755
10701
|
this.providerCapabilityMissing = true;
|
|
8756
|
-
|
|
8757
|
-
"[bridge] selfHealSweep ABORTED: backend is missing fetchKeyPackageExpiry \u2014 the SDK dist is STALE (it predates KeyPackage expiry rotation). Rebuild the SDK dist THEN the bridge dist. KeyPackage rotation and healing are NOT running; observations are reported STALE."
|
|
8758
|
-
);
|
|
10702
|
+
writeServiceLog2("error", "self_heal.backend_capability_missing");
|
|
8759
10703
|
return;
|
|
8760
10704
|
}
|
|
8761
10705
|
this.providerCapabilityMissing = false;
|
|
@@ -8782,13 +10726,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8782
10726
|
failed.push(encId);
|
|
8783
10727
|
if (!this.enclaveListWarned.has(encId)) {
|
|
8784
10728
|
this.enclaveListWarned.add(encId);
|
|
8785
|
-
|
|
10729
|
+
writeServiceLog2("warn", "self_heal.enclave_listing_failed", {}, e);
|
|
8786
10730
|
}
|
|
8787
10731
|
}
|
|
8788
10732
|
}
|
|
8789
10733
|
this.sweepEnclavesFailed = failed;
|
|
8790
10734
|
if (!listedAny) {
|
|
8791
|
-
|
|
10735
|
+
writeServiceLog2("warn", "self_heal.all_enclave_listings_failed", { count: failed.length });
|
|
8792
10736
|
return;
|
|
8793
10737
|
}
|
|
8794
10738
|
for (const c of chans) {
|
|
@@ -8839,26 +10783,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8839
10783
|
}
|
|
8840
10784
|
obs.state = "healthy";
|
|
8841
10785
|
obs.diverged = false;
|
|
8842
|
-
|
|
8843
|
-
`[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
|
|
8844
|
-
);
|
|
10786
|
+
writeServiceLog2("info", "self_heal.replay_succeeded", { epoch: obs.held_epoch });
|
|
8845
10787
|
continue;
|
|
8846
10788
|
}
|
|
8847
10789
|
const repudiated = await this.healWedgedChannel(c.channel_id, true);
|
|
8848
|
-
|
|
8849
|
-
`[bridge] selfHealSweep: ${c.channel_id} STUCK at epoch ${obs.held_epoch} (server=${obs.server_epoch}) across two sweeps \u2014 ${repudiated ? "repudiated and re-requested" : "repair SUPPRESSED (anti-thrash); state unchanged"}`
|
|
8850
|
-
);
|
|
10790
|
+
writeServiceLog2("warn", "self_heal.stuck_channel", { repudiated });
|
|
8851
10791
|
}
|
|
8852
10792
|
continue;
|
|
8853
10793
|
}
|
|
8854
|
-
|
|
8855
|
-
`[bridge] selfHealSweep: ${c.channel_id} ${behind ? `BEHIND (held=${obs.held_epoch} server=${obs.server_epoch}) \u2014 reporting only, action predicate is group-id` : "healthy, held group matches server"} \u2014 skipping`
|
|
8856
|
-
);
|
|
10794
|
+
writeServiceLog2("debug", "self_heal.channel_healthy");
|
|
8857
10795
|
continue;
|
|
8858
10796
|
}
|
|
8859
10797
|
obs.state = "diverged";
|
|
8860
10798
|
obs.diverged = true;
|
|
8861
|
-
|
|
10799
|
+
writeServiceLog2("warn", "self_heal.stale_group_state");
|
|
8862
10800
|
} else {
|
|
8863
10801
|
obs.state = "no_local_state";
|
|
8864
10802
|
obs.diverged = false;
|
|
@@ -8881,7 +10819,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8881
10819
|
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
8882
10820
|
await this.signalHeldKpWelcome(g.group_id);
|
|
8883
10821
|
} catch (e) {
|
|
8884
|
-
|
|
10822
|
+
writeServiceLog2("warn", "self_heal.channel_probe_failed", {}, e);
|
|
8885
10823
|
} finally {
|
|
8886
10824
|
this.observations.set(c.channel_id, obs);
|
|
8887
10825
|
}
|
|
@@ -8924,16 +10862,28 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8924
10862
|
async sendWithSelfHealTerminal(...args2) {
|
|
8925
10863
|
const [, channelId] = args2;
|
|
8926
10864
|
try {
|
|
8927
|
-
|
|
10865
|
+
const result = await sendWithSelfHeal(...args2);
|
|
10866
|
+
if (this.pausedChannels.has(channelId)) this.clearPause(channelId);
|
|
10867
|
+
return result;
|
|
8928
10868
|
} catch (e) {
|
|
8929
10869
|
if (e instanceof AgentPausedError) {
|
|
8930
|
-
|
|
10870
|
+
writeServiceLog2("warn", "send.agent_paused");
|
|
10871
|
+
this.pausedChannels.set(channelId, Date.now());
|
|
8931
10872
|
throw new AgentPausedSendError(channelId);
|
|
8932
10873
|
}
|
|
10874
|
+
if (e instanceof Error && /^publish failed: 40[13]$/.test(e.message)) {
|
|
10875
|
+
writeServiceLog2("warn", "send.forbidden");
|
|
10876
|
+
throw new SendForbiddenError(channelId);
|
|
10877
|
+
}
|
|
8933
10878
|
throw e;
|
|
8934
10879
|
}
|
|
8935
10880
|
}
|
|
8936
|
-
|
|
10881
|
+
/**
|
|
10882
|
+
* `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
|
|
10883
|
+
* turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
|
|
10884
|
+
* continuation parts of one multi-part reply pass false (final-wave I2).
|
|
10885
|
+
*/
|
|
10886
|
+
async send(channelId, text, messageId, opts = {}) {
|
|
8937
10887
|
const meta = this.freshMeta(channelId);
|
|
8938
10888
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
8939
10889
|
const joined = await this.reconcileJoin(channelId);
|
|
@@ -8968,10 +10918,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8968
10918
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
8969
10919
|
);
|
|
8970
10920
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
10921
|
+
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
8971
10922
|
void recordOutboundMemory(this.memory, channelId, text, id);
|
|
8972
10923
|
return { message_id: id, status: "sent" };
|
|
8973
10924
|
}
|
|
8974
|
-
async sendA2A(channelId, a2a, messageId) {
|
|
10925
|
+
async sendA2A(channelId, a2a, messageId, opts = {}) {
|
|
8975
10926
|
this.enforceRateFloor("a2a", channelId);
|
|
8976
10927
|
const joined = await this.reconcileJoin(channelId);
|
|
8977
10928
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
@@ -9005,6 +10956,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9005
10956
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
9006
10957
|
);
|
|
9007
10958
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
10959
|
+
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
9008
10960
|
return { message_id: id, status: "sent" };
|
|
9009
10961
|
}
|
|
9010
10962
|
sendDeps() {
|
|
@@ -9070,6 +11022,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9070
11022
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
9071
11023
|
);
|
|
9072
11024
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
11025
|
+
this.agentHops.noteOwnSend(channelId);
|
|
9073
11026
|
return { message_id: id, status: "sent" };
|
|
9074
11027
|
}
|
|
9075
11028
|
// ── B7: FileSafe vault commands (loopback boundary) ──────────────────────
|
|
@@ -9398,10 +11351,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9398
11351
|
return defer ? "deferred" : "answered";
|
|
9399
11352
|
} catch (e) {
|
|
9400
11353
|
this.fastPathFailed++;
|
|
9401
|
-
|
|
9402
|
-
`[bridge] voice fast path failed for ${m.channelId}/${m.messageId}: ${e.message ?? "unknown error"}
|
|
9403
|
-
`
|
|
9404
|
-
);
|
|
11354
|
+
writeServiceLog2("warn", "voice.fast_path_failed", {}, e);
|
|
9405
11355
|
return "failed";
|
|
9406
11356
|
}
|
|
9407
11357
|
}
|
|
@@ -9590,10 +11540,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9590
11540
|
} catch (e) {
|
|
9591
11541
|
if (!this.enclaveEnumWarned) {
|
|
9592
11542
|
this.enclaveEnumWarned = true;
|
|
9593
|
-
|
|
9594
|
-
"[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
|
|
9595
|
-
e
|
|
9596
|
-
);
|
|
11543
|
+
writeServiceLog2("warn", "self_heal.enclave_enumeration_failed", {}, e);
|
|
9597
11544
|
}
|
|
9598
11545
|
}
|
|
9599
11546
|
}
|
|
@@ -9641,6 +11588,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9641
11588
|
});
|
|
9642
11589
|
}
|
|
9643
11590
|
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
11591
|
+
const self = members.get(this.cfg.identityId);
|
|
11592
|
+
if (self?.name) this.lastOwnDisplayName = self.name;
|
|
9644
11593
|
await this.resolveAndPinOwner(channelId, rows);
|
|
9645
11594
|
} catch {
|
|
9646
11595
|
}
|
|
@@ -9701,9 +11650,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9701
11650
|
keyAlgo: owner.key_algo ?? "ed25519"
|
|
9702
11651
|
});
|
|
9703
11652
|
} else if (existing.publicKeyHex !== publicKeyHex) {
|
|
9704
|
-
|
|
9705
|
-
`[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
|
|
9706
|
-
);
|
|
11653
|
+
writeServiceLog2("warn", "identity.owner_pin_mismatch");
|
|
9707
11654
|
this.channelOwner.set(channelId, "anomalous");
|
|
9708
11655
|
}
|
|
9709
11656
|
} catch {
|
|
@@ -9721,19 +11668,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9721
11668
|
const cached = this.channelOwner.get(channelId);
|
|
9722
11669
|
const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
|
|
9723
11670
|
if (!cached || cached === "anomalous") {
|
|
9724
|
-
if (TRACE
|
|
11671
|
+
if (TRACE && !cached) writeServiceLog2("debug", "identity.owner_cache_missing");
|
|
11672
|
+
else if (TRACE) writeServiceLog2("debug", "identity.owner_cache_anomalous");
|
|
9725
11673
|
return null;
|
|
9726
11674
|
}
|
|
9727
11675
|
const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
|
|
9728
11676
|
if (!pin) {
|
|
9729
|
-
if (TRACE)
|
|
11677
|
+
if (TRACE) writeServiceLog2("debug", "identity.owner_pin_missing");
|
|
9730
11678
|
return null;
|
|
9731
11679
|
}
|
|
9732
11680
|
if (pin.publicKeyHex !== cached.publicKeyHex) {
|
|
9733
|
-
if (TRACE)
|
|
11681
|
+
if (TRACE) writeServiceLog2("debug", "identity.owner_pin_mismatch");
|
|
9734
11682
|
return null;
|
|
9735
11683
|
}
|
|
9736
|
-
if (TRACE)
|
|
11684
|
+
if (TRACE) writeServiceLog2("debug", "identity.owner_pin_resolved");
|
|
9737
11685
|
return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
|
|
9738
11686
|
}
|
|
9739
11687
|
async reportStatus(channelId, state, detail) {
|
|
@@ -9824,13 +11772,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9824
11772
|
const body = await runSummarize({
|
|
9825
11773
|
request,
|
|
9826
11774
|
complete: (prompt, o) => this.completeForSummary(prompt, o.signal),
|
|
9827
|
-
log:
|
|
11775
|
+
log: writeServiceLog2
|
|
9828
11776
|
});
|
|
9829
11777
|
if (body === null) return;
|
|
9830
11778
|
try {
|
|
9831
11779
|
await this.sendSummary(channelId, body);
|
|
9832
11780
|
} catch (e) {
|
|
9833
|
-
|
|
11781
|
+
writeServiceLog2("warn", "summarize.send_failed", {}, e);
|
|
9834
11782
|
}
|
|
9835
11783
|
}
|
|
9836
11784
|
/**
|
|
@@ -9847,7 +11795,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9847
11795
|
*/
|
|
9848
11796
|
async completeForSummary(prompt, signal) {
|
|
9849
11797
|
if (!this.fastLlm) {
|
|
9850
|
-
|
|
11798
|
+
writeServiceLog2("warn", "summarize.runtime_unavailable");
|
|
9851
11799
|
throw new Error("summarize_no_runtime");
|
|
9852
11800
|
}
|
|
9853
11801
|
const messages = [{ role: "user", content: prompt }];
|
|
@@ -9896,12 +11844,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9896
11844
|
(envelope) => this.transport.publish(envelope)
|
|
9897
11845
|
);
|
|
9898
11846
|
}
|
|
9899
|
-
/** Minimal stderr logger for non-fatal daemon events (mirrors the ad hoc
|
|
9900
|
-
* `process.stderr.write` calls elsewhere in this file). */
|
|
9901
|
-
log(message) {
|
|
9902
|
-
process.stderr.write(`[bridge] ${message}
|
|
9903
|
-
`);
|
|
9904
|
-
}
|
|
9905
11847
|
/**
|
|
9906
11848
|
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
9907
11849
|
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
@@ -10043,7 +11985,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10043
11985
|
for await (const ping of sub.pings) {
|
|
10044
11986
|
this.pingsReceived++;
|
|
10045
11987
|
if (ping?.reconnected) {
|
|
10046
|
-
|
|
11988
|
+
writeServiceLog2("info", "transport.delivery_ping_connected");
|
|
10047
11989
|
await this.refetchRuntimeConfig();
|
|
10048
11990
|
}
|
|
10049
11991
|
if (ping?.voiceSettingsChanged) {
|
|
@@ -10059,21 +12001,19 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10059
12001
|
} catch {
|
|
10060
12002
|
}
|
|
10061
12003
|
if (!this.closingDown) {
|
|
10062
|
-
|
|
10063
|
-
"[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"
|
|
10064
|
-
);
|
|
12004
|
+
writeServiceLog2("error", "transport.delivery_ping_stream_ended");
|
|
10065
12005
|
}
|
|
10066
12006
|
})();
|
|
10067
12007
|
this.safetyTimer = setInterval(() => {
|
|
10068
12008
|
void this.ingestOnce().catch((e) => {
|
|
10069
|
-
if (!this.closingDown)
|
|
12009
|
+
if (!this.closingDown) writeServiceLog2("error", "receive.ingest_failed", {}, e);
|
|
10070
12010
|
});
|
|
10071
12011
|
}, 25e3);
|
|
10072
12012
|
if (this.selfHealSweepEnabled) {
|
|
10073
12013
|
const scheduleSweep = () => {
|
|
10074
12014
|
this.selfHealTimer = setTimeout(() => {
|
|
10075
12015
|
void this.selfHealSweep().catch((e) => {
|
|
10076
|
-
if (!this.closingDown)
|
|
12016
|
+
if (!this.closingDown) writeServiceLog2("error", "self_heal.sweep_failed", {}, e);
|
|
10077
12017
|
});
|
|
10078
12018
|
scheduleSweep();
|
|
10079
12019
|
}, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
|
|
@@ -10113,7 +12053,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10113
12053
|
try {
|
|
10114
12054
|
await this.signalHeldKpWelcome(gid);
|
|
10115
12055
|
} catch (e) {
|
|
10116
|
-
|
|
12056
|
+
writeServiceLog2("warn", "self_heal.welcome_signal_failed", {}, e);
|
|
10117
12057
|
}
|
|
10118
12058
|
}
|
|
10119
12059
|
} catch {
|
|
@@ -10154,11 +12094,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10154
12094
|
);
|
|
10155
12095
|
if (parsed.type && parsed.type !== "text") {
|
|
10156
12096
|
if (parsed.type === "summarize") {
|
|
10157
|
-
const request = parseSummarizeBody(
|
|
10158
|
-
parsed,
|
|
10159
|
-
this.cfg.identityId,
|
|
10160
|
-
(msg) => this.log(msg)
|
|
10161
|
-
);
|
|
12097
|
+
const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog2);
|
|
10162
12098
|
if (request !== null) {
|
|
10163
12099
|
void this.handleSummarize(m.channelId, request);
|
|
10164
12100
|
}
|
|
@@ -10171,12 +12107,38 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10171
12107
|
const senderType = m.envelope.sender_type;
|
|
10172
12108
|
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
10173
12109
|
const chanMeta = this.freshMeta(m.channelId);
|
|
12110
|
+
const loopSenderIsAgent = this.loopSenderIsAgent(
|
|
12111
|
+
m.channelId,
|
|
12112
|
+
m.envelope.sender_id,
|
|
12113
|
+
senderIsAgent,
|
|
12114
|
+
false
|
|
12115
|
+
);
|
|
12116
|
+
const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
|
|
12117
|
+
if (loopSenderIsAgent === true) {
|
|
12118
|
+
const shareText = typeof parsed.text === "string" ? parsed.text : "";
|
|
12119
|
+
const answerable = chanMeta && chanMeta.is_dm === false ? shouldProcessRoomMessage(
|
|
12120
|
+
shareText,
|
|
12121
|
+
this.cfg.agentName ?? this.cfg.identityId,
|
|
12122
|
+
this.cfg.identityId,
|
|
12123
|
+
{
|
|
12124
|
+
senderIsAgent: true,
|
|
12125
|
+
roomDisplayName: this.ownRoomDisplayName(),
|
|
12126
|
+
agentHops,
|
|
12127
|
+
maxAgentHops: this.maxAgentHops
|
|
12128
|
+
}
|
|
12129
|
+
) : agentHopAllowed(agentHops, this.maxAgentHops);
|
|
12130
|
+
if (!answerable) {
|
|
12131
|
+
this.noteAgentTurnSuppressed(agentHops);
|
|
12132
|
+
acked.push(m.queueId);
|
|
12133
|
+
continue;
|
|
12134
|
+
}
|
|
12135
|
+
}
|
|
10174
12136
|
const attachment = await materializeAttachment(
|
|
10175
12137
|
parsed,
|
|
10176
12138
|
{ attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
|
|
10177
12139
|
this.receiveDeps()
|
|
10178
12140
|
);
|
|
10179
|
-
|
|
12141
|
+
const runtimeMessage = {
|
|
10180
12142
|
channel_id: m.channelId,
|
|
10181
12143
|
message_id: m.messageId,
|
|
10182
12144
|
sender_id: m.envelope.sender_id,
|
|
@@ -10190,14 +12152,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10190
12152
|
voice: true,
|
|
10191
12153
|
guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
|
|
10192
12154
|
} : {}
|
|
10193
|
-
}
|
|
10194
|
-
this.
|
|
12155
|
+
};
|
|
12156
|
+
if (!await this.stageRuntimeMessage(runtimeMessage)) continue;
|
|
10195
12157
|
} catch (e) {
|
|
10196
12158
|
this.failedAttachments++;
|
|
10197
|
-
|
|
10198
|
-
`[bridge] attachment failed on ${m.messageId}: ${e.message}
|
|
10199
|
-
`
|
|
10200
|
-
);
|
|
12159
|
+
writeServiceLog2("warn", "attachment.materialize_failed", {}, e);
|
|
10201
12160
|
}
|
|
10202
12161
|
}
|
|
10203
12162
|
acked.push(m.queueId);
|
|
@@ -10207,16 +12166,35 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10207
12166
|
const meta = this.freshMeta(m.channelId);
|
|
10208
12167
|
const senderType = m.envelope.sender_type;
|
|
10209
12168
|
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
12169
|
+
const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
|
|
12170
|
+
identityId: parsed.speaker_identity_id,
|
|
12171
|
+
displayName: parsed.speaker_display_name
|
|
12172
|
+
} : void 0;
|
|
12173
|
+
const loopSenderIsAgent = this.loopSenderIsAgent(
|
|
12174
|
+
m.channelId,
|
|
12175
|
+
m.envelope.sender_id,
|
|
12176
|
+
senderIsAgent,
|
|
12177
|
+
speaker !== void 0
|
|
12178
|
+
);
|
|
12179
|
+
const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
|
|
10210
12180
|
let shouldBuffer = true;
|
|
10211
12181
|
if (meta && meta.is_dm === false) {
|
|
10212
12182
|
shouldBuffer = shouldProcessRoomMessage(
|
|
10213
12183
|
parsed.text,
|
|
10214
12184
|
this.cfg.agentName ?? this.cfg.identityId,
|
|
10215
12185
|
this.cfg.identityId,
|
|
10216
|
-
{
|
|
12186
|
+
{
|
|
12187
|
+
senderIsAgent: loopSenderIsAgent,
|
|
12188
|
+
roomDisplayName: this.ownRoomDisplayName(),
|
|
12189
|
+
agentHops,
|
|
12190
|
+
maxAgentHops: this.maxAgentHops
|
|
12191
|
+
}
|
|
10217
12192
|
);
|
|
10218
12193
|
if (!shouldBuffer) this.filteredRoomMessages++;
|
|
12194
|
+
} else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
|
|
12195
|
+
shouldBuffer = false;
|
|
10219
12196
|
}
|
|
12197
|
+
if (!shouldBuffer && loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
|
|
10220
12198
|
if (shouldBuffer) {
|
|
10221
12199
|
const senderName = this.freshRoster(m.channelId)?.get(
|
|
10222
12200
|
m.envelope.sender_id
|
|
@@ -10226,10 +12204,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10226
12204
|
m.envelope.sender_id
|
|
10227
12205
|
);
|
|
10228
12206
|
const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
|
|
10229
|
-
const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
|
|
10230
|
-
identityId: parsed.speaker_identity_id,
|
|
10231
|
-
displayName: parsed.speaker_display_name
|
|
10232
|
-
} : void 0;
|
|
10233
12207
|
const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
|
|
10234
12208
|
const humanTurn = senderIsAgent !== true || speaker !== void 0;
|
|
10235
12209
|
const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
|
|
@@ -10238,7 +12212,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10238
12212
|
if (this.rateFloor.available(`room:${m.channelId}`) < 2) fastPathRoomBlocked = true;
|
|
10239
12213
|
}
|
|
10240
12214
|
if (fastPathRoomBlocked) this.fastPathSkipped++;
|
|
10241
|
-
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked;
|
|
12215
|
+
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
|
|
10242
12216
|
const turnText = parsed.text;
|
|
10243
12217
|
const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
|
|
10244
12218
|
const fastGreeting = isCallStart && fastPathFiring;
|
|
@@ -10262,7 +12236,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10262
12236
|
});
|
|
10263
12237
|
if (fastPathFiring) {
|
|
10264
12238
|
const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
|
|
10265
|
-
|
|
12239
|
+
const outcome = await this.runFastPath(
|
|
10266
12240
|
{
|
|
10267
12241
|
channelId: m.channelId,
|
|
10268
12242
|
messageId: m.messageId,
|
|
@@ -10272,16 +12246,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10272
12246
|
fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text,
|
|
10273
12247
|
voiceGuidance,
|
|
10274
12248
|
fastSpeaker
|
|
10275
|
-
)
|
|
10276
|
-
|
|
10277
|
-
this.
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
}
|
|
12249
|
+
);
|
|
12250
|
+
if (outcome !== "answered") {
|
|
12251
|
+
if (!await this.stageRuntimeMessage(runtimeEntry(outcome === "deferred", await runtimeMemory))) {
|
|
12252
|
+
continue;
|
|
12253
|
+
}
|
|
12254
|
+
}
|
|
10281
12255
|
} else {
|
|
10282
|
-
this.
|
|
12256
|
+
if (!await this.stageRuntimeMessage(runtimeEntry(false, await runtimeMemory))) continue;
|
|
10283
12257
|
}
|
|
10284
|
-
this.capBuffer();
|
|
10285
12258
|
void recordInboundMemory(
|
|
10286
12259
|
this.memory,
|
|
10287
12260
|
m.channelId,
|
|
@@ -10312,7 +12285,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10312
12285
|
}
|
|
10313
12286
|
async drain(channelId, waitSeconds) {
|
|
10314
12287
|
let got = this.takeBuffered(channelId);
|
|
10315
|
-
if (got.length || waitSeconds <= 0)
|
|
12288
|
+
if (got.length || waitSeconds <= 0) {
|
|
12289
|
+
this.ackRuntimeMessages(got.map((message) => message.message_id));
|
|
12290
|
+
return got;
|
|
12291
|
+
}
|
|
10316
12292
|
const cap = Math.min(waitSeconds, 50) * 1e3;
|
|
10317
12293
|
await new Promise((resolve) => {
|
|
10318
12294
|
let fired = false;
|
|
@@ -10328,7 +12304,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10328
12304
|
timer = setTimeout(wake, cap);
|
|
10329
12305
|
this.waiters.push(wake);
|
|
10330
12306
|
});
|
|
10331
|
-
|
|
12307
|
+
got = this.takeBuffered(channelId);
|
|
12308
|
+
this.ackRuntimeMessages(got.map((message) => message.message_id));
|
|
12309
|
+
return got;
|
|
10332
12310
|
}
|
|
10333
12311
|
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
10334
12312
|
subscribe(listener) {
|
|
@@ -10337,19 +12315,319 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10337
12315
|
}
|
|
10338
12316
|
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
10339
12317
|
takeBuffered(channelId) {
|
|
10340
|
-
if (channelId === void 0) return this.buffer.splice(0);
|
|
12318
|
+
if (channelId === void 0 && this.pausedChannels.size === 0) return this.buffer.splice(0);
|
|
10341
12319
|
const keep = [];
|
|
10342
12320
|
const out = [];
|
|
10343
|
-
for (const m of this.buffer)
|
|
12321
|
+
for (const m of this.buffer) {
|
|
12322
|
+
const wanted = channelId === void 0 || m.channel_id === channelId;
|
|
12323
|
+
(wanted && !this.heldForPause(m.channel_id) ? out : keep).push(m);
|
|
12324
|
+
}
|
|
10344
12325
|
this.buffer.length = 0;
|
|
10345
12326
|
this.buffer.push(...keep);
|
|
10346
12327
|
return out;
|
|
10347
12328
|
}
|
|
12329
|
+
/**
|
|
12330
|
+
* Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
|
|
12331
|
+
* Splices out, oldest first, at most ONE buffered message per channel that
|
|
12332
|
+
* is not in `busyChannels` (the channels the consumer still holds a message
|
|
12333
|
+
* in flight for); everything else stays buffered in order. A runtime is
|
|
12334
|
+
* therefore never handed a second message for a channel it is still
|
|
12335
|
+
* working on — which it used to refuse with a "busy" reply and ack — and a
|
|
12336
|
+
* backlog replays one message per channel at a time after a restart.
|
|
12337
|
+
*/
|
|
12338
|
+
takeDeliverable(busyChannels) {
|
|
12339
|
+
const picked = /* @__PURE__ */ new Set();
|
|
12340
|
+
const out = [];
|
|
12341
|
+
const keep = [];
|
|
12342
|
+
const backingOff = /* @__PURE__ */ new Set();
|
|
12343
|
+
const now = Date.now();
|
|
12344
|
+
let wakeAt = Number.POSITIVE_INFINITY;
|
|
12345
|
+
for (const m of this.buffer) {
|
|
12346
|
+
const ch = m.channel_id;
|
|
12347
|
+
if (busyChannels.has(ch) || picked.has(ch) || backingOff.has(ch) || this.heldForPause(ch)) {
|
|
12348
|
+
keep.push(m);
|
|
12349
|
+
continue;
|
|
12350
|
+
}
|
|
12351
|
+
const notBefore = this.redeliverNotBefore.get(m.message_id);
|
|
12352
|
+
if (notBefore !== void 0 && notBefore > now) {
|
|
12353
|
+
backingOff.add(ch);
|
|
12354
|
+
wakeAt = Math.min(wakeAt, notBefore);
|
|
12355
|
+
keep.push(m);
|
|
12356
|
+
continue;
|
|
12357
|
+
}
|
|
12358
|
+
picked.add(ch);
|
|
12359
|
+
out.push(m);
|
|
12360
|
+
}
|
|
12361
|
+
if (wakeAt !== Number.POSITIVE_INFINITY) this.armRedeliveryWake(wakeAt);
|
|
12362
|
+
if (out.length) {
|
|
12363
|
+
this.buffer.length = 0;
|
|
12364
|
+
this.buffer.push(...keep);
|
|
12365
|
+
}
|
|
12366
|
+
return out;
|
|
12367
|
+
}
|
|
12368
|
+
/**
|
|
12369
|
+
* Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
|
|
12370
|
+
* true when it may be sent (and counts the attempt durably), false when the
|
|
12371
|
+
* message already reached the delivery cap — then it is dead-lettered:
|
|
12372
|
+
* acknowledged, never redelivered, never reaching a model. (Paused channels
|
|
12373
|
+
* never get here: takeDeliverable holds them, so a pause spends no attempts.)
|
|
12374
|
+
* Logs are content-free (event + counts only).
|
|
12375
|
+
*/
|
|
12376
|
+
admitRuntimeDelivery(message) {
|
|
12377
|
+
const prior = this.deliveryAttempts.get(message.message_id) ?? 0;
|
|
12378
|
+
if (prior >= this.maxDeliveryAttempts) {
|
|
12379
|
+
this.deadLetter(message, prior);
|
|
12380
|
+
return false;
|
|
12381
|
+
}
|
|
12382
|
+
this.redeliverNotBefore.delete(message.message_id);
|
|
12383
|
+
let durable = 0;
|
|
12384
|
+
try {
|
|
12385
|
+
durable = this.store.noteRuntimeInboxDelivery(message.message_id);
|
|
12386
|
+
} catch (e) {
|
|
12387
|
+
writeServiceLog2("warn", "runtime_inbox.attempt_persist_failed", {}, e);
|
|
12388
|
+
}
|
|
12389
|
+
this.deliveryAttempts.set(message.message_id, Math.max(durable, prior + 1));
|
|
12390
|
+
return true;
|
|
12391
|
+
}
|
|
12392
|
+
/**
|
|
12393
|
+
* Final-wave I1: put back runtime deliveries that FAILED — the runtime sent a
|
|
12394
|
+
* `nack` for the message, or the socket closed while it was in flight (an
|
|
12395
|
+
* older runtime closes on any failed turn). Each one's attempt was already
|
|
12396
|
+
* counted durably at delivery; here it either dead-letters (cap reached) or
|
|
12397
|
+
* is requeued at the front behind a 2^attempts × 5 s redelivery backoff.
|
|
12398
|
+
* Messages that were never handed to the runtime go back via requeueFront
|
|
12399
|
+
* instead and are not affected.
|
|
12400
|
+
*/
|
|
12401
|
+
requeueFailedRuntimeDeliveries(messages) {
|
|
12402
|
+
const back = [];
|
|
12403
|
+
const now = Date.now();
|
|
12404
|
+
for (const m of messages) {
|
|
12405
|
+
const attempts = this.deliveryAttempts.get(m.message_id) ?? 0;
|
|
12406
|
+
if (attempts >= this.maxDeliveryAttempts) {
|
|
12407
|
+
this.deadLetter(m, attempts);
|
|
12408
|
+
continue;
|
|
12409
|
+
}
|
|
12410
|
+
if (attempts > 0) this.redeliverNotBefore.set(m.message_id, now + redeliveryDelayMs(attempts));
|
|
12411
|
+
back.push(m);
|
|
12412
|
+
}
|
|
12413
|
+
this.requeueFront(back);
|
|
12414
|
+
}
|
|
12415
|
+
/**
|
|
12416
|
+
* Phase 3 F1 dead letter: acknowledged, never redelivered, never reaching a
|
|
12417
|
+
* model — plus (final-wave I1(c)) one content-free notice to the channel,
|
|
12418
|
+
* at most once per channel per hour. Logs carry counts only.
|
|
12419
|
+
*/
|
|
12420
|
+
deadLetter(message, attempts) {
|
|
12421
|
+
this.deadLetteredMessages++;
|
|
12422
|
+
writeServiceLog2("warn", "runtime_inbox.dead_letter", { attempts });
|
|
12423
|
+
this.ackRuntimeMessages([message.message_id]);
|
|
12424
|
+
this.noticeDeadLetter(message.channel_id);
|
|
12425
|
+
}
|
|
12426
|
+
noticeDeadLetter(channelId) {
|
|
12427
|
+
if (this.closingDown) return;
|
|
12428
|
+
const now = Date.now();
|
|
12429
|
+
const last = this.deadLetterNoticeAt.get(channelId);
|
|
12430
|
+
if (last !== void 0 && now - last < DEAD_LETTER_NOTICE_INTERVAL_MS) return;
|
|
12431
|
+
this.deadLetterNoticeAt.delete(channelId);
|
|
12432
|
+
this.deadLetterNoticeAt.set(channelId, now);
|
|
12433
|
+
if (this.deadLetterNoticeAt.size > 5e3) {
|
|
12434
|
+
const oldest = this.deadLetterNoticeAt.keys().next();
|
|
12435
|
+
if (!oldest.done) this.deadLetterNoticeAt.delete(oldest.value);
|
|
12436
|
+
}
|
|
12437
|
+
let pending;
|
|
12438
|
+
try {
|
|
12439
|
+
pending = this.send(channelId, DEAD_LETTER_NOTICE, void 0, { agentTurn: false });
|
|
12440
|
+
} catch (e) {
|
|
12441
|
+
pending = Promise.reject(e);
|
|
12442
|
+
}
|
|
12443
|
+
void pending.then(
|
|
12444
|
+
() => writeServiceLog2("info", "runtime_inbox.dead_letter_notice_sent"),
|
|
12445
|
+
(e) => writeServiceLog2("warn", "runtime_inbox.dead_letter_notice_failed", {}, e)
|
|
12446
|
+
);
|
|
12447
|
+
}
|
|
12448
|
+
/** Poke consumers once the earliest redelivery backoff ends. */
|
|
12449
|
+
armRedeliveryWake(at) {
|
|
12450
|
+
if (this.closingDown) return;
|
|
12451
|
+
if (this.redeliveryTimer !== null && this.redeliveryTimerAt <= at) return;
|
|
12452
|
+
if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
|
|
12453
|
+
this.redeliveryTimerAt = at;
|
|
12454
|
+
const timer = setTimeout(() => {
|
|
12455
|
+
this.redeliveryTimer = null;
|
|
12456
|
+
this.redeliveryTimerAt = Number.POSITIVE_INFINITY;
|
|
12457
|
+
this.wakeListeners();
|
|
12458
|
+
}, Math.max(0, at - Date.now()));
|
|
12459
|
+
timer.unref?.();
|
|
12460
|
+
this.redeliveryTimer = timer;
|
|
12461
|
+
}
|
|
12462
|
+
/**
|
|
12463
|
+
* 2026-09-25: this agent's OWN display_name for the room @mention filter
|
|
12464
|
+
* (`roomDisplayName`). Never the channel's name: a channel called
|
|
12465
|
+
* "Hermesav, Cortina011" must not make `@hermesav` address this agent. A pure
|
|
12466
|
+
* field read; the roster refresh that fills it runs once per ingest cycle,
|
|
12467
|
+
* TTL-gated, before the filter. Undefined until any roster has listed this
|
|
12468
|
+
* agent, in which case the filter still matches the credential agentName and
|
|
12469
|
+
* the identity id.
|
|
12470
|
+
*/
|
|
12471
|
+
ownRoomDisplayName() {
|
|
12472
|
+
return this.lastOwnDisplayName;
|
|
12473
|
+
}
|
|
12474
|
+
/** Phase 3 F3: the loop guard's view of the sender — server roster first. */
|
|
12475
|
+
loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
|
|
12476
|
+
if (relayedHumanTurn) return false;
|
|
12477
|
+
const row = this.freshRoster(channelId)?.get(senderId);
|
|
12478
|
+
if (row) return row.is_agent;
|
|
12479
|
+
return envelopeSaysAgent;
|
|
12480
|
+
}
|
|
12481
|
+
noteAgentTurnSuppressed(agentHops) {
|
|
12482
|
+
this.agentLoopSuppressed++;
|
|
12483
|
+
writeServiceLog2("info", "inbound.agent_turn_suppressed", { agent_hops: agentHops });
|
|
12484
|
+
}
|
|
12485
|
+
/**
|
|
12486
|
+
* Phase 3 F1: whether `channelId`'s buffered work must be HELD because the
|
|
12487
|
+
* agent is paused there. Held work stays durably pending (never acked, no
|
|
12488
|
+
* delivery attempt spent) and is released by {@link recheckPause} once the
|
|
12489
|
+
* owner resumes the agent. Only a channel whose send was refused with
|
|
12490
|
+
* `agent_paused` is ever held. Holding arms a recheck at the end of the
|
|
12491
|
+
* PAUSE_RECHECK_MS window, so release does not depend on new traffic.
|
|
12492
|
+
*/
|
|
12493
|
+
heldForPause(channelId) {
|
|
12494
|
+
const at = this.pausedChannels.get(channelId);
|
|
12495
|
+
if (at === void 0) return false;
|
|
12496
|
+
this.schedulePauseRecheck(channelId, at);
|
|
12497
|
+
return true;
|
|
12498
|
+
}
|
|
12499
|
+
schedulePauseRecheck(channelId, confirmedAt) {
|
|
12500
|
+
if (this.closingDown || this.pauseRecheckTimers.has(channelId)) return;
|
|
12501
|
+
const delay = Math.max(0, confirmedAt + PAUSE_RECHECK_MS - Date.now());
|
|
12502
|
+
const timer = setTimeout(() => {
|
|
12503
|
+
void this.recheckPause(channelId).catch((e) => {
|
|
12504
|
+
writeServiceLog2("warn", "pause.recheck_failed", {}, e);
|
|
12505
|
+
});
|
|
12506
|
+
}, delay);
|
|
12507
|
+
timer.unref?.();
|
|
12508
|
+
this.pauseRecheckTimers.set(channelId, timer);
|
|
12509
|
+
}
|
|
12510
|
+
/**
|
|
12511
|
+
* Re-read this agent's own membership row. Still paused → keep holding and
|
|
12512
|
+
* re-arm while work is held. Resumed — or a failed/inconclusive probe, which
|
|
12513
|
+
* fails OPEN so a channel is never left deaf (the worst case is one model
|
|
12514
|
+
* call whose reply is refused again, re-arming the pause) — releases the
|
|
12515
|
+
* held work and pokes consumers to drain it now.
|
|
12516
|
+
*/
|
|
12517
|
+
async recheckPause(channelId) {
|
|
12518
|
+
const timer = this.pauseRecheckTimers.get(channelId);
|
|
12519
|
+
if (timer) clearTimeout(timer);
|
|
12520
|
+
this.pauseRecheckTimers.delete(channelId);
|
|
12521
|
+
if (!this.pausedChannels.has(channelId) || this.closingDown) return;
|
|
12522
|
+
if (await this.probeOwnPause(channelId) === true) {
|
|
12523
|
+
const now = Date.now();
|
|
12524
|
+
this.pausedChannels.set(channelId, now);
|
|
12525
|
+
if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
|
|
12526
|
+
return;
|
|
12527
|
+
}
|
|
12528
|
+
this.clearPause(channelId);
|
|
12529
|
+
}
|
|
12530
|
+
clearPause(channelId) {
|
|
12531
|
+
this.pausedChannels.delete(channelId);
|
|
12532
|
+
const timer = this.pauseRecheckTimers.get(channelId);
|
|
12533
|
+
if (timer) clearTimeout(timer);
|
|
12534
|
+
this.pauseRecheckTimers.delete(channelId);
|
|
12535
|
+
writeServiceLog2("info", "pause.released");
|
|
12536
|
+
this.wakeListeners();
|
|
12537
|
+
}
|
|
12538
|
+
/** Read this agent's own `paused_at` from the enclave roster; undefined = unknown. */
|
|
12539
|
+
async probeOwnPause(channelId) {
|
|
12540
|
+
try {
|
|
12541
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves/${encodeURIComponent(
|
|
12542
|
+
this.resolveEnclave(channelId)
|
|
12543
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
12544
|
+
const res = await this.fetchImpl(url, {
|
|
12545
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
12546
|
+
});
|
|
12547
|
+
if (!res.ok) return void 0;
|
|
12548
|
+
const rows = await res.json();
|
|
12549
|
+
if (!Array.isArray(rows)) return void 0;
|
|
12550
|
+
const own = rows.find(
|
|
12551
|
+
(r) => r?.identity_id === this.cfg.identityId
|
|
12552
|
+
);
|
|
12553
|
+
if (!own) return void 0;
|
|
12554
|
+
return typeof own.paused_at === "string" && own.paused_at.length > 0;
|
|
12555
|
+
} catch (e) {
|
|
12556
|
+
writeServiceLog2("warn", "pause.probe_failed", {}, e);
|
|
12557
|
+
return void 0;
|
|
12558
|
+
}
|
|
12559
|
+
}
|
|
10348
12560
|
recordRuntimeReply(inReplyTo, channelId, replyMessageId) {
|
|
10349
|
-
return this.
|
|
12561
|
+
return this.store.recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId);
|
|
10350
12562
|
}
|
|
10351
12563
|
runtimeReplyReceipt(inReplyTo) {
|
|
10352
|
-
return this.
|
|
12564
|
+
return this.store.runtimeReplyReceipt(inReplyTo);
|
|
12565
|
+
}
|
|
12566
|
+
/**
|
|
12567
|
+
* Phase 3 F1: retire a runtime message whose reply failed TERMINALLY
|
|
12568
|
+
* (agent_paused / send_forbidden): acknowledged durably, never redelivered,
|
|
12569
|
+
* so the model is not run again for it. Pokes consumers so a socket holding
|
|
12570
|
+
* it in flight frees the channel for its next message.
|
|
12571
|
+
*/
|
|
12572
|
+
retireRuntimeMessage(messageId) {
|
|
12573
|
+
this.ackRuntimeMessages([messageId]);
|
|
12574
|
+
if (this.retiredRuntimeIds.size >= 5e3) {
|
|
12575
|
+
const oldest = this.retiredRuntimeIds.values().next();
|
|
12576
|
+
if (!oldest.done) this.retiredRuntimeIds.delete(oldest.value);
|
|
12577
|
+
}
|
|
12578
|
+
this.retiredRuntimeIds.add(messageId);
|
|
12579
|
+
this.wakeListeners();
|
|
12580
|
+
}
|
|
12581
|
+
isRuntimeMessageRetired(messageId) {
|
|
12582
|
+
return this.retiredRuntimeIds.has(messageId);
|
|
12583
|
+
}
|
|
12584
|
+
/** Complete the durable handoff only after the runtime explicitly acks. */
|
|
12585
|
+
ackRuntimeMessages(messageIds) {
|
|
12586
|
+
for (const messageId of messageIds) {
|
|
12587
|
+
this.store.ackRuntimeInbox(messageId);
|
|
12588
|
+
this.runtimePendingIds.delete(messageId);
|
|
12589
|
+
this.deliveryAttempts.delete(messageId);
|
|
12590
|
+
this.redeliverNotBefore.delete(messageId);
|
|
12591
|
+
}
|
|
12592
|
+
}
|
|
12593
|
+
async restoreRuntimeInbox() {
|
|
12594
|
+
const rows = await this.store.loadRuntimeInbox();
|
|
12595
|
+
for (const row of rows) {
|
|
12596
|
+
const message = JSON.parse(new TextDecoder().decode(row.payload));
|
|
12597
|
+
if (!message || message.message_id !== row.messageId || typeof message.channel_id !== "string") {
|
|
12598
|
+
throw new Error("restored runtime inbox row has an invalid message binding");
|
|
12599
|
+
}
|
|
12600
|
+
this.runtimePendingIds.add(message.message_id);
|
|
12601
|
+
if (row.deliveryAttempts > 0) {
|
|
12602
|
+
this.deliveryAttempts.set(message.message_id, row.deliveryAttempts);
|
|
12603
|
+
if (row.deliveryAttempts < this.maxDeliveryAttempts) {
|
|
12604
|
+
this.redeliverNotBefore.set(message.message_id, Date.now() + redeliveryDelayMs(row.deliveryAttempts));
|
|
12605
|
+
}
|
|
12606
|
+
}
|
|
12607
|
+
this.buffer.push(message);
|
|
12608
|
+
}
|
|
12609
|
+
}
|
|
12610
|
+
/**
|
|
12611
|
+
* Seal a runtime frame before the backend queue id may be acknowledged.
|
|
12612
|
+
* Returning false is backpressure: the caller leaves the backend row unacked
|
|
12613
|
+
* so it remains the durable queue rather than shedding accepted work.
|
|
12614
|
+
*/
|
|
12615
|
+
async stageRuntimeMessage(message) {
|
|
12616
|
+
if (this.runtimePendingIds.has(message.message_id)) return true;
|
|
12617
|
+
if (this.runtimePendingIds.size >= this.maxBuffer) {
|
|
12618
|
+
writeServiceLog2("warn", "runtime_inbox.full");
|
|
12619
|
+
return false;
|
|
12620
|
+
}
|
|
12621
|
+
try {
|
|
12622
|
+
const payload = new TextEncoder().encode(JSON.stringify(message));
|
|
12623
|
+
await this.store.stageRuntimeInbox(message.message_id, payload, message.ts);
|
|
12624
|
+
this.runtimePendingIds.add(message.message_id);
|
|
12625
|
+
this.buffer.push(message);
|
|
12626
|
+
return true;
|
|
12627
|
+
} catch (error) {
|
|
12628
|
+
writeServiceLog2("error", "runtime_inbox.stage_failed", {}, error);
|
|
12629
|
+
return false;
|
|
12630
|
+
}
|
|
10353
12631
|
}
|
|
10354
12632
|
/**
|
|
10355
12633
|
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
@@ -10437,6 +12715,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10437
12715
|
buffered: this.buffer.length,
|
|
10438
12716
|
dropped: this.droppedMessages,
|
|
10439
12717
|
filtered_room: this.filteredRoomMessages,
|
|
12718
|
+
dead_lettered: this.deadLetteredMessages,
|
|
12719
|
+
paused_channels: this.pausedChannels.size,
|
|
12720
|
+
agent_loop_suppressed: this.agentLoopSuppressed,
|
|
10440
12721
|
rate_limited: this.rateLimitedSends,
|
|
10441
12722
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
10442
12723
|
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
@@ -10489,6 +12770,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10489
12770
|
errors: this.provider.errorStats(),
|
|
10490
12771
|
dropped: this.droppedMessages,
|
|
10491
12772
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
12773
|
+
deadLetteredMessages: this.deadLetteredMessages,
|
|
12774
|
+
pausedChannels: this.pausedChannels.size,
|
|
12775
|
+
agentLoopSuppressed: this.agentLoopSuppressed,
|
|
10492
12776
|
rateLimitedSends: this.rateLimitedSends,
|
|
10493
12777
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
10494
12778
|
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
@@ -10500,6 +12784,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10500
12784
|
}
|
|
10501
12785
|
async close() {
|
|
10502
12786
|
this.closingDown = true;
|
|
12787
|
+
for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
|
|
12788
|
+
this.pauseRecheckTimers.clear();
|
|
12789
|
+
if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
|
|
12790
|
+
this.redeliveryTimer = null;
|
|
10503
12791
|
this.pingHandle?.close();
|
|
10504
12792
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
10505
12793
|
if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
|
|
@@ -10518,8 +12806,7 @@ function loadConfig(opts) {
|
|
|
10518
12806
|
const raw = readFileSync6(opts.credentialPath, "utf8");
|
|
10519
12807
|
try {
|
|
10520
12808
|
const mode = statSync3(opts.credentialPath).mode & 511;
|
|
10521
|
-
if (mode & 63)
|
|
10522
|
-
`);
|
|
12809
|
+
if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
|
|
10523
12810
|
} catch {
|
|
10524
12811
|
}
|
|
10525
12812
|
let json2;
|
|
@@ -10675,6 +12962,9 @@ var BRIDGE_ERRORS = {
|
|
|
10675
12962
|
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
10676
12963
|
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
10677
12964
|
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
12965
|
+
// Phase 3 F1: TERMINAL send outcomes — do not retry the turn.
|
|
12966
|
+
agent_paused: { http: 403, message: "agent is paused in this channel; not sent" },
|
|
12967
|
+
send_forbidden: { http: 403, message: "send refused (credential revoked or not a member); not sent" },
|
|
10678
12968
|
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
10679
12969
|
// FileSafe vault (B7): digest mismatch, DEK-wrap AEAD failure (bad/dropped
|
|
10680
12970
|
// nonce, wrong KEK), owner-pin/signature/rollback rejection, or "no wrap
|
|
@@ -10697,7 +12987,7 @@ function wsErrorFrame(code, detail) {
|
|
|
10697
12987
|
}
|
|
10698
12988
|
|
|
10699
12989
|
// src/http-server.ts
|
|
10700
|
-
function writeSse(res, msgs) {
|
|
12990
|
+
function writeSse(res, msgs, onWriteError) {
|
|
10701
12991
|
for (let i = 0; i < msgs.length; i++) {
|
|
10702
12992
|
if (res.destroyed || res.writableEnded) return msgs.slice(i);
|
|
10703
12993
|
try {
|
|
@@ -10705,12 +12995,24 @@ function writeSse(res, msgs) {
|
|
|
10705
12995
|
data: ${JSON.stringify(msgs[i])}
|
|
10706
12996
|
|
|
10707
12997
|
`);
|
|
10708
|
-
} catch {
|
|
12998
|
+
} catch (err) {
|
|
12999
|
+
try {
|
|
13000
|
+
onWriteError?.(err);
|
|
13001
|
+
} catch {
|
|
13002
|
+
}
|
|
10709
13003
|
return msgs.slice(i);
|
|
10710
13004
|
}
|
|
10711
13005
|
}
|
|
10712
13006
|
return [];
|
|
10713
13007
|
}
|
|
13008
|
+
function sseWriteFailureReporter() {
|
|
13009
|
+
let warned = false;
|
|
13010
|
+
return (error) => {
|
|
13011
|
+
if (warned) return;
|
|
13012
|
+
warned = true;
|
|
13013
|
+
writeServiceLog2("warn", "sse.write_failed", {}, error);
|
|
13014
|
+
};
|
|
13015
|
+
}
|
|
10714
13016
|
function capabilities(opts) {
|
|
10715
13017
|
return {
|
|
10716
13018
|
api_version: "1",
|
|
@@ -10737,6 +13039,8 @@ var sendSchema = z3.object({
|
|
|
10737
13039
|
text: z3.string().optional(),
|
|
10738
13040
|
idempotency_key: z3.string().optional(),
|
|
10739
13041
|
in_reply_to: z3.string().min(1).max(200).optional(),
|
|
13042
|
+
/** Final-wave I2: a notice (e.g. a daily-limit reply) is not an agent turn. */
|
|
13043
|
+
notice: z3.boolean().optional(),
|
|
10740
13044
|
a2a: a2aSchema.optional()
|
|
10741
13045
|
}).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
|
|
10742
13046
|
message: "send requires either text or a2a"
|
|
@@ -10862,7 +13166,16 @@ function createHttpServer(agent, opts) {
|
|
|
10862
13166
|
if (method === "POST" && path === "/v1/send") {
|
|
10863
13167
|
const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
|
|
10864
13168
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
10865
|
-
|
|
13169
|
+
let r;
|
|
13170
|
+
const turn = { agentTurn: isAgentTurn(agent, parsed.data) };
|
|
13171
|
+
try {
|
|
13172
|
+
r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, void 0, turn) : await agent.send(parsed.data.channel_id, parsed.data.text, void 0, turn);
|
|
13173
|
+
} catch (e) {
|
|
13174
|
+
if (parsed.data.in_reply_to && (e instanceof AgentPausedSendError || e instanceof SendForbiddenError)) {
|
|
13175
|
+
agent.retireRuntimeMessage(parsed.data.in_reply_to);
|
|
13176
|
+
}
|
|
13177
|
+
throw e;
|
|
13178
|
+
}
|
|
10866
13179
|
if (parsed.data.in_reply_to) {
|
|
10867
13180
|
agent.recordRuntimeReply(parsed.data.in_reply_to, parsed.data.channel_id, r.message_id);
|
|
10868
13181
|
}
|
|
@@ -10938,8 +13251,12 @@ function createHttpServer(agent, opts) {
|
|
|
10938
13251
|
connection: "keep-alive"
|
|
10939
13252
|
});
|
|
10940
13253
|
res.write(":connected\n\n");
|
|
13254
|
+
const onWriteError = sseWriteFailureReporter();
|
|
10941
13255
|
const drainToClient = () => {
|
|
10942
|
-
const
|
|
13256
|
+
const batch = agent.takeBuffered(channel);
|
|
13257
|
+
const left2 = writeSse(res, batch, onWriteError);
|
|
13258
|
+
const leftIds = new Set(left2.map((message) => message.message_id));
|
|
13259
|
+
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
10943
13260
|
if (left2.length) agent.requeueFront(left2);
|
|
10944
13261
|
};
|
|
10945
13262
|
const unsubscribe = agent.subscribe(drainToClient);
|
|
@@ -10967,11 +13284,12 @@ function createHttpServer(agent, opts) {
|
|
|
10967
13284
|
}
|
|
10968
13285
|
if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
|
|
10969
13286
|
if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
|
|
13287
|
+
if (e instanceof AgentPausedSendError) return jsonError(res, "agent_paused");
|
|
13288
|
+
if (e instanceof SendForbiddenError) return jsonError(res, "send_forbidden");
|
|
10970
13289
|
if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
|
|
10971
13290
|
if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
|
|
10972
13291
|
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
10973
|
-
|
|
10974
|
-
`);
|
|
13292
|
+
writeServiceLog2("error", "http.request_failed", {}, e);
|
|
10975
13293
|
return jsonError(res, "internal");
|
|
10976
13294
|
}
|
|
10977
13295
|
};
|
|
@@ -11036,21 +13354,35 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11036
13354
|
});
|
|
11037
13355
|
const inflight = /* @__PURE__ */ new Map();
|
|
11038
13356
|
const flush = () => {
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
agent.
|
|
11044
|
-
|
|
13357
|
+
for (; ; ) {
|
|
13358
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
13359
|
+
const busy = /* @__PURE__ */ new Set();
|
|
13360
|
+
for (const [id, m] of inflight) {
|
|
13361
|
+
if (agent.isRuntimeMessageRetired(id)) inflight.delete(id);
|
|
13362
|
+
else busy.add(m.channel_id);
|
|
11045
13363
|
}
|
|
11046
|
-
const
|
|
11047
|
-
|
|
11048
|
-
|
|
11049
|
-
|
|
11050
|
-
|
|
11051
|
-
|
|
11052
|
-
|
|
13364
|
+
const batch = agent.takeDeliverable(busy);
|
|
13365
|
+
if (batch.length === 0) return;
|
|
13366
|
+
let retired = false;
|
|
13367
|
+
for (let i = 0; i < batch.length; i++) {
|
|
13368
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
13369
|
+
agent.requeueFront(batch.slice(i));
|
|
13370
|
+
return;
|
|
13371
|
+
}
|
|
13372
|
+
if (!agent.admitRuntimeDelivery(batch[i])) {
|
|
13373
|
+
retired = true;
|
|
13374
|
+
continue;
|
|
13375
|
+
}
|
|
13376
|
+
const frame = { type: "message", ...batch[i] };
|
|
13377
|
+
try {
|
|
13378
|
+
sock.send(JSON.stringify(frame));
|
|
13379
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
13380
|
+
} catch {
|
|
13381
|
+
agent.requeueFront(batch.slice(i));
|
|
13382
|
+
return;
|
|
13383
|
+
}
|
|
11053
13384
|
}
|
|
13385
|
+
if (!retired) return;
|
|
11054
13386
|
}
|
|
11055
13387
|
};
|
|
11056
13388
|
const unsubscribe = agent.subscribe(flush);
|
|
@@ -11061,7 +13393,9 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11061
13393
|
agent.noteSessionClose();
|
|
11062
13394
|
unsubscribe();
|
|
11063
13395
|
if (inflight.size) {
|
|
11064
|
-
agent.
|
|
13396
|
+
agent.requeueFailedRuntimeDeliveries(
|
|
13397
|
+
[...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
|
|
13398
|
+
);
|
|
11065
13399
|
inflight.clear();
|
|
11066
13400
|
agent.wakeListeners();
|
|
11067
13401
|
}
|
|
@@ -11071,7 +13405,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11071
13405
|
sock.on("close", cleanup);
|
|
11072
13406
|
sock.on("error", cleanup);
|
|
11073
13407
|
sock.on("message", (data) => {
|
|
11074
|
-
void handleInbound(sock, agent, data, inflight);
|
|
13408
|
+
void handleInbound(sock, agent, data, inflight, flush);
|
|
11075
13409
|
});
|
|
11076
13410
|
flush();
|
|
11077
13411
|
});
|
|
@@ -11095,7 +13429,14 @@ function sendJson(sock, body) {
|
|
|
11095
13429
|
} catch {
|
|
11096
13430
|
}
|
|
11097
13431
|
}
|
|
11098
|
-
|
|
13432
|
+
function sendErrorCode(e) {
|
|
13433
|
+
if (e instanceof ChannelPendingError) return "channel_pending";
|
|
13434
|
+
if (e instanceof RateLimitedError) return "rate_limited";
|
|
13435
|
+
if (e instanceof AgentPausedSendError) return "agent_paused";
|
|
13436
|
+
if (e instanceof SendForbiddenError) return "send_forbidden";
|
|
13437
|
+
return "invalid_request";
|
|
13438
|
+
}
|
|
13439
|
+
async function handleInbound(sock, agent, data, inflight, flush) {
|
|
11099
13440
|
let msg;
|
|
11100
13441
|
try {
|
|
11101
13442
|
msg = JSON.parse(data.toString("utf8"));
|
|
@@ -11114,8 +13455,40 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11114
13455
|
return;
|
|
11115
13456
|
}
|
|
11116
13457
|
inflight.delete(m.message_id);
|
|
13458
|
+
agent.ackRuntimeMessages([m.message_id]);
|
|
13459
|
+
flush();
|
|
13460
|
+
return;
|
|
13461
|
+
}
|
|
13462
|
+
if (m.type === "hello") {
|
|
13463
|
+
sendJson(sock, { type: "hello", features: ["nack"] });
|
|
13464
|
+
return;
|
|
13465
|
+
}
|
|
13466
|
+
if (m.type === "nack") {
|
|
13467
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
13468
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
13469
|
+
return;
|
|
13470
|
+
}
|
|
13471
|
+
const failed = inflight.get(m.message_id);
|
|
13472
|
+
if (!failed) return;
|
|
13473
|
+
inflight.delete(m.message_id);
|
|
13474
|
+
if (agent.isRuntimeMessageRetired(m.message_id)) {
|
|
13475
|
+
flush();
|
|
13476
|
+
return;
|
|
13477
|
+
}
|
|
13478
|
+
writeServiceLog2("warn", "runtime_inbox.nack", { reply_failed: m.reason === "reply_failed" });
|
|
13479
|
+
agent.requeueFailedRuntimeDeliveries([failed]);
|
|
13480
|
+
flush();
|
|
11117
13481
|
return;
|
|
11118
13482
|
}
|
|
13483
|
+
const replyFailed = (e, inReplyTo) => {
|
|
13484
|
+
const code = sendErrorCode(e);
|
|
13485
|
+
if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
|
|
13486
|
+
inflight.delete(inReplyTo);
|
|
13487
|
+
agent.ackRuntimeMessages([inReplyTo]);
|
|
13488
|
+
}
|
|
13489
|
+
sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
|
|
13490
|
+
if (code === "agent_paused" || code === "send_forbidden") flush();
|
|
13491
|
+
};
|
|
11119
13492
|
if (m.type === "send") {
|
|
11120
13493
|
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
11121
13494
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
@@ -11132,19 +13505,18 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11132
13505
|
return;
|
|
11133
13506
|
}
|
|
11134
13507
|
try {
|
|
11135
|
-
const r = await agent.sendA2A(
|
|
13508
|
+
const r = await agent.sendA2A(
|
|
13509
|
+
m.channel_id,
|
|
13510
|
+
{ a2a_version: a.a2a_version, kind: a.kind, object: a.object },
|
|
13511
|
+
void 0,
|
|
13512
|
+
{ agentTurn: isAgentTurn(agent, m) }
|
|
13513
|
+
);
|
|
11136
13514
|
if (typeof m.in_reply_to === "string") {
|
|
11137
13515
|
agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
|
|
11138
13516
|
}
|
|
11139
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
13517
|
+
sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
|
|
11140
13518
|
} catch (e) {
|
|
11141
|
-
|
|
11142
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
11143
|
-
} else if (e instanceof RateLimitedError) {
|
|
11144
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
11145
|
-
} else {
|
|
11146
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
11147
|
-
}
|
|
13519
|
+
replyFailed(e, m.in_reply_to);
|
|
11148
13520
|
}
|
|
11149
13521
|
return;
|
|
11150
13522
|
}
|
|
@@ -11153,19 +13525,13 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11153
13525
|
return;
|
|
11154
13526
|
}
|
|
11155
13527
|
try {
|
|
11156
|
-
const r = await agent.send(m.channel_id, m.text);
|
|
13528
|
+
const r = await agent.send(m.channel_id, m.text, void 0, { agentTurn: isAgentTurn(agent, m) });
|
|
11157
13529
|
if (typeof m.in_reply_to === "string") {
|
|
11158
13530
|
agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
|
|
11159
13531
|
}
|
|
11160
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
13532
|
+
sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
|
|
11161
13533
|
} catch (e) {
|
|
11162
|
-
|
|
11163
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
11164
|
-
} else if (e instanceof RateLimitedError) {
|
|
11165
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
11166
|
-
} else {
|
|
11167
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
11168
|
-
}
|
|
13534
|
+
replyFailed(e, m.in_reply_to);
|
|
11169
13535
|
}
|
|
11170
13536
|
return;
|
|
11171
13537
|
}
|