@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/bin.js
CHANGED
|
@@ -180,14 +180,53 @@ function systemPrompt(p, settings) {
|
|
|
180
180
|
);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// src/service-log.ts
|
|
184
|
+
var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
185
|
+
var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
186
|
+
function errorClass(error) {
|
|
187
|
+
if (error === null) return "NullThrown";
|
|
188
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
189
|
+
const primitive = {
|
|
190
|
+
string: "StringThrown",
|
|
191
|
+
number: "NumberThrown",
|
|
192
|
+
boolean: "BooleanThrown",
|
|
193
|
+
bigint: "BigIntThrown",
|
|
194
|
+
symbol: "SymbolThrown",
|
|
195
|
+
undefined: "UndefinedThrown"
|
|
196
|
+
};
|
|
197
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
201
|
+
return typeof name === "string" && CLASS.test(name) ? name : "Error";
|
|
202
|
+
} catch {
|
|
203
|
+
return "Error";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function formatServiceLog(level, event, fields = {}, error) {
|
|
207
|
+
const safeEvent = TOKEN.test(event) ? event : "invalid_event";
|
|
208
|
+
const parts = [`[bridge] ${level} ${safeEvent}`];
|
|
209
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
210
|
+
if (!TOKEN.test(key)) continue;
|
|
211
|
+
if (typeof value === "boolean") parts.push(`${key}=${value}`);
|
|
212
|
+
else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
|
|
213
|
+
}
|
|
214
|
+
if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
|
|
215
|
+
return parts.join(" ");
|
|
216
|
+
}
|
|
217
|
+
function writeServiceLog(level, event, fields, error) {
|
|
218
|
+
const line = formatServiceLog(level, event, fields, error);
|
|
219
|
+
process.stderr.write(`${line}
|
|
220
|
+
`);
|
|
221
|
+
}
|
|
222
|
+
|
|
183
223
|
// src/config.ts
|
|
184
224
|
var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
|
|
185
225
|
function loadConfig(opts) {
|
|
186
226
|
const raw = readFileSync(opts.credentialPath, "utf8");
|
|
187
227
|
try {
|
|
188
228
|
const mode = statSync(opts.credentialPath).mode & 511;
|
|
189
|
-
if (mode & 63)
|
|
190
|
-
`);
|
|
229
|
+
if (mode & 63) writeServiceLog("warn", "config.credential_permissions_unsafe");
|
|
191
230
|
} catch {
|
|
192
231
|
}
|
|
193
232
|
let json2;
|
|
@@ -286,6 +325,55 @@ function validateBind(bind, tls) {
|
|
|
286
325
|
}
|
|
287
326
|
}
|
|
288
327
|
|
|
328
|
+
// ../connect-sdk-ts/dist/service-log.js
|
|
329
|
+
var TOKEN2 = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
330
|
+
var CLASS2 = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
331
|
+
function errorClass2(error) {
|
|
332
|
+
if (error === null)
|
|
333
|
+
return "NullThrown";
|
|
334
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
335
|
+
const primitive = {
|
|
336
|
+
string: "StringThrown",
|
|
337
|
+
number: "NumberThrown",
|
|
338
|
+
boolean: "BooleanThrown",
|
|
339
|
+
bigint: "BigIntThrown",
|
|
340
|
+
symbol: "SymbolThrown",
|
|
341
|
+
undefined: "UndefinedThrown"
|
|
342
|
+
};
|
|
343
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
347
|
+
return typeof name === "string" && CLASS2.test(name) ? name : "Error";
|
|
348
|
+
} catch {
|
|
349
|
+
return "Error";
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function formatServiceLog2(level, event, fields = {}, error) {
|
|
353
|
+
const safeEvent = TOKEN2.test(event) ? event : "invalid_event";
|
|
354
|
+
const parts = [`[sdk] ${level} ${safeEvent}`];
|
|
355
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
356
|
+
if (!TOKEN2.test(key))
|
|
357
|
+
continue;
|
|
358
|
+
if (typeof value === "boolean")
|
|
359
|
+
parts.push(`${key}=${value}`);
|
|
360
|
+
else if (typeof value === "number" && Number.isFinite(value))
|
|
361
|
+
parts.push(`${key}=${value}`);
|
|
362
|
+
}
|
|
363
|
+
if (error !== void 0)
|
|
364
|
+
parts.push(`error_class=${errorClass2(error)}`);
|
|
365
|
+
return parts.join(" ");
|
|
366
|
+
}
|
|
367
|
+
function writeServiceLog2(level, event, fields, error) {
|
|
368
|
+
const line = formatServiceLog2(level, event, fields, error);
|
|
369
|
+
if (level === "error")
|
|
370
|
+
console.error(line);
|
|
371
|
+
else if (level === "warn")
|
|
372
|
+
console.warn(line);
|
|
373
|
+
else
|
|
374
|
+
console.log(line);
|
|
375
|
+
}
|
|
376
|
+
|
|
289
377
|
// ../connect-core/dist/cadence.js
|
|
290
378
|
var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
291
379
|
var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
|
|
@@ -1468,17 +1556,29 @@ var enc2 = new TextEncoder();
|
|
|
1468
1556
|
var dec2 = new TextDecoder();
|
|
1469
1557
|
|
|
1470
1558
|
// ../connect-clients-core/dist/filesafe/owner-pin.js
|
|
1559
|
+
function rejectionReason(e) {
|
|
1560
|
+
const msg = e instanceof Error ? e.message : "";
|
|
1561
|
+
if (msg.includes("does not chain"))
|
|
1562
|
+
return "not_chaining";
|
|
1563
|
+
if (msg.includes("bad handoff signature"))
|
|
1564
|
+
return "bad_signature";
|
|
1565
|
+
return "other";
|
|
1566
|
+
}
|
|
1471
1567
|
async function resolvePinnedOwner(args2) {
|
|
1472
1568
|
let pin = await args2.store.load(args2.enclaveId);
|
|
1473
1569
|
if (pin === null) {
|
|
1474
1570
|
pin = { g0Cred: args2.observedG0Cred, chain: [] };
|
|
1475
1571
|
}
|
|
1476
1572
|
let chain = pin.chain;
|
|
1477
|
-
for (
|
|
1573
|
+
for (let i = 0; i < args2.relayedLinks.length; i++) {
|
|
1478
1574
|
const tail = currentOwnerCred(pin.g0Cred, chain);
|
|
1479
1575
|
try {
|
|
1480
|
-
chain = await extendOwnerChain(chain,
|
|
1481
|
-
} catch {
|
|
1576
|
+
chain = await extendOwnerChain(chain, args2.relayedLinks[i], tail);
|
|
1577
|
+
} catch (e) {
|
|
1578
|
+
try {
|
|
1579
|
+
args2.onRejectedLink?.({ index: i, reason: rejectionReason(e) });
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1482
1582
|
}
|
|
1483
1583
|
}
|
|
1484
1584
|
const next = { g0Cred: pin.g0Cred, chain };
|
|
@@ -1499,7 +1599,7 @@ var SoftwareKeyCustody = class _SoftwareKeyCustody {
|
|
|
1499
1599
|
this.priv = priv;
|
|
1500
1600
|
this.pub = pub;
|
|
1501
1601
|
if (!quiet) {
|
|
1502
|
-
|
|
1602
|
+
writeServiceLog2("warn", "custody.software_key_unattested");
|
|
1503
1603
|
}
|
|
1504
1604
|
}
|
|
1505
1605
|
static generate(algo) {
|
|
@@ -1609,6 +1709,56 @@ var MalformedGroupRosterError = class extends ConnectSdkError {
|
|
|
1609
1709
|
this.receivedType = receivedType;
|
|
1610
1710
|
}
|
|
1611
1711
|
};
|
|
1712
|
+
var MlsBackendHttpError = class extends ConnectSdkError {
|
|
1713
|
+
status;
|
|
1714
|
+
code;
|
|
1715
|
+
reason;
|
|
1716
|
+
/**
|
|
1717
|
+
* `code` (C2 T0): the backend's error code parsed from the response body
|
|
1718
|
+
* (`{"detail": "x"}`, `{"detail": {"error": "x"}}` or `{"error": "x"}`),
|
|
1719
|
+
* when there is one. The distribute outcome classifier (C2 T3) reads it
|
|
1720
|
+
* against `DISTRIBUTE_DEFINITE_CODES`; absent means "no code", which is
|
|
1721
|
+
* ambiguous by construction. It never enters the message, so the message
|
|
1722
|
+
* stays byte-identical to the pre-C2 one.
|
|
1723
|
+
*
|
|
1724
|
+
* `reason` (C2 T3): the 409 `EPOCH_CONFLICT` body's `reason`
|
|
1725
|
+
* (`stale_epoch`, `epoch_not_monotonic`, `superseded`) when present. The
|
|
1726
|
+
* add and remove sites read it to route a `superseded` refusal away from
|
|
1727
|
+
* the drain-and-retry path (LOW-2). Never in the message either.
|
|
1728
|
+
*/
|
|
1729
|
+
constructor(path, status, code, reason) {
|
|
1730
|
+
super(`${path} failed: ${status}`);
|
|
1731
|
+
this.status = status;
|
|
1732
|
+
this.code = code;
|
|
1733
|
+
this.reason = reason;
|
|
1734
|
+
}
|
|
1735
|
+
};
|
|
1736
|
+
var PendingCommitUnresolvedError = class extends ConnectSdkError {
|
|
1737
|
+
channelId;
|
|
1738
|
+
constructor(channelId) {
|
|
1739
|
+
super("mls.pending_commit.unresolved: a previous commit on this conversation is still being confirmed; retry later");
|
|
1740
|
+
this.channelId = channelId;
|
|
1741
|
+
this.name = "PendingCommitUnresolvedError";
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
var GroupSupersededError = class extends ConnectSdkError {
|
|
1745
|
+
channelId;
|
|
1746
|
+
groupId;
|
|
1747
|
+
constructor(channelId, groupId) {
|
|
1748
|
+
super("mls.group_superseded: the conversation's group was replaced by an owner reset; the change was not applied");
|
|
1749
|
+
this.channelId = channelId;
|
|
1750
|
+
this.groupId = groupId;
|
|
1751
|
+
this.name = "GroupSupersededError";
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
var AttestedOrphanRefusedError = class extends ConnectSdkError {
|
|
1755
|
+
channelId;
|
|
1756
|
+
constructor(channelId) {
|
|
1757
|
+
super("mls.attested_orphan_refused: the conversation's reset did not complete; it needs the owner to reset again");
|
|
1758
|
+
this.channelId = channelId;
|
|
1759
|
+
this.name = "AttestedOrphanRefusedError";
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1612
1762
|
|
|
1613
1763
|
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
1614
1764
|
async function buildOutboundEnvelope(p) {
|
|
@@ -1735,6 +1885,94 @@ function leafIndicesForKeyPackage(mgr, kp) {
|
|
|
1735
1885
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1736
1886
|
import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
1737
1887
|
|
|
1888
|
+
// ../connect-sdk-ts/dist/mls/store.js
|
|
1889
|
+
function assertPendingCommitShape(rec) {
|
|
1890
|
+
if (rec.commitEpoch !== rec.request.commit_epoch) {
|
|
1891
|
+
throw new Error("pending commit: commitEpoch does not equal request.commit_epoch");
|
|
1892
|
+
}
|
|
1893
|
+
if (rec.op === "bootstrap" && rec.prior !== null) {
|
|
1894
|
+
throw new Error("pending commit: a bootstrap record must have prior === null");
|
|
1895
|
+
}
|
|
1896
|
+
if (rec.op === "add" || rec.op === "remove") {
|
|
1897
|
+
if (rec.prior === null) {
|
|
1898
|
+
throw new Error(`pending commit: an ${rec.op} record must have a prior MAIN`);
|
|
1899
|
+
}
|
|
1900
|
+
if (rec.groupId !== rec.prior.groupId) {
|
|
1901
|
+
throw new Error(`pending commit: an ${rec.op} record's groupId must equal prior.groupId`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
function isPendingReservation(rec) {
|
|
1906
|
+
return rec.request.commit === void 0;
|
|
1907
|
+
}
|
|
1908
|
+
function pendingReservation(p) {
|
|
1909
|
+
return {
|
|
1910
|
+
groupId: p.groupId,
|
|
1911
|
+
op: "supersede",
|
|
1912
|
+
request: { device_id: p.deviceId, commit_epoch: 0 },
|
|
1913
|
+
state: p.placeholder,
|
|
1914
|
+
commitEpoch: 0,
|
|
1915
|
+
prior: p.prior,
|
|
1916
|
+
createdAt: p.createdAt
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
function pendingCommitKey(rec) {
|
|
1920
|
+
return { groupId: rec.groupId, commitEpoch: rec.commitEpoch, commit: rec.request.commit ?? "" };
|
|
1921
|
+
}
|
|
1922
|
+
function matchesPendingCommitKey(rec, k) {
|
|
1923
|
+
return rec.groupId === k.groupId && rec.commitEpoch === k.commitEpoch && (rec.request.commit ?? "") === k.commit;
|
|
1924
|
+
}
|
|
1925
|
+
function sameMainState(a, b) {
|
|
1926
|
+
if (!a || !b)
|
|
1927
|
+
return !a && !b;
|
|
1928
|
+
return a.groupState === b.groupState && a.ciphersuite === b.ciphersuite;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ../connect-sdk-ts/dist/mls/distribute-outcome.js
|
|
1932
|
+
var CODES = [
|
|
1933
|
+
{ status: 409, code: "EPOCH_CONFLICT" },
|
|
1934
|
+
{ status: 409, code: "welcome_target_not_approved_member" },
|
|
1935
|
+
{ status: 403, code: "not_group_committer" },
|
|
1936
|
+
{ status: 403, code: "not_authorized_to_remove" },
|
|
1937
|
+
{ status: 403, code: "removal_would_empty_group" },
|
|
1938
|
+
{ status: 403, code: "removal_would_drop_last_committer" },
|
|
1939
|
+
{ status: 422, code: "device_identity_mismatch" },
|
|
1940
|
+
{ status: 422, code: "no_target_devices" },
|
|
1941
|
+
{ status: 400, code: "invalid_commit_hex" },
|
|
1942
|
+
{ status: 400, code: "expected_epoch_required" },
|
|
1943
|
+
{ status: 400, code: "removed_identity_id required" },
|
|
1944
|
+
{ status: 400, code: "commit required" },
|
|
1945
|
+
{ status: 404, code: "not_connect_group" },
|
|
1946
|
+
{ status: 404, code: "group_missing" },
|
|
1947
|
+
{ status: 403, code: "agent_paused" },
|
|
1948
|
+
{ status: 403, code: "forbidden" },
|
|
1949
|
+
{ status: 400, code: "mls.stale_epoch" }
|
|
1950
|
+
];
|
|
1951
|
+
var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
|
|
1952
|
+
var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
|
|
1953
|
+
function parseErrorCode(body) {
|
|
1954
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
1955
|
+
return void 0;
|
|
1956
|
+
const b = body;
|
|
1957
|
+
if (typeof b.detail === "string")
|
|
1958
|
+
return b.detail;
|
|
1959
|
+
if (b.detail !== null && typeof b.detail === "object" && !Array.isArray(b.detail)) {
|
|
1960
|
+
const inner = b.detail.error;
|
|
1961
|
+
if (typeof inner === "string")
|
|
1962
|
+
return inner;
|
|
1963
|
+
}
|
|
1964
|
+
if (typeof b.error === "string")
|
|
1965
|
+
return b.error;
|
|
1966
|
+
return void 0;
|
|
1967
|
+
}
|
|
1968
|
+
function classifyDistributeError(err) {
|
|
1969
|
+
if (!(err instanceof MlsBackendHttpError))
|
|
1970
|
+
return "ambiguous";
|
|
1971
|
+
if (typeof err.code !== "string")
|
|
1972
|
+
return "ambiguous";
|
|
1973
|
+
return DEFINITE_KEYS.has(`${err.status} ${err.code}`) ? "definite" : "ambiguous";
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1738
1976
|
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
1739
1977
|
var MlsOperationQueue = class {
|
|
1740
1978
|
chains = /* @__PURE__ */ new Map();
|
|
@@ -2557,9 +2795,52 @@ function getSignaturePublicKeyFromLeafIndex(ratchetTree, leafIndex) {
|
|
|
2557
2795
|
}
|
|
2558
2796
|
|
|
2559
2797
|
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
2798
|
+
var PENDING_INLINE_RESENDS = 2;
|
|
2799
|
+
var DEFAULT_PENDING_RESEND_DELAYS_MS = [250, 1e3];
|
|
2800
|
+
var PENDING_UNRESOLVED_LOG_INTERVAL_MS = 6e4;
|
|
2801
|
+
var PENDING_AUTO_RESOLVE_INTERVAL_MS = 6e4;
|
|
2802
|
+
var PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS = 6e4;
|
|
2803
|
+
var PENDING_LEFT_ESCALATION_PULLS = 20;
|
|
2804
|
+
var PENDING_LEFT_ESCALATION_MS = 10 * 6e4;
|
|
2805
|
+
var PendingRowEscalatedError = class extends Error {
|
|
2806
|
+
constructor() {
|
|
2807
|
+
super("mls.pending_commit.left_row_escalated");
|
|
2808
|
+
this.name = "PendingRowEscalatedError";
|
|
2809
|
+
}
|
|
2810
|
+
};
|
|
2811
|
+
function sleepMs(ms) {
|
|
2812
|
+
return ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
|
|
2813
|
+
}
|
|
2814
|
+
function tagOf(mgr) {
|
|
2815
|
+
return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
2816
|
+
}
|
|
2817
|
+
function tagOfState(state) {
|
|
2818
|
+
const m = new MLSGroupManager2(state.ciphersuite);
|
|
2819
|
+
m.importState(state);
|
|
2820
|
+
return tagOf(m);
|
|
2821
|
+
}
|
|
2822
|
+
function sameMainTag(a, b) {
|
|
2823
|
+
if (a === null || b === null)
|
|
2824
|
+
return a === b;
|
|
2825
|
+
return a.groupId === b.groupId && a.epoch === b.epoch;
|
|
2826
|
+
}
|
|
2827
|
+
var RESERVATION_PLACEHOLDER_STATE = {
|
|
2828
|
+
groupState: "",
|
|
2829
|
+
ciphersuite: "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519"
|
|
2830
|
+
};
|
|
2831
|
+
function isDefiniteReinitRefusal(e) {
|
|
2832
|
+
return e instanceof MlsBackendHttpError && e.status >= 400 && e.status < 500;
|
|
2833
|
+
}
|
|
2834
|
+
function isSupersededRefusal(e) {
|
|
2835
|
+
return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && e.reason === "superseded";
|
|
2836
|
+
}
|
|
2837
|
+
function isEpochConflict(e) {
|
|
2838
|
+
return e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT" && !isSupersededRefusal(e);
|
|
2839
|
+
}
|
|
2560
2840
|
var LABEL = "agentvault-connect-v1";
|
|
2561
2841
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
2562
2842
|
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
2843
|
+
var DELIVERY_PULL_PAGE = 100;
|
|
2563
2844
|
var BootstrapRaceLostError = class extends Error {
|
|
2564
2845
|
constructor() {
|
|
2565
2846
|
super("bootstrap race lost: another device created the group; join instead");
|
|
@@ -2584,6 +2865,16 @@ var AmbiguousOwnLeafError = class extends Error {
|
|
|
2584
2865
|
this.name = "AmbiguousOwnLeafError";
|
|
2585
2866
|
}
|
|
2586
2867
|
};
|
|
2868
|
+
var OwnLeafRemoveRefusedError = class extends Error {
|
|
2869
|
+
op;
|
|
2870
|
+
reason;
|
|
2871
|
+
constructor(op, reason) {
|
|
2872
|
+
super(`${op}: refusing a Remove that would or could evict this committer's own leaf (${reason})`);
|
|
2873
|
+
this.op = op;
|
|
2874
|
+
this.reason = reason;
|
|
2875
|
+
this.name = "OwnLeafRemoveRefusedError";
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2587
2878
|
var HealAddAfterRemoveError = class extends Error {
|
|
2588
2879
|
constructor(cause) {
|
|
2589
2880
|
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
@@ -2715,8 +3006,67 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2715
3006
|
* past-epoch {@link permanentlySkipped} drain. Read via
|
|
2716
3007
|
* {@link retiredDrainedCount}. */
|
|
2717
3008
|
retiredDrained = 0;
|
|
3009
|
+
/**
|
|
3010
|
+
* C1 (P0 containment): CUSTODY of every application row this provider has
|
|
3011
|
+
* decrypted, keyed by queue_id, until the server shows the row gone.
|
|
3012
|
+
*
|
|
3013
|
+
* Why: a row's message key comes from the LIVE exporter secret only, so once
|
|
3014
|
+
* any drain applies commit N->N+1 the first successful decrypt of an epoch-N
|
|
3015
|
+
* row is the ONLY one there will ever be. Before C1, internal drains
|
|
3016
|
+
* (reconcileForSend, joinAndReconcile, the committer 409 drain) decrypted such
|
|
3017
|
+
* rows, discarded the plaintext and applied the commit; the redelivered row
|
|
3018
|
+
* then failed AEAD, was classified permanentlySkipped and acked: silent loss.
|
|
3019
|
+
* A CONCURRENT receiveOnce that pulled the row before another drain applied
|
|
3020
|
+
* the commit lost it the same way (C1 review, Important #1).
|
|
3021
|
+
*
|
|
3022
|
+
* Rule: an application row whose queue_id is in custody is NEVER decrypted or
|
|
3023
|
+
* classified again, by any call. {@link receiveOnce} returns the stored entry
|
|
3024
|
+
* for it, un-acked, in the row's seq position, on every pull that contains the
|
|
3025
|
+
* row, so the caller's ack stays the only ack. An internal drain's discard is
|
|
3026
|
+
* therefore harmless (the next receiveOnce serves the entry), and a caller
|
|
3027
|
+
* that drops an entry without acking is offered it again on the next pull,
|
|
3028
|
+
* exactly like a row that still decrypts. Eviction happens only when an
|
|
3029
|
+
* own-device pull SHORTER than {@link DELIVERY_PULL_PAGE} does not contain the
|
|
3030
|
+
* row (acked, expired, dead-lettered or fenced; it cannot come back).
|
|
3031
|
+
*
|
|
3032
|
+
* Limit: memory only. A process restart empties custody, and an un-acked row
|
|
3033
|
+
* whose epoch has moved on then fails AEAD and is acked as permanentlySkipped
|
|
3034
|
+
* (C1 report: durable-sink follow-up).
|
|
3035
|
+
*/
|
|
3036
|
+
custody = /* @__PURE__ */ new Map();
|
|
3037
|
+
/*
|
|
3038
|
+
* C2 DEVIATION FROM SPEC §4.2's IN-MEMORY PENDING SET, deliberate: there is
|
|
3039
|
+
* none. Every C2 decision (the send fence, the receive resolver, the
|
|
3040
|
+
* lock-entry resolve, promotion) reads the CHANNEL's DURABLE record, one
|
|
3041
|
+
* store read each. A per-process set can be wrong in BOTH directions across
|
|
3042
|
+
* tabs and processes (MEDIUM-1): it misses a record another tab wrote, and
|
|
3043
|
+
* it keeps fencing a channel another tab already resolved. The durable read
|
|
3044
|
+
* is right in both, and the plan already accepted one store read per send.
|
|
3045
|
+
* Nothing reads `listPendingCommits()` either, so one unreadable record
|
|
3046
|
+
* (T2's SQLite `MlsStoreSealError` refuses the whole list) cannot fence
|
|
3047
|
+
* every channel: it fences only its own, whose per-channel read throws.
|
|
3048
|
+
*/
|
|
3049
|
+
/** Memo for the provider-start re-run of owed follow-ups (MEDIUM-3). */
|
|
3050
|
+
owedRerun = null;
|
|
3051
|
+
/** Last `mls.pending_commit.unresolved` log per channel (rate limit). */
|
|
3052
|
+
unresolvedLoggedAt = /* @__PURE__ */ new Map();
|
|
3053
|
+
/** Channels whose legacy (pre-C2) staging slot was already discarded by
|
|
3054
|
+
* this process (spec §4.5 "Legacy staging residue"). */
|
|
3055
|
+
legacyStagingChecked = /* @__PURE__ */ new Set();
|
|
3056
|
+
/** C2 T3c (H1): last automatic resolve per channel (rate limit). */
|
|
3057
|
+
autoResolveAt = /* @__PURE__ */ new Map();
|
|
3058
|
+
/** C2 T3c (H1): automatic resolves in flight, awaited by drainInFlight. */
|
|
3059
|
+
backgroundResolves = /* @__PURE__ */ new Set();
|
|
3060
|
+
/** C2 T3c (H1): per channel, the run of consecutive pulls that left rows,
|
|
3061
|
+
* and when it started. Cleared by a pull that leaves none. */
|
|
3062
|
+
leftStreak = /* @__PURE__ */ new Map();
|
|
3063
|
+
/** C2 T3c (H2): last receive_resolve_failed log per channel. */
|
|
3064
|
+
receiveFailLoggedAt = /* @__PURE__ */ new Map();
|
|
2718
3065
|
constructor(o) {
|
|
2719
3066
|
this.o = o;
|
|
3067
|
+
if (typeof o.onPendingCommitResolved !== "function") {
|
|
3068
|
+
throw new TypeError("MlsGroupKeyProvider: onPendingCommitResolved is required and must be a function");
|
|
3069
|
+
}
|
|
2720
3070
|
this.suite = o.suite ?? "xchacha20poly1305";
|
|
2721
3071
|
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
2722
3072
|
this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
|
|
@@ -2847,7 +3197,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
2847
3197
|
timer.unref?.();
|
|
2848
3198
|
});
|
|
2849
3199
|
const drain = (async () => {
|
|
2850
|
-
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
|
|
3200
|
+
while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0 || this.backgroundResolves.size > 0) {
|
|
3201
|
+
await Promise.allSettled([...this.backgroundResolves]);
|
|
2851
3202
|
await this.locks.idle();
|
|
2852
3203
|
await this.publishLock.idle();
|
|
2853
3204
|
}
|
|
@@ -3025,7 +3376,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3025
3376
|
orderedDeviceIds
|
|
3026
3377
|
});
|
|
3027
3378
|
} else if (outcome !== "committed" && g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
3028
|
-
|
|
3379
|
+
writeServiceLog2("warn", "guest.swap_skipped_add_noop");
|
|
3029
3380
|
}
|
|
3030
3381
|
return outcome;
|
|
3031
3382
|
}
|
|
@@ -3093,17 +3444,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3093
3444
|
try {
|
|
3094
3445
|
await this.locks.run(channelId, async () => {
|
|
3095
3446
|
const mgr = await this.manager(channelId);
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3447
|
+
try {
|
|
3448
|
+
for (const c of commits) {
|
|
3449
|
+
if (c.epoch <= Number(mgr.epoch))
|
|
3450
|
+
continue;
|
|
3451
|
+
await mgr.decrypt(hexToBytes(c.payload));
|
|
3452
|
+
applied++;
|
|
3453
|
+
}
|
|
3454
|
+
if (applied > 0)
|
|
3455
|
+
await this.saveMain(channelId, mgr);
|
|
3456
|
+
} catch (e) {
|
|
3457
|
+
if (applied > 0)
|
|
3458
|
+
this.evictIfCached(channelId, mgr);
|
|
3459
|
+
throw e;
|
|
3101
3460
|
}
|
|
3102
|
-
if (applied > 0)
|
|
3103
|
-
await this.o.store.save(channelId, mgr.exportState());
|
|
3104
3461
|
});
|
|
3105
3462
|
} catch (e) {
|
|
3106
|
-
|
|
3463
|
+
writeServiceLog2("warn", "resync.replay_commit_failed", { applied }, e);
|
|
3107
3464
|
return false;
|
|
3108
3465
|
}
|
|
3109
3466
|
if (applied > 0) {
|
|
@@ -3163,6 +3520,494 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3163
3520
|
* delete (a re-delete is a no-op). On success the manager stays cached as
|
|
3164
3521
|
* before — no eviction.
|
|
3165
3522
|
*/
|
|
3523
|
+
/**
|
|
3524
|
+
* Phase 3 C4: persist a manager that has ALREADY ADVANCED in memory by applying
|
|
3525
|
+
* a commit the SERVER can hand us again (a delivery row we have not acked, a
|
|
3526
|
+
* /sync replay). If the save throws, evict the cached manager before
|
|
3527
|
+
* rethrowing, so the next access reloads the persisted (pre-commit) state and
|
|
3528
|
+
* re-applies the still-queued commit.
|
|
3529
|
+
*
|
|
3530
|
+
* Why evict rather than keep the advanced cache: with the cache ahead of disk,
|
|
3531
|
+
* the redelivered commit no longer applies to it, the classifier sees a commit
|
|
3532
|
+
* at or below the live epoch and ACKS it as dead, and the disk keeps the old
|
|
3533
|
+
* epoch. The next restart loads that epoch while the server has moved on: a
|
|
3534
|
+
* silent fork (mls.evict-on-save-failure.test.ts).
|
|
3535
|
+
*
|
|
3536
|
+
* ⛔ ONLY for server-redeliverable commits. A committer's OWN commit that the
|
|
3537
|
+
* server already accepted cannot be re-applied from the wire (a member cannot
|
|
3538
|
+
* process its own commit), so evicting there throws away the only copy of the
|
|
3539
|
+
* true state. The committer paths keep their own handling.
|
|
3540
|
+
*
|
|
3541
|
+
* `MLSGroupManager.decrypt` assigns its new state only after processing
|
|
3542
|
+
* succeeds, so a decrypt that THROWS leaves the manager unchanged and needs no
|
|
3543
|
+
* eviction; only the advanced-but-unsaved case does.
|
|
3544
|
+
*/
|
|
3545
|
+
async saveAdvancedOrEvict(channelId, mgr) {
|
|
3546
|
+
try {
|
|
3547
|
+
await this.saveMain(channelId, mgr);
|
|
3548
|
+
} catch (e) {
|
|
3549
|
+
this.evictIfCached(channelId, mgr);
|
|
3550
|
+
throw e;
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
/**
|
|
3554
|
+
* C2 (spec §4.2, MEDIUM-1): the ONE writer of MAIN outside promotion. Every
|
|
3555
|
+
* MAIN save in this file goes through here (pinned by
|
|
3556
|
+
* `save-main-call-sites.arch.test.ts`), and here goes through the store's
|
|
3557
|
+
* `saveMainGuarded`, which refuses ATOMICALLY when the channel holds a
|
|
3558
|
+
* pending-commit record whose `prior` is not this manager's (group, epoch).
|
|
3559
|
+
* So while a commit's outcome is unknown, MAIN can move only within the
|
|
3560
|
+
* epoch the commit was built on, whether the write comes from this tab, a
|
|
3561
|
+
* second tab, or another process.
|
|
3562
|
+
*
|
|
3563
|
+
* On ANY throw the cached manager is evicted if it is still `mgr`, so the
|
|
3564
|
+
* cache never runs ahead of MAIN: a refusal (`PendingCommitUnresolvedError`)
|
|
3565
|
+
* and a raw store failure (SQLite `SQLITE_BUSY` after its busy timeout, an
|
|
3566
|
+
* IndexedDB abort) are the same fact here, "MAIN did not move". The error
|
|
3567
|
+
* propagates: a receive caller leaves the row un-acked (uncounted, for a
|
|
3568
|
+
* refusal), a committer caller fails.
|
|
3569
|
+
*/
|
|
3570
|
+
async saveMain(channelId, mgr) {
|
|
3571
|
+
try {
|
|
3572
|
+
await this.o.store.saveMainGuarded(channelId, mgr.exportState(), tagOf(mgr));
|
|
3573
|
+
} catch (e) {
|
|
3574
|
+
this.evictIfCached(channelId, mgr);
|
|
3575
|
+
throw e;
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
// ── C2: pending-commit records ────────────────────────────────────────────
|
|
3579
|
+
//
|
|
3580
|
+
// A commit is written to a durable record BEFORE /distribute, resolved only
|
|
3581
|
+
// by proof (a 2xx, a coded definite rejection, or a receive-side proof), and
|
|
3582
|
+
// RESENT byte-for-byte, never rebuilt. See spec §4 and the plan's Task T3.
|
|
3583
|
+
//
|
|
3584
|
+
// ⛔ IRREVERSIBILITY. Nothing below builds a commit, adds a member or removes
|
|
3585
|
+
// one. Recovery only resends `rec.request`, whose bytes were built, and
|
|
3586
|
+
// checked by the admission rule (duplicate-leaf) and the own-leaf guard
|
|
3587
|
+
// (C3), at the site that created the record, against the tree they apply
|
|
3588
|
+
// to. A resend that lands can land only on that tree: the server's
|
|
3589
|
+
// expected_epoch compare-and-set (one commit per (group, epoch), H4/T1a) and
|
|
3590
|
+
// the superseded refusal (§3.2) reject it anywhere else.
|
|
3591
|
+
nowMs() {
|
|
3592
|
+
return this.o.now?.() ?? Date.now();
|
|
3593
|
+
}
|
|
3594
|
+
/** The (group, epoch) of the DURABLE MAIN, or null when there is none. Read
|
|
3595
|
+
* from the store, not the cache: promotion compares against what is at
|
|
3596
|
+
* rest. */
|
|
3597
|
+
async durableMainTag(channelId) {
|
|
3598
|
+
const saved = await this.o.store.load(channelId);
|
|
3599
|
+
return saved ? tagOfState(saved) : null;
|
|
3600
|
+
}
|
|
3601
|
+
/** Start the provider-start re-run of owed follow-ups once per process,
|
|
3602
|
+
* detached (see {@link resumePendingCommits}). A failure is logged and the
|
|
3603
|
+
* memo clears, so the next call retries it. Synchronous: callers never wait
|
|
3604
|
+
* on the hook. */
|
|
3605
|
+
kickOwedRerun() {
|
|
3606
|
+
if (this.owedRerun)
|
|
3607
|
+
return;
|
|
3608
|
+
void this.resumePendingCommits().catch((e) => writeServiceLog2("warn", "mls.pending_commit.owed_rerun_failed", {}, e));
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* C2 (MEDIUM-3): provider-start work, idempotent per process (a rejected
|
|
3612
|
+
* run clears its memo). Re-runs every owed follow-up (the post-commit work
|
|
3613
|
+
* of a commit that landed and was promoted, whose hook had not yet returned)
|
|
3614
|
+
* through `onPendingCommitResolved` with `via: "resolver"`, clearing each
|
|
3615
|
+
* slot only after its hook returns. The provider starts this itself,
|
|
3616
|
+
* detached, on the first send-fence check or receive pass; a host may call
|
|
3617
|
+
* it at startup to run the owed work before anything else. The hook is
|
|
3618
|
+
* at-least-once, hence required to be idempotent.
|
|
3619
|
+
*/
|
|
3620
|
+
async resumePendingCommits() {
|
|
3621
|
+
this.owedRerun ??= (async () => {
|
|
3622
|
+
for (const { channelId, owed } of await this.o.store.listOwedFollowUps()) {
|
|
3623
|
+
await this.runFollowUpHook(channelId, owed.op, owed.followUp, "resolver", owed.createdAt);
|
|
3624
|
+
}
|
|
3625
|
+
})().catch((e) => {
|
|
3626
|
+
this.owedRerun = null;
|
|
3627
|
+
throw e;
|
|
3628
|
+
});
|
|
3629
|
+
await this.owedRerun;
|
|
3630
|
+
}
|
|
3631
|
+
/** Run the hook for a landed commit that owes a follow-up, then clear the
|
|
3632
|
+
* owed slot. A throwing hook KEEPS the slot (re-run at the next provider
|
|
3633
|
+
* start) and is logged, never propagated: the commit itself landed and
|
|
3634
|
+
* MAIN is promoted, so failing the op would misreport it.
|
|
3635
|
+
*
|
|
3636
|
+
* C2 T3c (M1): the clear is keyed on the slot's `createdAt`, so it removes
|
|
3637
|
+
* only the slot whose hook just ran. The provider-start re-run is detached
|
|
3638
|
+
* and holds no channel lock; a newer commit's slot written meanwhile (it
|
|
3639
|
+
* overwrites the channel's slot) is kept for its own hook. */
|
|
3640
|
+
async runFollowUpHook(channelId, op, followUp, via, slotCreatedAt) {
|
|
3641
|
+
try {
|
|
3642
|
+
await this.o.onPendingCommitResolved(channelId, { op, outcome: "landed", followUp, via });
|
|
3643
|
+
} catch (e) {
|
|
3644
|
+
writeServiceLog2("warn", "mls.pending_commit.hook_failed", {}, e);
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
await this.o.store.clearOwedFollowUp(channelId, slotCreatedAt);
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* C2 (coordinator ruling on T2, MEDIUM-3): run and clear any follow-up still
|
|
3651
|
+
* OWED on this channel, and THROW if that fails. Called before a commit that
|
|
3652
|
+
* will itself carry a follow-up is built: every store's `commitPending`
|
|
3653
|
+
* overwrites the channel's owed slot, so promoting the new commit over an
|
|
3654
|
+
* undrained slot would silently drop the older owed work. Fail closed: a
|
|
3655
|
+
* hook that throws (or an unreadable slot) refuses the new commit and keeps
|
|
3656
|
+
* the old slot. Caller holds the channel lock.
|
|
3657
|
+
*
|
|
3658
|
+
* C2 T3c (M2): reads ONLY this channel's slot (`getOwedFollowUp`), so an
|
|
3659
|
+
* unreadable slot of another channel cannot refuse this channel's commit.
|
|
3660
|
+
* With `abandon` (the owner's explicit Reset), an UNREADABLE slot of this
|
|
3661
|
+
* channel is discarded (logged `mls.pending_commit.owed_abandoned`) rather
|
|
3662
|
+
* than blocking the owner's one way out; a hook that throws still refuses.
|
|
3663
|
+
*/
|
|
3664
|
+
async drainOwedFollowUpUnlocked(channelId, abandon = false) {
|
|
3665
|
+
let owed;
|
|
3666
|
+
try {
|
|
3667
|
+
owed = await this.o.store.getOwedFollowUp(channelId);
|
|
3668
|
+
} catch (e) {
|
|
3669
|
+
if (!abandon)
|
|
3670
|
+
throw e;
|
|
3671
|
+
writeServiceLog2("warn", "mls.pending_commit.owed_abandoned", {}, e);
|
|
3672
|
+
await this.o.store.clearOwedFollowUp(channelId);
|
|
3673
|
+
return;
|
|
3674
|
+
}
|
|
3675
|
+
if (!owed)
|
|
3676
|
+
return;
|
|
3677
|
+
await this.o.onPendingCommitResolved(channelId, {
|
|
3678
|
+
op: owed.op,
|
|
3679
|
+
outcome: "landed",
|
|
3680
|
+
followUp: owed.followUp,
|
|
3681
|
+
via: "resolver"
|
|
3682
|
+
});
|
|
3683
|
+
await this.o.store.clearOwedFollowUp(channelId, owed.createdAt);
|
|
3684
|
+
}
|
|
3685
|
+
/** Tell the host how a record resolved (no follow-up owed). A throwing hook
|
|
3686
|
+
* is logged and swallowed for the same reason as above. */
|
|
3687
|
+
async notifyPendingResolved(channelId, ev) {
|
|
3688
|
+
try {
|
|
3689
|
+
await this.o.onPendingCommitResolved(channelId, ev);
|
|
3690
|
+
} catch (e) {
|
|
3691
|
+
writeServiceLog2("warn", "mls.pending_commit.hook_failed", {}, e);
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
/** Persist the record BEFORE `/distribute`. A throw here propagates and the
|
|
3695
|
+
* commit is never sent (spec §4.1).
|
|
3696
|
+
*
|
|
3697
|
+
* C2 T3c: CREATE-ONLY, atomically in the store. This instance resolved the
|
|
3698
|
+
* channel's record at lock entry, but another tab or process holds its own
|
|
3699
|
+
* lock and may have staged a commit since. Overwriting that record would
|
|
3700
|
+
* orphan a commit that may land (the other instance could then never
|
|
3701
|
+
* promote it). So a present record refuses this commit with
|
|
3702
|
+
* `PendingCommitUnresolvedError` before anything is sent. */
|
|
3703
|
+
async stagePendingCommit(channelId, rec, replacing) {
|
|
3704
|
+
const opts = replacing ? { replacing } : { onlyIfAbsent: true };
|
|
3705
|
+
let wrote;
|
|
3706
|
+
try {
|
|
3707
|
+
wrote = await this.o.store.savePendingCommit(channelId, rec, opts);
|
|
3708
|
+
} catch (e) {
|
|
3709
|
+
if (replacing)
|
|
3710
|
+
await this.o.store.clearPendingCommit(channelId, replacing).catch(() => false);
|
|
3711
|
+
throw e;
|
|
3712
|
+
}
|
|
3713
|
+
if (!wrote)
|
|
3714
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3715
|
+
}
|
|
3716
|
+
/** Send `rec.request` byte-for-byte, then up to `resends` more times while
|
|
3717
|
+
* the outcome stays ambiguous (spec §4.3). Never rebuilds the commit. */
|
|
3718
|
+
async sendPendingCommit(rec, resends) {
|
|
3719
|
+
const delays = this.o.pendingResendDelaysMs ?? DEFAULT_PENDING_RESEND_DELAYS_MS;
|
|
3720
|
+
let last;
|
|
3721
|
+
for (let attempt = 0; attempt <= resends; attempt++) {
|
|
3722
|
+
if (attempt > 0)
|
|
3723
|
+
await sleepMs(delays[Math.min(attempt - 1, delays.length - 1)] ?? 0);
|
|
3724
|
+
try {
|
|
3725
|
+
await this.o.backend.distribute(rec.groupId, rec.request);
|
|
3726
|
+
return { kind: "landed" };
|
|
3727
|
+
} catch (e) {
|
|
3728
|
+
if (classifyDistributeError(e) === "definite")
|
|
3729
|
+
return { kind: "definite", error: e };
|
|
3730
|
+
last = e;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
return { kind: "ambiguous", error: last };
|
|
3734
|
+
}
|
|
3735
|
+
/**
|
|
3736
|
+
* Apply a send outcome to the channel's record. Called under the channel lock.
|
|
3737
|
+
* - landed: custody of the epoch-N rows (C1b; skipped when there is no
|
|
3738
|
+
* prior MAIN), then conditional promotion.
|
|
3739
|
+
* - definite: the commit did not land. Clear the record, evict the cache,
|
|
3740
|
+
* tell the hook `not_landed`.
|
|
3741
|
+
* - ambiguous: KEEP the record, evict the cache (MAIN is still `prior`),
|
|
3742
|
+
* log the unresolved age (rate-limited). Nothing auto-discards on age.
|
|
3743
|
+
*/
|
|
3744
|
+
async settlePendingCommit(channelId, rec, out, via, built) {
|
|
3745
|
+
if (out.kind === "landed") {
|
|
3746
|
+
if (rec.prior !== null)
|
|
3747
|
+
await this.custodyAppRowsBeforeOwnCommit(channelId, rec.prior.epoch);
|
|
3748
|
+
const p = await this.promotePendingUnlocked(channelId, via, rec, built);
|
|
3749
|
+
return p === "none" ? "vanished" : p;
|
|
3750
|
+
}
|
|
3751
|
+
if (out.kind === "definite") {
|
|
3752
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec));
|
|
3753
|
+
this.managers.delete(channelId);
|
|
3754
|
+
await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via });
|
|
3755
|
+
return "not_landed";
|
|
3756
|
+
}
|
|
3757
|
+
this.managers.delete(channelId);
|
|
3758
|
+
this.logPendingUnresolved(channelId, rec);
|
|
3759
|
+
return "ambiguous";
|
|
3760
|
+
}
|
|
3761
|
+
logPendingUnresolved(channelId, rec) {
|
|
3762
|
+
const now = this.nowMs();
|
|
3763
|
+
const last = this.unresolvedLoggedAt.get(channelId);
|
|
3764
|
+
if (last !== void 0 && now - last < PENDING_UNRESOLVED_LOG_INTERVAL_MS)
|
|
3765
|
+
return;
|
|
3766
|
+
this.unresolvedLoggedAt.set(channelId, now);
|
|
3767
|
+
writeServiceLog2("warn", "mls.pending_commit.unresolved", {
|
|
3768
|
+
age_s: Math.max(0, Math.floor((now - rec.createdAt) / 1e3))
|
|
3769
|
+
});
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* C2 (spec §4.2, H2): promote the record `proven` (whose landing this caller
|
|
3773
|
+
* PROVED: a 2xx for its bytes, or a trial decrypt under its state) ONLY if MAIN
|
|
3774
|
+
* is still the state the commit was built on. Caller holds the channel lock
|
|
3775
|
+
* (the receive path calls this inside the lock it already holds: LOW-4, the
|
|
3776
|
+
* queue is not reentrant).
|
|
3777
|
+
*
|
|
3778
|
+
* `prior` is compared by STRICT equality, and `null` means "no MAIN" (ruling
|
|
3779
|
+
* 2026-09-25): MAIN absent → promote; MAIN present at any group or epoch →
|
|
3780
|
+
* the record is stale. Match → `commitPending` (MAIN := rec.state, record
|
|
3781
|
+
* deleted, owed follow-up slot written, one atomic store step), install the
|
|
3782
|
+
* promoted manager, run the hook. Mismatch → discard the record, keep MAIN,
|
|
3783
|
+
* log `mls.pending_commit.stale_prior`: MAIN moved out of band (a v8 store
|
|
3784
|
+
* write after a downgrade, say), and promoting would rewind it.
|
|
3785
|
+
*
|
|
3786
|
+
* ⛔ C2 T3c (security review C1): EVERY store step here names `proven`'s key
|
|
3787
|
+
* (group, commit epoch, commit bytes) and the store checks it INSIDE the
|
|
3788
|
+
* step, together with "MAIN is exactly the state this caller just checked
|
|
3789
|
+
* against `prior`". A store shared by two tabs or processes may by now hold
|
|
3790
|
+
* ANOTHER record (the other instance resolved ours and staged its own, still
|
|
3791
|
+
* in flight). Promoting that record would make MAIN a state no server
|
|
3792
|
+
* accepted (a fork); clearing it would strand a commit that then lands.
|
|
3793
|
+
*
|
|
3794
|
+
* When `proven` is no longer in the store: it was resolved by another
|
|
3795
|
+
* instance. If MAIN is at or past `proven` in its group, that instance
|
|
3796
|
+
* promoted it (`proven` landed, so no foreign commit can hold its epoch):
|
|
3797
|
+
* report `landed` and drop the cache so MAIN reloads. Otherwise `none`.
|
|
3798
|
+
*
|
|
3799
|
+
* A throw from the store propagates with MAIN and the record unchanged (the
|
|
3800
|
+
* store's all-or-nothing contract).
|
|
3801
|
+
*
|
|
3802
|
+
* `built`: the manager an INLINE commit site built the commit with. Its state
|
|
3803
|
+
* IS `rec.state` (exported from it when the record was written, not mutated
|
|
3804
|
+
* since), so it is installed as the cached manager instead of a re-import,
|
|
3805
|
+
* exactly as the pre-C2 sites cached it after their save.
|
|
3806
|
+
*/
|
|
3807
|
+
async promotePendingUnlocked(channelId, via, proven, built) {
|
|
3808
|
+
const key = pendingCommitKey(proven);
|
|
3809
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3810
|
+
const mainState = await this.o.store.load(channelId);
|
|
3811
|
+
const main2 = mainState ? tagOfState(mainState) : null;
|
|
3812
|
+
const matches = proven.prior === null ? main2 === null : main2 !== null && main2.groupId === proven.prior.groupId && main2.epoch === proven.prior.epoch;
|
|
3813
|
+
if (!matches) {
|
|
3814
|
+
if (await this.o.store.clearPendingCommit(channelId, key)) {
|
|
3815
|
+
writeServiceLog2("warn", "mls.pending_commit.stale_prior");
|
|
3816
|
+
await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "stale_prior", via });
|
|
3817
|
+
return "stale_prior";
|
|
3818
|
+
}
|
|
3819
|
+
return this.provenRecordGone(channelId, proven);
|
|
3820
|
+
}
|
|
3821
|
+
const r = await this.o.store.commitPending(channelId, { ...key, main: mainState });
|
|
3822
|
+
if (r === "record_mismatch")
|
|
3823
|
+
return this.provenRecordGone(channelId, proven);
|
|
3824
|
+
if (r === "main_moved")
|
|
3825
|
+
continue;
|
|
3826
|
+
let mgr = built;
|
|
3827
|
+
if (!mgr) {
|
|
3828
|
+
mgr = new MLSGroupManager2(proven.state.ciphersuite);
|
|
3829
|
+
mgr.importState(proven.state);
|
|
3830
|
+
}
|
|
3831
|
+
this.managers.set(channelId, mgr);
|
|
3832
|
+
if (proven.followUp)
|
|
3833
|
+
await this.runFollowUpHook(channelId, proven.op, proven.followUp, via, proven.createdAt);
|
|
3834
|
+
else
|
|
3835
|
+
await this.notifyPendingResolved(channelId, { op: proven.op, outcome: "landed", via });
|
|
3836
|
+
return "landed";
|
|
3837
|
+
}
|
|
3838
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3839
|
+
}
|
|
3840
|
+
/** C2 T3c (C1): the proven record is not in the store. See
|
|
3841
|
+
* {@link promotePendingUnlocked}. */
|
|
3842
|
+
async provenRecordGone(channelId, proven) {
|
|
3843
|
+
const mainState = await this.o.store.load(channelId);
|
|
3844
|
+
const main2 = mainState ? tagOfState(mainState) : null;
|
|
3845
|
+
if (main2 !== null && main2.groupId === proven.groupId && main2.epoch >= proven.commitEpoch) {
|
|
3846
|
+
this.managers.delete(channelId);
|
|
3847
|
+
writeServiceLog2("info", "mls.pending_commit.promoted_elsewhere");
|
|
3848
|
+
return "landed";
|
|
3849
|
+
}
|
|
3850
|
+
return "none";
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* C2 (spec §4.4 "Commit", plan step 3.6): resolve the channel's record at
|
|
3854
|
+
* lock entry, before a committer op, `ensureSession`, a reset or a supersede
|
|
3855
|
+
* builds anything. Resends the stored bytes and settles the outcome with
|
|
3856
|
+
* `via: "resolver"`. Returns `none` when there was no record (after
|
|
3857
|
+
* discarding any legacy pre-C2 staging slot once per channel per process:
|
|
3858
|
+
* MAIN is authoritative, and a staged state with no request cannot be
|
|
3859
|
+
* resent). The CALLER decides what `ambiguous` means; most throw
|
|
3860
|
+
* `PendingCommitUnresolvedError`.
|
|
3861
|
+
*/
|
|
3862
|
+
async resolvePendingCommitUnlocked(channelId, resends = PENDING_INLINE_RESENDS, saw) {
|
|
3863
|
+
const rec = await this.o.store.loadPendingCommit(channelId);
|
|
3864
|
+
saw?.(rec);
|
|
3865
|
+
if (!rec) {
|
|
3866
|
+
if (!this.legacyStagingChecked.has(channelId)) {
|
|
3867
|
+
await this.o.store.discardStaging(channelId);
|
|
3868
|
+
this.legacyStagingChecked.add(channelId);
|
|
3869
|
+
}
|
|
3870
|
+
return "none";
|
|
3871
|
+
}
|
|
3872
|
+
if (isPendingReservation(rec)) {
|
|
3873
|
+
this.logPendingUnresolved(channelId, rec);
|
|
3874
|
+
return "ambiguous";
|
|
3875
|
+
}
|
|
3876
|
+
const out = await this.sendPendingCommit(rec, resends);
|
|
3877
|
+
const r = await this.settlePendingCommit(channelId, rec, out, "resolver");
|
|
3878
|
+
return r === "vanished" ? "none" : r;
|
|
3879
|
+
}
|
|
3880
|
+
/** {@link resolvePendingCommitUnlocked}, then refuse to continue while the
|
|
3881
|
+
* record stays ambiguous. The standard lock-entry guard. */
|
|
3882
|
+
async requirePendingResolvedUnlocked(channelId) {
|
|
3883
|
+
const r = await this.resolvePendingCommitUnlocked(channelId);
|
|
3884
|
+
if (r === "ambiguous")
|
|
3885
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3886
|
+
return r;
|
|
3887
|
+
}
|
|
3888
|
+
/**
|
|
3889
|
+
* C2 (OQ-C2-1 ruling): the owner's explicit abandon, reached ONLY through
|
|
3890
|
+
* `resetSessionSupersede(..., { abandonPending: true })`. Discards the
|
|
3891
|
+
* record without knowing whether its commit landed; the attested reset that
|
|
3892
|
+
* follows is the correct recovery either way. Logs
|
|
3893
|
+
* `mls.pending_commit.abandoned` and tells the hook `abandoned`.
|
|
3894
|
+
*/
|
|
3895
|
+
/**
|
|
3896
|
+
* C2 T3d (MEDIUM-1): `seen` is the record the owner's resolve ACTUALLY saw
|
|
3897
|
+
* and found ambiguous, or "unreadable" when that load threw. A seen record
|
|
3898
|
+
* is cleared KEYED: if another instance meanwhile proved it landed, promoted
|
|
3899
|
+
* it and staged a newer, unproven record, that newer record is not the one
|
|
3900
|
+
* the owner abandoned, and clearing it would strand a commit that may land.
|
|
3901
|
+
* Then nothing is cleared and this returns false (the caller refuses; the
|
|
3902
|
+
* owner retries). Only an UNREADABLE record is cleared unkeyed: nothing can
|
|
3903
|
+
* read its key, and this is the owner's one way out of it.
|
|
3904
|
+
*/
|
|
3905
|
+
async abandonPendingCommitUnlocked(channelId, seen) {
|
|
3906
|
+
if (seen === "unreadable") {
|
|
3907
|
+
writeServiceLog2("warn", "mls.pending_commit.abandon_unreadable");
|
|
3908
|
+
await this.o.store.clearPendingCommit(channelId);
|
|
3909
|
+
this.managers.delete(channelId);
|
|
3910
|
+
writeServiceLog2("warn", "mls.pending_commit.abandoned");
|
|
3911
|
+
return true;
|
|
3912
|
+
}
|
|
3913
|
+
if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(seen))) {
|
|
3914
|
+
writeServiceLog2("warn", "mls.pending_commit.abandon_raced");
|
|
3915
|
+
return false;
|
|
3916
|
+
}
|
|
3917
|
+
this.managers.delete(channelId);
|
|
3918
|
+
writeServiceLog2("warn", "mls.pending_commit.abandoned");
|
|
3919
|
+
await this.notifyPendingResolved(channelId, { op: seen.op, outcome: "abandoned", via: "resolver" });
|
|
3920
|
+
return true;
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* The commit tail shared by the six commit sites (spec §4.1): persist the
|
|
3924
|
+
* record, send, settle inline. Returns `{ landed: true }` after promotion,
|
|
3925
|
+
* or the definite rejection for the site's own existing path. Throws
|
|
3926
|
+
* `PendingCommitUnresolvedError` when the outcome stays ambiguous after the
|
|
3927
|
+
* inline resends (the record is kept and the channel fenced), and a plain
|
|
3928
|
+
* content-free `Error` when promotion found MAIN moved out of band.
|
|
3929
|
+
*/
|
|
3930
|
+
async commitWithPendingRecord(channelId, rec, built, replacing) {
|
|
3931
|
+
await this.stagePendingCommit(channelId, rec, replacing);
|
|
3932
|
+
const out = await this.sendPendingCommit(rec, PENDING_INLINE_RESENDS);
|
|
3933
|
+
const r = await this.settlePendingCommit(channelId, rec, out, "inline", built);
|
|
3934
|
+
if (r === "landed")
|
|
3935
|
+
return { landed: true };
|
|
3936
|
+
if (r === "not_landed")
|
|
3937
|
+
return { landed: false, error: out.error };
|
|
3938
|
+
if (r === "ambiguous")
|
|
3939
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3940
|
+
throw new Error(`mls.pending_commit.${r}: the commit's prior state moved before promotion; not applied locally`);
|
|
3941
|
+
}
|
|
3942
|
+
/** Build the record for a commit about to be sent. `prior` is the MAIN the
|
|
3943
|
+
* commit was built on (null: none). */
|
|
3944
|
+
pendingRecord(op, groupId, request, mgr, prior, followUp) {
|
|
3945
|
+
return {
|
|
3946
|
+
groupId,
|
|
3947
|
+
op,
|
|
3948
|
+
request,
|
|
3949
|
+
state: mgr.exportState(),
|
|
3950
|
+
commitEpoch: Number(mgr.epoch),
|
|
3951
|
+
prior,
|
|
3952
|
+
...followUp ? { followUp } : {},
|
|
3953
|
+
createdAt: this.nowMs()
|
|
3954
|
+
};
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* C2 (spec §4.4 "Send", HIGH-3, MEDIUM-1): the send fence. Throws
|
|
3958
|
+
* `PendingCommitUnresolvedError` while the channel holds a pending record,
|
|
3959
|
+
* so no key is derived for SEND (message, vault KEK seal, 1:1 voice) at an
|
|
3960
|
+
* epoch a landed Remove may already have rotated away from. Re-reads the
|
|
3961
|
+
* DURABLE record on every call, so a record another tab or process wrote is
|
|
3962
|
+
* seen the first time, and a record another tab resolved no longer fences.
|
|
3963
|
+
* Receive never calls this: it derives through {@link exporterKeyUnfenced}.
|
|
3964
|
+
*/
|
|
3965
|
+
async assertNotPending(channelId) {
|
|
3966
|
+
this.kickOwedRerun();
|
|
3967
|
+
if (await this.o.store.loadPendingCommit(channelId)) {
|
|
3968
|
+
this.scheduleAutoResolve(channelId);
|
|
3969
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3973
|
+
* C2 T3c (H1): resolve the channel's pending record in the background, at
|
|
3974
|
+
* most once per minute per channel. Deferred to a later tick and run under
|
|
3975
|
+
* the channel lock like any lock-entry resolve (never inside the receive
|
|
3976
|
+
* lock that noticed it; the queue is not reentrant). It only RESENDS the
|
|
3977
|
+
* stored bytes, exactly as a committer's lock entry does. Failures are
|
|
3978
|
+
* logged, rate-limited, and otherwise ignored: the record stays and the
|
|
3979
|
+
* next trigger tries again.
|
|
3980
|
+
*/
|
|
3981
|
+
scheduleAutoResolve(channelId) {
|
|
3982
|
+
const now = this.nowMs();
|
|
3983
|
+
const last = this.autoResolveAt.get(channelId);
|
|
3984
|
+
if (last !== void 0 && now - last < PENDING_AUTO_RESOLVE_INTERVAL_MS)
|
|
3985
|
+
return;
|
|
3986
|
+
this.autoResolveAt.set(channelId, now);
|
|
3987
|
+
const job = (async () => {
|
|
3988
|
+
await Promise.resolve();
|
|
3989
|
+
try {
|
|
3990
|
+
await this.locks.run(channelId, () => this.resolvePendingCommitUnlocked(channelId));
|
|
3991
|
+
} catch (e) {
|
|
3992
|
+
writeServiceLog2("warn", "mls.pending_commit.auto_resolve_failed", {}, e);
|
|
3993
|
+
}
|
|
3994
|
+
})();
|
|
3995
|
+
this.backgroundResolves.add(job);
|
|
3996
|
+
void job.finally(() => this.backgroundResolves.delete(job));
|
|
3997
|
+
}
|
|
3998
|
+
/** C2 (for T6's stuck state): ms since the channel's pending record was
|
|
3999
|
+
* written, or null when it has none. A durable read; never throws on
|
|
4000
|
+
* absence. */
|
|
4001
|
+
async pendingCommitAgeMs(channelId) {
|
|
4002
|
+
const rec = await this.o.store.loadPendingCommit(channelId);
|
|
4003
|
+
return rec ? Math.max(0, this.nowMs() - rec.createdAt) : null;
|
|
4004
|
+
}
|
|
4005
|
+
/** Drop `channelId`'s cached manager only if it is still `mgr` (a concurrent
|
|
4006
|
+
* supersede may already have installed a different one, which must survive). */
|
|
4007
|
+
evictIfCached(channelId, mgr) {
|
|
4008
|
+
if (this.managers.get(channelId) === mgr)
|
|
4009
|
+
this.managers.delete(channelId);
|
|
4010
|
+
}
|
|
3166
4011
|
async evictOnThrow(channelId, op) {
|
|
3167
4012
|
try {
|
|
3168
4013
|
return await op();
|
|
@@ -3171,7 +4016,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3171
4016
|
throw e;
|
|
3172
4017
|
}
|
|
3173
4018
|
}
|
|
4019
|
+
/**
|
|
4020
|
+
* The public exporter key. C2 (HIGH-3): fenced. While the channel holds an
|
|
4021
|
+
* unresolved pending commit this throws `PendingCommitUnresolvedError`, so
|
|
4022
|
+
* nothing is sealed at an epoch a landed Remove may have rotated away from.
|
|
4023
|
+
*
|
|
4024
|
+
* ⚠️ C2 T3c (review M4) CORRECTED THIS COMMENT: not every external caller
|
|
4025
|
+
* SEALS. Its callers are the envelope builder and the fake's
|
|
4026
|
+
* `sendApplication` (seal), the vault KEK seal in connect-web (seal), AND
|
|
4027
|
+
* connect-web's vault OPEN (`useVault.ts` `openVaultKekset`, which peeks and
|
|
4028
|
+
* opens the sealed KEK set with it). The fence cannot tell a seal from an
|
|
4029
|
+
* open, so a pending record also fails the vault open closed (the accepted
|
|
4030
|
+
* HIGH-3 consequence; T6 maps it to the retryable not-ready outcome). The
|
|
4031
|
+
* same holds for {@link voiceMediaKey}: BOTH ends of a 1:1 call derive the
|
|
4032
|
+
* media key through it, so the callee's join fails closed too while a record
|
|
4033
|
+
* is pending. Receive-side derivations inside this file use
|
|
4034
|
+
* {@link exporterKeyUnfenced} and keep decrypting epoch N. A refused call
|
|
4035
|
+
* starts the automatic resolve (T3c, H1), so the fence lifts on its own
|
|
4036
|
+
* once the outcome is known.
|
|
4037
|
+
*/
|
|
3174
4038
|
async exporterKey(enclaveId, channelId, epoch) {
|
|
4039
|
+
await this.assertNotPending(channelId);
|
|
4040
|
+
return this.exporterKeyUnfenced(enclaveId, channelId, epoch);
|
|
4041
|
+
}
|
|
4042
|
+
/** C2: the unfenced exporter read, for RECEIVE derivations only
|
|
4043
|
+
* (`decryptApplicationRow`, `replayMissedApplicationMessages`). Reads MAIN,
|
|
4044
|
+
* which never moves past a pending record. */
|
|
4045
|
+
async exporterKeyUnfenced(enclaveId, channelId, epoch) {
|
|
3175
4046
|
const mgr = await this.manager(channelId);
|
|
3176
4047
|
return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
|
|
3177
4048
|
}
|
|
@@ -3192,6 +4063,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3192
4063
|
* inside another `locks.run(channelId, ...)` for the same channel.
|
|
3193
4064
|
*/
|
|
3194
4065
|
async voiceMediaKey(enclaveId, channelId, epoch, callId) {
|
|
4066
|
+
await this.assertNotPending(channelId);
|
|
3195
4067
|
return this.locks.run(channelId, async () => {
|
|
3196
4068
|
const mgr = await this.manager(channelId);
|
|
3197
4069
|
return mgr.exportSecret(VOICE_MEDIA_EXPORTER_LABEL, buildVoiceExporterContext(enclaveId, channelId, epoch, callId), 32);
|
|
@@ -3348,13 +4220,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3348
4220
|
const exp = kpId === "" ? null : await this.o.backend.fetchKeyPackageExpiry(kpId);
|
|
3349
4221
|
const expired = exp !== null && isServerExpired(exp, this.o.now?.() ?? Date.now());
|
|
3350
4222
|
if (!expired) {
|
|
3351
|
-
|
|
4223
|
+
writeServiceLog2("warn", "keypackage.publish_skipped_pending");
|
|
3352
4224
|
return kpId;
|
|
3353
4225
|
}
|
|
3354
|
-
|
|
4226
|
+
writeServiceLog2("warn", "keypackage.expired_rotation");
|
|
3355
4227
|
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
3356
4228
|
}
|
|
3357
|
-
|
|
4229
|
+
writeServiceLog2("warn", "keypackage.minting_fresh");
|
|
3358
4230
|
const mgr = new MLSGroupManager2();
|
|
3359
4231
|
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
3360
4232
|
const hex = bytesToHex2(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage));
|
|
@@ -3369,7 +4241,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3369
4241
|
return this.o.store.loadLastKpId(this.o.deviceId);
|
|
3370
4242
|
}
|
|
3371
4243
|
async ensureSession(channelId, opts) {
|
|
3372
|
-
return this.locks.run(channelId, () => this.
|
|
4244
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4245
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4246
|
+
return this.ensureSessionUnlocked(channelId, opts);
|
|
4247
|
+
}));
|
|
3373
4248
|
}
|
|
3374
4249
|
async ensureSessionUnlocked(channelId, opts) {
|
|
3375
4250
|
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
@@ -3403,16 +4278,11 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3403
4278
|
member_device_ids: [this.o.deviceId, ...others],
|
|
3404
4279
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
3405
4280
|
});
|
|
3406
|
-
await this.
|
|
4281
|
+
await this.saveMain(channelId, mgr);
|
|
3407
4282
|
this.managers.set(channelId, mgr);
|
|
3408
4283
|
return;
|
|
3409
4284
|
}
|
|
3410
4285
|
if (opts.bootstrap) {
|
|
3411
|
-
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
3412
|
-
if (init.created === false)
|
|
3413
|
-
throw new BootstrapRaceLostError();
|
|
3414
|
-
const mgr = new MLSGroupManager2();
|
|
3415
|
-
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
3416
4286
|
const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
|
|
3417
4287
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
3418
4288
|
const pkgs = others.map((d) => {
|
|
@@ -3421,20 +4291,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3421
4291
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
3422
4292
|
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
3423
4293
|
});
|
|
4294
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
4295
|
+
if (init.created === false) {
|
|
4296
|
+
if (!this.isAdoptableOrphan(init, others)) {
|
|
4297
|
+
if (this.isOrphanShape(init, others))
|
|
4298
|
+
throw new AttestedOrphanRefusedError(channelId);
|
|
4299
|
+
throw new BootstrapRaceLostError();
|
|
4300
|
+
}
|
|
4301
|
+
writeServiceLog2("warn", "session.bootstrap_orphan_adopted");
|
|
4302
|
+
}
|
|
4303
|
+
const mgr = new MLSGroupManager2();
|
|
4304
|
+
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
3424
4305
|
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
3425
|
-
|
|
4306
|
+
const rec = this.pendingRecord("bootstrap", init.group_id, {
|
|
3426
4307
|
device_id: this.o.deviceId,
|
|
3427
4308
|
commit: bytesToHex2(commit),
|
|
3428
4309
|
commit_epoch: Number(mgr.epoch),
|
|
3429
4310
|
expected_epoch: init.current_epoch,
|
|
3430
4311
|
member_device_ids: [this.o.deviceId, ...others],
|
|
3431
4312
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
3432
|
-
});
|
|
3433
|
-
this.
|
|
3434
|
-
|
|
4313
|
+
}, mgr, null);
|
|
4314
|
+
const res = await this.commitWithPendingRecord(channelId, rec, mgr);
|
|
4315
|
+
if (!res.landed) {
|
|
4316
|
+
const e = res.error;
|
|
4317
|
+
if (e instanceof MlsBackendHttpError && e.status === 409 && e.code === "EPOCH_CONFLICT")
|
|
4318
|
+
throw new BootstrapRaceLostError();
|
|
4319
|
+
throw e;
|
|
4320
|
+
}
|
|
3435
4321
|
} else {
|
|
3436
4322
|
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
3437
|
-
const w = rows.find((r) => r.message_type === "welcome" &&
|
|
4323
|
+
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
|
|
3438
4324
|
if (!w)
|
|
3439
4325
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
3440
4326
|
const mgr = new MLSGroupManager2();
|
|
@@ -3442,15 +4328,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3442
4328
|
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
3443
4329
|
} catch (err) {
|
|
3444
4330
|
this.recordDecryptError(w, err);
|
|
3445
|
-
|
|
4331
|
+
writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
|
|
3446
4332
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
3447
4333
|
return;
|
|
3448
4334
|
}
|
|
3449
4335
|
this.managers.set(channelId, mgr);
|
|
3450
|
-
await this.
|
|
4336
|
+
await this.saveMain(channelId, mgr);
|
|
3451
4337
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
3452
4338
|
}
|
|
3453
4339
|
}
|
|
4340
|
+
/**
|
|
4341
|
+
* C5 (Phase 3): may a bootstrap ADOPT the existing server group `init`
|
|
4342
|
+
* returned (created=false)? Only an orphan of the right SHAPE
|
|
4343
|
+
* ({@link isOrphanShape}) that the server reports as NOT owner-attested:
|
|
4344
|
+
* `reset_attested === false`, strictly. C2 T3b: `true` and ABSENT both refuse
|
|
4345
|
+
* (fail closed; an older backend omits the field).
|
|
4346
|
+
*/
|
|
4347
|
+
isAdoptableOrphan(init, others) {
|
|
4348
|
+
return this.isOrphanShape(init, others) && init.reset_attested === false;
|
|
4349
|
+
}
|
|
4350
|
+
/** C5: still at epoch 0 (no commit ever accepted, so nobody holds usable
|
|
4351
|
+
* state), with a roster that is exactly this device plus devices this
|
|
4352
|
+
* bootstrap Adds. Fails closed on a missing roster. Says nothing about
|
|
4353
|
+
* attestation (see {@link isAdoptableOrphan}). */
|
|
4354
|
+
isOrphanShape(init, others) {
|
|
4355
|
+
if (init.current_epoch !== 0)
|
|
4356
|
+
return false;
|
|
4357
|
+
if (!Array.isArray(init.member_device_ids))
|
|
4358
|
+
return false;
|
|
4359
|
+
return init.member_device_ids.every((d) => d === this.o.deviceId || others.includes(d));
|
|
4360
|
+
}
|
|
3454
4361
|
/**
|
|
3455
4362
|
* FileSafe vault owner bootstrap (Task 7, P4/P5 wiring): create the LOCAL
|
|
3456
4363
|
* ts-mls group bound to a group_id the backend ALREADY minted, WITHOUT
|
|
@@ -3499,7 +4406,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3499
4406
|
const mgr = new MLSGroupManager2();
|
|
3500
4407
|
await mgr.createGroup(new TextEncoder().encode(groupId), bundle);
|
|
3501
4408
|
this.managers.set(channelId, mgr);
|
|
3502
|
-
await this.
|
|
4409
|
+
await this.saveMain(channelId, mgr);
|
|
3503
4410
|
}
|
|
3504
4411
|
/**
|
|
3505
4412
|
* Committer-side: add a member to an ALREADY-bootstrapped (live) group.
|
|
@@ -3515,7 +4422,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3515
4422
|
* parallel group_id-by-channel resolver.
|
|
3516
4423
|
*/
|
|
3517
4424
|
async addMemberToLiveGroup(channelId, newDeviceId, opts) {
|
|
3518
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4425
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4426
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4427
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts);
|
|
4428
|
+
}));
|
|
3519
4429
|
}
|
|
3520
4430
|
/**
|
|
3521
4431
|
* Committer-side (owner): add a member to an already-bootstrapped VAULT
|
|
@@ -3536,7 +4446,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3536
4446
|
if (!opts.expectedCredentialPubKey) {
|
|
3537
4447
|
throw new VaultCredentialMismatchError(opts.expectedCredentialPubKey, "");
|
|
3538
4448
|
}
|
|
3539
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4449
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4450
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4451
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, { vaultAdd: true, ...opts });
|
|
4452
|
+
}));
|
|
3540
4453
|
}
|
|
3541
4454
|
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
3542
4455
|
const isVault = !!opts && "vaultAdd" in opts && opts.vaultAdd === true;
|
|
@@ -3591,13 +4504,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3591
4504
|
deltaVouches
|
|
3592
4505
|
});
|
|
3593
4506
|
if (hasMalformedBinding(identityLeaves)) {
|
|
3594
|
-
|
|
4507
|
+
writeServiceLog2("warn", "admission.malformed_leaf_binding");
|
|
3595
4508
|
}
|
|
3596
4509
|
if (targetLeaves.length > 0 && deltaVouches) {
|
|
3597
|
-
|
|
4510
|
+
writeServiceLog2("warn", "admission.possible_keypackage_substitution", {
|
|
4511
|
+
leaf_count: targetLeaves.length
|
|
4512
|
+
});
|
|
3598
4513
|
}
|
|
3599
4514
|
if (targetLeaves.length > 1) {
|
|
3600
|
-
|
|
4515
|
+
writeServiceLog2("warn", "admission.duplicate_device_leaves", {
|
|
4516
|
+
leaf_count: targetLeaves.length
|
|
4517
|
+
});
|
|
3601
4518
|
}
|
|
3602
4519
|
switch (verdict.decision) {
|
|
3603
4520
|
case "admit":
|
|
@@ -3624,21 +4541,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3624
4541
|
groupId = init.group_id;
|
|
3625
4542
|
members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
3626
4543
|
}
|
|
4544
|
+
if (new TextDecoder().decode(mgr.groupId) !== groupId) {
|
|
4545
|
+
throw new Error("addMemberToLiveGroup: group binding mismatch");
|
|
4546
|
+
}
|
|
3627
4547
|
const priorEpoch = Number(mgr.epoch);
|
|
4548
|
+
const prior = tagOf(mgr);
|
|
3628
4549
|
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
4550
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("add", groupId, {
|
|
4551
|
+
device_id: this.o.deviceId,
|
|
4552
|
+
commit: bytesToHex2(commit),
|
|
4553
|
+
commit_epoch: Number(mgr.epoch),
|
|
4554
|
+
expected_epoch: priorEpoch,
|
|
4555
|
+
member_device_ids: members,
|
|
4556
|
+
op: "add",
|
|
4557
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
|
|
4558
|
+
}, mgr, prior), mgr);
|
|
4559
|
+
if (!res.landed) {
|
|
4560
|
+
const e = res.error;
|
|
4561
|
+
if (isSupersededRefusal(e))
|
|
4562
|
+
throw new GroupSupersededError(channelId, groupId);
|
|
4563
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3642
4564
|
throw e;
|
|
3643
4565
|
try {
|
|
3644
4566
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3646,7 +4568,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3646
4568
|
}
|
|
3647
4569
|
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
3648
4570
|
}
|
|
3649
|
-
await this.o.store.save(channelId, mgr.exportState());
|
|
3650
4571
|
return "committed";
|
|
3651
4572
|
}
|
|
3652
4573
|
/**
|
|
@@ -3669,36 +4590,43 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3669
4590
|
* of this group.
|
|
3670
4591
|
*/
|
|
3671
4592
|
async removeMemberFromLiveGroup(channelId, identityId) {
|
|
3672
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4593
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4594
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4595
|
+
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0);
|
|
4596
|
+
}));
|
|
3673
4597
|
}
|
|
3674
4598
|
async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
|
|
3675
4599
|
const mgr = await this.manager(channelId);
|
|
3676
4600
|
const indices = mgr.leafIndicesForIdentity(identityId);
|
|
3677
4601
|
if (indices.length === 0)
|
|
3678
4602
|
return;
|
|
4603
|
+
this.refuseOwnLeafRemove(mgr, indices, "removeMember");
|
|
3679
4604
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
4605
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
4606
|
+
throw new Error("removeMemberFromLiveGroup: group binding mismatch");
|
|
4607
|
+
}
|
|
3680
4608
|
const priorEpoch = Number(mgr.epoch);
|
|
4609
|
+
const prior = tagOf(mgr);
|
|
3681
4610
|
const { commit } = await mgr.removeMembers(indices);
|
|
3682
4611
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3683
4612
|
throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
|
|
3684
4613
|
}
|
|
3685
|
-
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
3686
4614
|
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
if (_attempt >= 2)
|
|
4615
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
4616
|
+
device_id: this.o.deviceId,
|
|
4617
|
+
commit: bytesToHex2(commit),
|
|
4618
|
+
commit_epoch: Number(mgr.epoch),
|
|
4619
|
+
expected_epoch: priorEpoch,
|
|
4620
|
+
member_device_ids: members,
|
|
4621
|
+
op: "remove",
|
|
4622
|
+
removed_identity_id: identityId,
|
|
4623
|
+
welcomes: []
|
|
4624
|
+
}, mgr, prior), mgr);
|
|
4625
|
+
if (!res.landed) {
|
|
4626
|
+
const e = res.error;
|
|
4627
|
+
if (isSupersededRefusal(e))
|
|
4628
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
4629
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3702
4630
|
throw e;
|
|
3703
4631
|
try {
|
|
3704
4632
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3706,7 +4634,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3706
4634
|
}
|
|
3707
4635
|
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
|
|
3708
4636
|
}
|
|
3709
|
-
await this.o.store.promoteStaging(channelId);
|
|
3710
4637
|
}
|
|
3711
4638
|
/**
|
|
3712
4639
|
* Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
|
|
@@ -3745,25 +4672,36 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3745
4672
|
* should delegate to it.
|
|
3746
4673
|
*
|
|
3747
4674
|
* Returns null rather than throwing so every caller must decide explicitly what
|
|
3748
|
-
* an unknown own-leaf means.
|
|
3749
|
-
*
|
|
3750
|
-
*
|
|
3751
|
-
*
|
|
3752
|
-
* `if (ownLeaf !== null && leaves.includes(ownLeaf))` — the `else` compares no
|
|
3753
|
-
* identities at all, so a null return sends an OWN-identity removal down the
|
|
3754
|
-
* positional `leaves[pos]` path, which is exactly the add-order guess the
|
|
3755
|
-
* self-exclusion branch exists to avoid (RFC 9420 §7.1 leftmost-blank refill
|
|
3756
|
-
* permutes that mapping after any Remove). Null is therefore currently
|
|
3757
|
-
* indistinguishable from "foreign identity", and only the cardinality guard
|
|
3758
|
-
* stands between it and evicting the wrong device of the same human.
|
|
3759
|
-
* Correcting that is Phase 1b, not this comment.
|
|
4675
|
+
* an unknown own-leaf means. Every remove path now decides the same way: null
|
|
4676
|
+
* FAILS CLOSED (Phase 3 C3, {@link refuseOwnLeafRemove}). Before C3,
|
|
4677
|
+
* {@link removeDeviceFromLiveGroupUnlocked} treated null as "foreign identity"
|
|
4678
|
+
* and sent an own-identity removal down the positional add-order guess.
|
|
3760
4679
|
*/
|
|
3761
4680
|
ownLeafIndex(mgr) {
|
|
3762
4681
|
const idx = mgr.state?.privatePath?.leafIndex;
|
|
3763
4682
|
return typeof idx === "number" ? idx : null;
|
|
3764
4683
|
}
|
|
4684
|
+
/**
|
|
4685
|
+
* Phase 3 C3: the shared own-leaf refusal for every remove path. Throws
|
|
4686
|
+
* {@link OwnLeafRemoveRefusedError} when this committer's own leaf index is
|
|
4687
|
+
* unknown (fail closed) or is among `targetLeaves`. Returns the own leaf index
|
|
4688
|
+
* so a caller that needs it (self-exclusion) reads it from the same check.
|
|
4689
|
+
* Synchronous and called before any commit is built: a refusal distributes
|
|
4690
|
+
* nothing and leaves the cached manager untouched.
|
|
4691
|
+
*/
|
|
4692
|
+
refuseOwnLeafRemove(mgr, targetLeaves, op) {
|
|
4693
|
+
const ownLeaf = this.ownLeafIndex(mgr);
|
|
4694
|
+
if (ownLeaf === null)
|
|
4695
|
+
throw new OwnLeafRemoveRefusedError(op, "own_leaf_unknown");
|
|
4696
|
+
if (targetLeaves.includes(ownLeaf))
|
|
4697
|
+
throw new OwnLeafRemoveRefusedError(op, "own_leaf");
|
|
4698
|
+
return ownLeaf;
|
|
4699
|
+
}
|
|
3765
4700
|
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
3766
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4701
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4702
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4703
|
+
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0);
|
|
4704
|
+
}));
|
|
3767
4705
|
}
|
|
3768
4706
|
async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
|
|
3769
4707
|
const mgr = await this.manager(channelId);
|
|
@@ -3829,9 +4767,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3829
4767
|
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
3830
4768
|
throw new LeafRosterMismatchError();
|
|
3831
4769
|
}
|
|
4770
|
+
if (deviceId === this.o.deviceId)
|
|
4771
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
|
|
3832
4772
|
let leafIndex;
|
|
3833
4773
|
const ownLeaf = this.ownLeafIndex(mgr);
|
|
3834
|
-
if (ownLeaf
|
|
4774
|
+
if (ownLeaf === null)
|
|
4775
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf_unknown");
|
|
4776
|
+
if (leaves.includes(ownLeaf)) {
|
|
3835
4777
|
const others = leaves.filter((i) => i !== ownLeaf);
|
|
3836
4778
|
if (others.length !== 1) {
|
|
3837
4779
|
throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
|
|
@@ -3841,30 +4783,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3841
4783
|
leafIndex = leaves[pos];
|
|
3842
4784
|
}
|
|
3843
4785
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
4786
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
4787
|
+
throw new Error("removeDeviceFromLiveGroup: group binding mismatch");
|
|
4788
|
+
}
|
|
3844
4789
|
const priorEpoch = Number(mgr.epoch);
|
|
4790
|
+
const prior = tagOf(mgr);
|
|
3845
4791
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
3846
4792
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3847
4793
|
throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
|
|
3848
4794
|
}
|
|
3849
|
-
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
3850
4795
|
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
3851
4796
|
const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex2(opts.identity);
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
if (_attempt >= 2)
|
|
4797
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
4798
|
+
device_id: this.o.deviceId,
|
|
4799
|
+
commit: bytesToHex2(commit),
|
|
4800
|
+
commit_epoch: Number(mgr.epoch),
|
|
4801
|
+
expected_epoch: priorEpoch,
|
|
4802
|
+
member_device_ids: members,
|
|
4803
|
+
op: "remove",
|
|
4804
|
+
removed_identity_id: removedIdentity,
|
|
4805
|
+
removed_device_id: deviceId,
|
|
4806
|
+
welcomes: []
|
|
4807
|
+
}, mgr, prior), mgr);
|
|
4808
|
+
if (!res.landed) {
|
|
4809
|
+
const e = res.error;
|
|
4810
|
+
if (isSupersededRefusal(e))
|
|
4811
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
4812
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3868
4813
|
throw e;
|
|
3869
4814
|
try {
|
|
3870
4815
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3872,7 +4817,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3872
4817
|
}
|
|
3873
4818
|
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
3874
4819
|
}
|
|
3875
|
-
await this.o.store.promoteStaging(channelId);
|
|
3876
4820
|
}
|
|
3877
4821
|
/**
|
|
3878
4822
|
* Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
|
|
@@ -3930,7 +4874,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3930
4874
|
* re-runs on every attempt.
|
|
3931
4875
|
*/
|
|
3932
4876
|
async removeVaultLeafFromLiveGroup(channelId, expectedLeafSignaturePubKeyHex, opts) {
|
|
3933
|
-
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () =>
|
|
4877
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
4878
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4879
|
+
return this.removeVaultLeafUnlocked(channelId, expectedLeafSignaturePubKeyHex, opts, 0);
|
|
4880
|
+
}));
|
|
3934
4881
|
}
|
|
3935
4882
|
async removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt) {
|
|
3936
4883
|
const mgr = await this.manager(channelId);
|
|
@@ -3956,33 +4903,37 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3956
4903
|
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
3957
4904
|
if (claimed !== opts.expectedIdentityId)
|
|
3958
4905
|
throw new VaultLeafTOCTOUError();
|
|
4906
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeVaultLeaf");
|
|
4907
|
+
const followUp = opts.deviceId && opts.enclaveId ? { kind: "vault-remove", enclaveId: opts.enclaveId, deviceId: opts.deviceId } : void 0;
|
|
4908
|
+
if (followUp)
|
|
4909
|
+
await this.drainOwedFollowUpUnlocked(channelId);
|
|
3959
4910
|
const priorEpoch = Number(mgr.epoch);
|
|
4911
|
+
const prior = tagOf(mgr);
|
|
3960
4912
|
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
3961
4913
|
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
3962
4914
|
throw new Error("removeVaultLeafFromLiveGroup: unexpected epoch drift before distribute");
|
|
3963
4915
|
}
|
|
3964
4916
|
const groupId = new TextDecoder().decode(mgr.groupId);
|
|
3965
|
-
await this.
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
if (_attempt >= 2)
|
|
4917
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", groupId, {
|
|
4918
|
+
device_id: this.o.deviceId,
|
|
4919
|
+
commit: bytesToHex2(commit),
|
|
4920
|
+
commit_epoch: Number(mgr.epoch),
|
|
4921
|
+
expected_epoch: priorEpoch,
|
|
4922
|
+
member_device_ids: opts.vaultContext.memberDeviceIds,
|
|
4923
|
+
op: "remove",
|
|
4924
|
+
removed_identity_id: opts.expectedIdentityId,
|
|
4925
|
+
// Device-granular distribute (C1): when opts.deviceId is empty (no
|
|
4926
|
+
// owner-local leafSigKey->deviceId binding), OMIT removed_device_id —
|
|
4927
|
+
// the server falls back to identity-level eviction. The crypto
|
|
4928
|
+
// removal above already stands regardless; only delivery degrades.
|
|
4929
|
+
...opts.deviceId ? { removed_device_id: opts.deviceId } : {},
|
|
4930
|
+
welcomes: []
|
|
4931
|
+
}, mgr, prior, followUp), mgr);
|
|
4932
|
+
if (!res.landed) {
|
|
4933
|
+
const e = res.error;
|
|
4934
|
+
if (isSupersededRefusal(e))
|
|
4935
|
+
throw new GroupSupersededError(channelId, groupId);
|
|
4936
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
3986
4937
|
throw e;
|
|
3987
4938
|
try {
|
|
3988
4939
|
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
@@ -3990,7 +4941,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
3990
4941
|
}
|
|
3991
4942
|
return this.removeVaultLeafUnlocked(channelId, expectedHex, opts, _attempt + 1);
|
|
3992
4943
|
}
|
|
3993
|
-
await this.o.store.promoteStaging(channelId);
|
|
3994
4944
|
}
|
|
3995
4945
|
/**
|
|
3996
4946
|
* Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
|
|
@@ -4081,6 +5031,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4081
5031
|
*/
|
|
4082
5032
|
async healDivergedMember(channelId, memberDeviceId, opts) {
|
|
4083
5033
|
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
5034
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
4084
5035
|
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
4085
5036
|
const hex = kps[memberDeviceId];
|
|
4086
5037
|
if (!hex)
|
|
@@ -4107,7 +5058,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4107
5058
|
}
|
|
4108
5059
|
}
|
|
4109
5060
|
if (leaves.length === 1) {
|
|
4110
|
-
|
|
5061
|
+
writeServiceLog2("warn", "heal.single_leaf_device_unattributed");
|
|
4111
5062
|
}
|
|
4112
5063
|
let removeLanded = false;
|
|
4113
5064
|
if (leaves.length === 1) {
|
|
@@ -4209,6 +5160,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4209
5160
|
*/
|
|
4210
5161
|
async resetChannelForRejoin(channelId) {
|
|
4211
5162
|
await this.locks.run(channelId, async () => {
|
|
5163
|
+
const r = await this.resolvePendingCommitUnlocked(channelId);
|
|
5164
|
+
if (r === "ambiguous")
|
|
5165
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
5166
|
+
if (r === "landed") {
|
|
5167
|
+
writeServiceLog2("info", "mls.pending_commit.resolved_pending");
|
|
5168
|
+
return;
|
|
5169
|
+
}
|
|
4212
5170
|
this.evictChannelState(channelId);
|
|
4213
5171
|
await this.o.store.delete(channelId);
|
|
4214
5172
|
await this.o.store.discardStaging(channelId);
|
|
@@ -4233,7 +5191,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4233
5191
|
* (required subset of supplied) is still satisfied even though it never
|
|
4234
5192
|
* goes through addMembers. */
|
|
4235
5193
|
async resetSessionSupersede(channelId, opts) {
|
|
4236
|
-
return this.locks.run(channelId, async () => {
|
|
5194
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
5195
|
+
let resolved;
|
|
5196
|
+
let seen = "unreadable";
|
|
5197
|
+
try {
|
|
5198
|
+
resolved = await this.resolvePendingCommitUnlocked(channelId, PENDING_INLINE_RESENDS, (r) => {
|
|
5199
|
+
if (r)
|
|
5200
|
+
seen = r;
|
|
5201
|
+
});
|
|
5202
|
+
} catch (e) {
|
|
5203
|
+
if (opts.abandonPending !== true)
|
|
5204
|
+
throw e;
|
|
5205
|
+
resolved = "ambiguous";
|
|
5206
|
+
}
|
|
5207
|
+
if (resolved === "ambiguous") {
|
|
5208
|
+
if (opts.abandonPending !== true)
|
|
5209
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
5210
|
+
if (!await this.abandonPendingCommitUnlocked(channelId, seen))
|
|
5211
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
5212
|
+
}
|
|
5213
|
+
await this.drainOwedFollowUpUnlocked(channelId, opts.abandonPending === true);
|
|
4237
5214
|
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
4238
5215
|
if (!bundle) {
|
|
4239
5216
|
await this.publishKeyPackage();
|
|
@@ -4242,9 +5219,6 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4242
5219
|
if (!bundle)
|
|
4243
5220
|
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
4244
5221
|
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
4245
|
-
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
4246
|
-
const mgr = new MLSGroupManager2();
|
|
4247
|
-
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
4248
5222
|
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
4249
5223
|
const pkgs = others.map((d) => {
|
|
4250
5224
|
const hex = kps[d];
|
|
@@ -4252,19 +5226,62 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4252
5226
|
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
4253
5227
|
return MLSGroupManager2.deserializeKeyPackage(hexToBytes(hex));
|
|
4254
5228
|
});
|
|
4255
|
-
const
|
|
4256
|
-
|
|
5229
|
+
const mainState0 = await this.o.store.load(channelId);
|
|
5230
|
+
const prior0 = mainState0 ? tagOfState(mainState0) : null;
|
|
5231
|
+
const reservation = pendingReservation({
|
|
5232
|
+
groupId: opts.newGroupId,
|
|
5233
|
+
deviceId: this.o.deviceId,
|
|
5234
|
+
prior: prior0,
|
|
5235
|
+
placeholder: mainState0 ?? RESERVATION_PLACEHOLDER_STATE,
|
|
5236
|
+
createdAt: this.nowMs()
|
|
5237
|
+
});
|
|
5238
|
+
if (!await this.o.store.savePendingCommit(channelId, reservation, { onlyIfAbsent: true })) {
|
|
5239
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
5240
|
+
}
|
|
5241
|
+
const reservationKey = pendingCommitKey(reservation);
|
|
5242
|
+
const mainState1 = await this.o.store.load(channelId);
|
|
5243
|
+
const prior1 = mainState1 ? tagOfState(mainState1) : null;
|
|
5244
|
+
let prior = prior0;
|
|
5245
|
+
if (!sameMainTag(prior0, prior1)) {
|
|
5246
|
+
const corrected = { ...reservation, prior: prior1, state: mainState1 ?? RESERVATION_PLACEHOLDER_STATE };
|
|
5247
|
+
if (!await this.o.store.savePendingCommit(channelId, corrected, { replacing: reservationKey })) {
|
|
5248
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey).catch(() => false);
|
|
5249
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
5250
|
+
}
|
|
5251
|
+
prior = prior1;
|
|
5252
|
+
}
|
|
5253
|
+
let reinit;
|
|
5254
|
+
try {
|
|
5255
|
+
reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
5256
|
+
} catch (e) {
|
|
5257
|
+
if (isDefiniteReinitRefusal(e))
|
|
5258
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey);
|
|
5259
|
+
throw e;
|
|
5260
|
+
}
|
|
5261
|
+
let mgr;
|
|
5262
|
+
let commit;
|
|
5263
|
+
let welcome;
|
|
5264
|
+
try {
|
|
5265
|
+
mgr = new MLSGroupManager2();
|
|
5266
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
5267
|
+
({ commit, welcome } = await mgr.addMembers(pkgs));
|
|
5268
|
+
} catch (e) {
|
|
5269
|
+
await this.o.store.clearPendingCommit(channelId, reservationKey);
|
|
5270
|
+
throw e;
|
|
5271
|
+
}
|
|
5272
|
+
const staleMemberDeviceIds = reinit.stale_member_device_ids ?? [];
|
|
5273
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("supersede", reinit.group_id, {
|
|
4257
5274
|
device_id: this.o.deviceId,
|
|
4258
5275
|
commit: bytesToHex2(commit),
|
|
4259
5276
|
commit_epoch: Number(mgr.epoch),
|
|
4260
5277
|
expected_epoch: reinit.current_epoch,
|
|
4261
5278
|
member_device_ids: [this.o.deviceId, ...others],
|
|
4262
5279
|
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
|
|
4263
|
-
});
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
return { staleMemberDeviceIds
|
|
4267
|
-
});
|
|
5280
|
+
}, mgr, prior, { kind: "supersede", staleMemberDeviceIds }), mgr, reservationKey);
|
|
5281
|
+
if (!res.landed)
|
|
5282
|
+
throw res.error;
|
|
5283
|
+
return { staleMemberDeviceIds };
|
|
5284
|
+
}));
|
|
4268
5285
|
}
|
|
4269
5286
|
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
4270
5287
|
* Cheap synchronous check; does NOT consult the store. */
|
|
@@ -4329,7 +5346,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4329
5346
|
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(m.payload)));
|
|
4330
5347
|
if (envelope.channel_id !== channelId)
|
|
4331
5348
|
continue;
|
|
4332
|
-
const key = await this.locks.run(channelId, () => this.
|
|
5349
|
+
const key = await this.locks.run(channelId, () => this.exporterKeyUnfenced(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
4333
5350
|
const aad = await buildAssociatedData({
|
|
4334
5351
|
message_id: envelope.message_id,
|
|
4335
5352
|
channel_id: envelope.channel_id,
|
|
@@ -4526,7 +5543,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4526
5543
|
} catch (err) {
|
|
4527
5544
|
this.recordDecryptError(row, err);
|
|
4528
5545
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4529
|
-
|
|
5546
|
+
writeServiceLog2("debug", "supersede.welcome_open_failed", {}, err);
|
|
4530
5547
|
}
|
|
4531
5548
|
let requested = false;
|
|
4532
5549
|
try {
|
|
@@ -4544,6 +5561,23 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4544
5561
|
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
4545
5562
|
return refuse("supersede refused: post-join group id != attested new_group_id");
|
|
4546
5563
|
}
|
|
5564
|
+
let dead = null;
|
|
5565
|
+
try {
|
|
5566
|
+
dead = await this.o.store.loadPendingCommit(channelId);
|
|
5567
|
+
await this.resolvePendingCommitUnlocked(channelId, 0);
|
|
5568
|
+
} catch (e) {
|
|
5569
|
+
writeServiceLog2("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
|
|
5570
|
+
}
|
|
5571
|
+
if (dead)
|
|
5572
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(dead));
|
|
5573
|
+
try {
|
|
5574
|
+
const after = await this.o.store.loadPendingCommit(channelId);
|
|
5575
|
+
if (after && after.groupId !== row.group_id) {
|
|
5576
|
+
await this.o.store.clearPendingCommit(channelId, pendingCommitKey(after));
|
|
5577
|
+
}
|
|
5578
|
+
} catch (e) {
|
|
5579
|
+
writeServiceLog2("warn", "mls.pending_commit.supersede_resolve_failed", {}, e);
|
|
5580
|
+
}
|
|
4547
5581
|
this.evictChannelState(channelId);
|
|
4548
5582
|
await this.o.store.delete(channelId);
|
|
4549
5583
|
await this.o.store.discardStaging(channelId);
|
|
@@ -4551,13 +5585,21 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4551
5585
|
this.errors.delete(channelId);
|
|
4552
5586
|
this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
|
|
4553
5587
|
this.managers.set(channelId, mgr);
|
|
4554
|
-
await this.
|
|
5588
|
+
await this.saveMain(channelId, mgr);
|
|
4555
5589
|
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
4556
5590
|
return true;
|
|
4557
5591
|
});
|
|
4558
5592
|
}
|
|
4559
5593
|
async receiveOnce(deviceId) {
|
|
5594
|
+
this.kickOwedRerun();
|
|
4560
5595
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
5596
|
+
if (deviceId === this.o.deviceId && this.custody.size && rows.length < DELIVERY_PULL_PAGE) {
|
|
5597
|
+
const pulledIds = new Set(rows.map((r) => r.queue_id));
|
|
5598
|
+
for (const qid of this.custody.keys()) {
|
|
5599
|
+
if (!pulledIds.has(qid))
|
|
5600
|
+
this.custody.delete(qid);
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
4561
5603
|
const appEntries = [];
|
|
4562
5604
|
const handshakeAcked = [];
|
|
4563
5605
|
const nackIds = [];
|
|
@@ -4566,7 +5608,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4566
5608
|
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
4567
5609
|
const ordered = [...welcomes, ...nonWelcomes];
|
|
4568
5610
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && ordered.length) {
|
|
4569
|
-
|
|
5611
|
+
writeServiceLog2("debug", "delivery.rows_pulled", { count: ordered.length });
|
|
4570
5612
|
}
|
|
4571
5613
|
const classCtx = /* @__PURE__ */ new Map();
|
|
4572
5614
|
const classCtxFor = async (cid) => {
|
|
@@ -4593,10 +5635,58 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4593
5635
|
}
|
|
4594
5636
|
return ctx;
|
|
4595
5637
|
};
|
|
5638
|
+
const pendingAtPull = /* @__PURE__ */ new Map();
|
|
5639
|
+
const leftThisPull = /* @__PURE__ */ new Map();
|
|
5640
|
+
const pullNow = this.nowMs();
|
|
5641
|
+
const escalatedAtPull = (cid) => {
|
|
5642
|
+
const st = this.leftStreak.get(cid);
|
|
5643
|
+
return st !== void 0 && st.pulls >= PENDING_LEFT_ESCALATION_PULLS && pullNow - st.since >= PENDING_LEFT_ESCALATION_MS;
|
|
5644
|
+
};
|
|
5645
|
+
const hadPendingAtPull = async (cid) => {
|
|
5646
|
+
let v = pendingAtPull.get(cid);
|
|
5647
|
+
if (v === void 0) {
|
|
5648
|
+
try {
|
|
5649
|
+
v = await this.o.store.loadPendingCommit(cid) !== null;
|
|
5650
|
+
} catch {
|
|
5651
|
+
v = true;
|
|
5652
|
+
}
|
|
5653
|
+
pendingAtPull.set(cid, v);
|
|
5654
|
+
}
|
|
5655
|
+
return v;
|
|
5656
|
+
};
|
|
4596
5657
|
for (const row of ordered) {
|
|
4597
5658
|
try {
|
|
4598
5659
|
if (row.message_type !== "welcome" && row.connect_channel_id) {
|
|
4599
5660
|
await classCtxFor(row.connect_channel_id);
|
|
5661
|
+
if (await hadPendingAtPull(row.connect_channel_id)) {
|
|
5662
|
+
const pcid = row.connect_channel_id;
|
|
5663
|
+
let verdict;
|
|
5664
|
+
try {
|
|
5665
|
+
verdict = await this.resolveRowAgainstPending(pcid, row, classCtx.get(pcid));
|
|
5666
|
+
} catch (e) {
|
|
5667
|
+
const now = this.nowMs();
|
|
5668
|
+
const last = this.receiveFailLoggedAt.get(pcid);
|
|
5669
|
+
if (last === void 0 || now - last >= PENDING_RECEIVE_FAIL_LOG_INTERVAL_MS) {
|
|
5670
|
+
this.receiveFailLoggedAt.set(pcid, now);
|
|
5671
|
+
writeServiceLog2("warn", "mls.pending_commit.receive_resolve_failed", {}, e);
|
|
5672
|
+
}
|
|
5673
|
+
verdict = { kind: "leave" };
|
|
5674
|
+
}
|
|
5675
|
+
if (verdict.kind === "leave") {
|
|
5676
|
+
leftThisPull.set(pcid, (leftThisPull.get(pcid) ?? 0) + 1);
|
|
5677
|
+
if (escalatedAtPull(pcid))
|
|
5678
|
+
throw new PendingRowEscalatedError();
|
|
5679
|
+
continue;
|
|
5680
|
+
}
|
|
5681
|
+
if (verdict.kind === "acked") {
|
|
5682
|
+
handshakeAcked.push(row.queue_id);
|
|
5683
|
+
continue;
|
|
5684
|
+
}
|
|
5685
|
+
if (verdict.kind === "entry") {
|
|
5686
|
+
appEntries.push(verdict.entry);
|
|
5687
|
+
continue;
|
|
5688
|
+
}
|
|
5689
|
+
}
|
|
4600
5690
|
}
|
|
4601
5691
|
if (row.message_type === "welcome") {
|
|
4602
5692
|
const wcid = row.connect_channel_id;
|
|
@@ -4611,14 +5701,19 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4611
5701
|
heldGroupId = null;
|
|
4612
5702
|
}
|
|
4613
5703
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4614
|
-
|
|
5704
|
+
if (heldGroupId === null)
|
|
5705
|
+
writeServiceLog2("debug", "delivery.welcome_manager_unloadable");
|
|
5706
|
+
else if (heldGroupId === row.group_id)
|
|
5707
|
+
writeServiceLog2("debug", "delivery.welcome_stray");
|
|
5708
|
+
else
|
|
5709
|
+
writeServiceLog2("debug", "delivery.welcome_supersede_candidate");
|
|
4615
5710
|
}
|
|
4616
5711
|
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
4617
5712
|
handshakeAcked.push(row.queue_id);
|
|
4618
5713
|
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
4619
5714
|
if (this.supersedeBackoffActive(wcid, row.group_id)) {
|
|
4620
5715
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4621
|
-
|
|
5716
|
+
writeServiceLog2("debug", "delivery.welcome_refusal_backoff");
|
|
4622
5717
|
}
|
|
4623
5718
|
} else {
|
|
4624
5719
|
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
@@ -4627,7 +5722,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4627
5722
|
}
|
|
4628
5723
|
}
|
|
4629
5724
|
} else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
4630
|
-
|
|
5725
|
+
writeServiceLog2("debug", "delivery.welcome_no_local_state");
|
|
4631
5726
|
}
|
|
4632
5727
|
}
|
|
4633
5728
|
} else if (row.message_type === "commit") {
|
|
@@ -4638,7 +5733,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4638
5733
|
await this.locks.run(cid, async () => {
|
|
4639
5734
|
const mgr = await this.manager(cid);
|
|
4640
5735
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
4641
|
-
await this.
|
|
5736
|
+
await this.saveAdvancedOrEvict(cid, mgr);
|
|
4642
5737
|
advancedEpoch = Number(mgr.epoch);
|
|
4643
5738
|
});
|
|
4644
5739
|
this.recordDecryptSuccess(cid);
|
|
@@ -4649,36 +5744,18 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4649
5744
|
}
|
|
4650
5745
|
handshakeAcked.push(row.queue_id);
|
|
4651
5746
|
} else {
|
|
4652
|
-
const
|
|
4653
|
-
|
|
4654
|
-
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
4655
|
-
}
|
|
4656
|
-
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
4657
|
-
const aad = await buildAssociatedData({
|
|
4658
|
-
message_id: envelope.message_id,
|
|
4659
|
-
channel_id: envelope.channel_id,
|
|
4660
|
-
sender_id: envelope.sender_id,
|
|
4661
|
-
mls_group_epoch: envelope.mls_group_epoch,
|
|
4662
|
-
replay_nonce: envelope.replay_nonce,
|
|
4663
|
-
policy_binding: envelope.policy_binding
|
|
4664
|
-
});
|
|
4665
|
-
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
4666
|
-
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
4667
|
-
this.recordDecryptSuccess(appChannelId);
|
|
4668
|
-
appEntries.push({
|
|
4669
|
-
queueId: row.queue_id,
|
|
4670
|
-
messageId: envelope.message_id,
|
|
4671
|
-
channelId: appChannelId,
|
|
4672
|
-
plaintext,
|
|
4673
|
-
envelope,
|
|
4674
|
-
receivedAt: Date.now(),
|
|
4675
|
-
retryCount: 0,
|
|
4676
|
-
expires_at: row.expires_at,
|
|
4677
|
-
voiceMode: row.voice_mode ?? void 0,
|
|
4678
|
-
voiceDefaultResponder: row.voice_default_responder ?? void 0
|
|
4679
|
-
});
|
|
5747
|
+
const held = this.custody.get(row.queue_id);
|
|
5748
|
+
appEntries.push(held ? { ...held } : await this.decryptApplicationRow(row, { lockHeld: false }));
|
|
4680
5749
|
}
|
|
4681
5750
|
} catch (err) {
|
|
5751
|
+
const heldAfterWait = row.message_type === "application" ? this.custody.get(row.queue_id) : void 0;
|
|
5752
|
+
if (heldAfterWait) {
|
|
5753
|
+
appEntries.push({ ...heldAfterWait });
|
|
5754
|
+
continue;
|
|
5755
|
+
}
|
|
5756
|
+
if (err instanceof PendingCommitUnresolvedError) {
|
|
5757
|
+
continue;
|
|
5758
|
+
}
|
|
4682
5759
|
this.recordDecryptError(row, err);
|
|
4683
5760
|
const cid = row.connect_channel_id;
|
|
4684
5761
|
let drained = false;
|
|
@@ -4723,6 +5800,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4723
5800
|
}
|
|
4724
5801
|
}
|
|
4725
5802
|
}
|
|
5803
|
+
for (const [cid, had] of pendingAtPull) {
|
|
5804
|
+
if (!had)
|
|
5805
|
+
continue;
|
|
5806
|
+
if ((leftThisPull.get(cid) ?? 0) === 0) {
|
|
5807
|
+
this.leftStreak.delete(cid);
|
|
5808
|
+
continue;
|
|
5809
|
+
}
|
|
5810
|
+
const st = this.leftStreak.get(cid);
|
|
5811
|
+
if (st)
|
|
5812
|
+
st.pulls += 1;
|
|
5813
|
+
else
|
|
5814
|
+
this.leftStreak.set(cid, { pulls: 1, since: pullNow });
|
|
5815
|
+
this.scheduleAutoResolve(cid);
|
|
5816
|
+
}
|
|
4726
5817
|
if (handshakeAcked.length)
|
|
4727
5818
|
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
4728
5819
|
if (nackIds.length) {
|
|
@@ -4736,6 +5827,195 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4736
5827
|
}
|
|
4737
5828
|
return appEntries;
|
|
4738
5829
|
}
|
|
5830
|
+
/**
|
|
5831
|
+
* C2 (spec §4.4 "Receive resolves the record from the queue"; plan 3.8):
|
|
5832
|
+
* decide one delivery row of a channel that holds a pending record. Runs
|
|
5833
|
+
* under the channel lock and RE-READS the record inside it (a row earlier in
|
|
5834
|
+
* the same pull may have resolved it).
|
|
5835
|
+
*
|
|
5836
|
+
* Device-wide pulls arrive in `seq` order, and C1c puts every epoch-N row
|
|
5837
|
+
* before the commit that moves to N+1, so the first row for this channel past
|
|
5838
|
+
* epoch N is decisive:
|
|
5839
|
+
* 1. A row of the record's group at or below `prior.epoch` (an epoch-N app
|
|
5840
|
+
* row, an old duplicate) → `normal`: decrypt at MAIN and ack as usual.
|
|
5841
|
+
* Nothing at or below N can move MAIN.
|
|
5842
|
+
* 2. A COMMIT at `commitEpoch` in the record's group is someone else's (the
|
|
5843
|
+
* server never fans a commit back to its sender). If it applies at MAIN,
|
|
5844
|
+
* that proves ours was lost: clear the record, THEN save MAIN (a crash
|
|
5845
|
+
* between them redelivers the commit), hook `not_landed`.
|
|
5846
|
+
* 3. A row framed at `commitEpoch` (an app row at `commitEpoch`, or a commit
|
|
5847
|
+
* at `commitEpoch + 1`): trial it on a throwaway manager imported from
|
|
5848
|
+
* `rec.state`. If it applies, the group is provably on OUR epoch (a
|
|
5849
|
+
* cryptographic proof, stronger than a 2xx): promote with the UNLOCKED
|
|
5850
|
+
* promotion inside this held lock (LOW-4: the queue is not reentrant),
|
|
5851
|
+
* then process the row normally.
|
|
5852
|
+
* 4. Anything else of the record's group (fits neither state, or is past
|
|
5853
|
+
* both) → `leave`: un-acked, no nack, no error count, no dead-letter.
|
|
5854
|
+
*
|
|
5855
|
+
* LOW-5: cases 2 and 3 key on `row.group_id === rec.groupId`. A supersede
|
|
5856
|
+
* record targets the NEW group and a bootstrap record has no MAIN, so no row
|
|
5857
|
+
* of ANOTHER group ever resolves either; case 2 needs MAIN in the record's
|
|
5858
|
+
* group, so it applies to add and remove records only, and a supersede or
|
|
5859
|
+
* bootstrap record resolves by case 3 or by resend. Rows of another group
|
|
5860
|
+
* (the supersede's old group, a retired group) take the `normal` paths:
|
|
5861
|
+
* `saveMainGuarded` keeps any of them from moving MAIN past the record.
|
|
5862
|
+
*
|
|
5863
|
+
* Throws propagate to the caller, which leaves the row un-acked and
|
|
5864
|
+
* uncounted (a failed promotion included).
|
|
5865
|
+
*/
|
|
5866
|
+
async resolveRowAgainstPending(channelId, row, ctx) {
|
|
5867
|
+
return this.locks.run(channelId, async () => {
|
|
5868
|
+
let rec;
|
|
5869
|
+
try {
|
|
5870
|
+
rec = await this.o.store.loadPendingCommit(channelId);
|
|
5871
|
+
} catch (e) {
|
|
5872
|
+
if (row.message_type === "application")
|
|
5873
|
+
return { kind: "normal" };
|
|
5874
|
+
throw e;
|
|
5875
|
+
}
|
|
5876
|
+
if (!rec) {
|
|
5877
|
+
return { kind: "normal" };
|
|
5878
|
+
}
|
|
5879
|
+
if (row.group_id !== rec.groupId)
|
|
5880
|
+
return { kind: "normal" };
|
|
5881
|
+
if (isPendingReservation(rec))
|
|
5882
|
+
return { kind: "leave" };
|
|
5883
|
+
const rowEpoch = typeof row.epoch === "number" ? row.epoch : void 0;
|
|
5884
|
+
if (rowEpoch === void 0)
|
|
5885
|
+
return { kind: "leave" };
|
|
5886
|
+
const mainInRecGroup = rec.prior !== null && rec.prior.groupId === rec.groupId;
|
|
5887
|
+
if (row.message_type === "commit" && rowEpoch === rec.commitEpoch) {
|
|
5888
|
+
if (!mainInRecGroup)
|
|
5889
|
+
return { kind: "leave" };
|
|
5890
|
+
const mgr = await this.manager(channelId);
|
|
5891
|
+
const at = tagOf(mgr);
|
|
5892
|
+
if (at.groupId !== rec.prior.groupId || at.epoch !== rec.prior.epoch)
|
|
5893
|
+
return { kind: "leave" };
|
|
5894
|
+
try {
|
|
5895
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
5896
|
+
} catch {
|
|
5897
|
+
return { kind: "leave" };
|
|
5898
|
+
}
|
|
5899
|
+
if (!await this.o.store.clearPendingCommit(channelId, pendingCommitKey(rec))) {
|
|
5900
|
+
this.managers.delete(channelId);
|
|
5901
|
+
return { kind: "leave" };
|
|
5902
|
+
}
|
|
5903
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
5904
|
+
this.recordDecryptSuccess(channelId);
|
|
5905
|
+
if (ctx)
|
|
5906
|
+
ctx.liveCommitEpoch = Number(mgr.epoch);
|
|
5907
|
+
await this.notifyPendingResolved(channelId, { op: rec.op, outcome: "not_landed", via: "resolver" });
|
|
5908
|
+
return { kind: "acked" };
|
|
5909
|
+
}
|
|
5910
|
+
const framedAtOurs = row.message_type === "application" && rowEpoch === rec.commitEpoch || row.message_type === "commit" && rowEpoch === rec.commitEpoch + 1;
|
|
5911
|
+
if (framedAtOurs) {
|
|
5912
|
+
const trial = new MLSGroupManager2(rec.state.ciphersuite);
|
|
5913
|
+
trial.importState(rec.state);
|
|
5914
|
+
let applies;
|
|
5915
|
+
if (row.message_type === "application") {
|
|
5916
|
+
applies = await this.trialOpenApplication(row, trial);
|
|
5917
|
+
} else {
|
|
5918
|
+
try {
|
|
5919
|
+
await trial.decrypt(hexToBytes(row.payload));
|
|
5920
|
+
applies = true;
|
|
5921
|
+
} catch {
|
|
5922
|
+
applies = false;
|
|
5923
|
+
}
|
|
5924
|
+
}
|
|
5925
|
+
if (!applies)
|
|
5926
|
+
return { kind: "leave" };
|
|
5927
|
+
const p = await this.promotePendingUnlocked(channelId, "resolver", rec);
|
|
5928
|
+
if (p !== "landed")
|
|
5929
|
+
return { kind: "normal" };
|
|
5930
|
+
if (row.message_type === "application") {
|
|
5931
|
+
return { kind: "entry", entry: await this.decryptApplicationRow(row, { lockHeld: true }) };
|
|
5932
|
+
}
|
|
5933
|
+
const mgr = await this.manager(channelId);
|
|
5934
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
5935
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
5936
|
+
this.recordDecryptSuccess(channelId);
|
|
5937
|
+
if (ctx)
|
|
5938
|
+
ctx.liveCommitEpoch = Number(mgr.epoch);
|
|
5939
|
+
return { kind: "acked" };
|
|
5940
|
+
}
|
|
5941
|
+
if (mainInRecGroup && rowEpoch <= rec.prior.epoch)
|
|
5942
|
+
return { kind: "normal" };
|
|
5943
|
+
return { kind: "leave" };
|
|
5944
|
+
});
|
|
5945
|
+
}
|
|
5946
|
+
/** C2 case 3: would this application row open under `mgr`'s exporter? A
|
|
5947
|
+
* pure trial: no custody, no success record, no ack. The SDK-5 binding
|
|
5948
|
+
* (envelope channel == server-attributed channel) is checked first. */
|
|
5949
|
+
async trialOpenApplication(row, mgr) {
|
|
5950
|
+
try {
|
|
5951
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
5952
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id)
|
|
5953
|
+
return false;
|
|
5954
|
+
const key = mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
|
|
5955
|
+
const aad = await buildAssociatedData({
|
|
5956
|
+
message_id: envelope.message_id,
|
|
5957
|
+
channel_id: envelope.channel_id,
|
|
5958
|
+
sender_id: envelope.sender_id,
|
|
5959
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
5960
|
+
replay_nonce: envelope.replay_nonce,
|
|
5961
|
+
policy_binding: envelope.policy_binding
|
|
5962
|
+
});
|
|
5963
|
+
openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key: await key, associatedData: aad, suite: this.suite });
|
|
5964
|
+
return true;
|
|
5965
|
+
} catch {
|
|
5966
|
+
return false;
|
|
5967
|
+
}
|
|
5968
|
+
}
|
|
5969
|
+
/**
|
|
5970
|
+
* Decrypt one application delivery row into an {@link InboxEntry} and take
|
|
5971
|
+
* {@link custody} of it. Throws on any parse / binding / AEAD failure; the
|
|
5972
|
+
* caller decides what a failure means.
|
|
5973
|
+
*
|
|
5974
|
+
* `lockHeld`: the committer 409 drain and the own-commit drain run INSIDE the
|
|
5975
|
+
* channel lock already (the queue is not reentrant), so they derive the key
|
|
5976
|
+
* without re-taking it. The SDK-5 binding check below guarantees
|
|
5977
|
+
* envelope.channel_id equals the row's server-attributed channel, which is
|
|
5978
|
+
* the channel whose lock is held.
|
|
5979
|
+
*
|
|
5980
|
+
* `keyFrom`: derive the key from THIS manager instead of the cached live one.
|
|
5981
|
+
* Only {@link custodyAppRowsBeforeOwnCommit} passes it: a transient manager
|
|
5982
|
+
* rebuilt from the store's committed (pre-commit, epoch N) state while the
|
|
5983
|
+
* cached manager is already advanced to N+1.
|
|
5984
|
+
*/
|
|
5985
|
+
async decryptApplicationRow(row, opts) {
|
|
5986
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
5987
|
+
if (row.connect_channel_id != null && envelope.channel_id !== row.connect_channel_id) {
|
|
5988
|
+
throw new Error(`envelope/row channel binding mismatch: row=${row.connect_channel_id} envelope=${envelope.channel_id}`);
|
|
5989
|
+
}
|
|
5990
|
+
const keyFrom = opts.keyFrom;
|
|
5991
|
+
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);
|
|
5992
|
+
const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
|
|
5993
|
+
const aad = await buildAssociatedData({
|
|
5994
|
+
message_id: envelope.message_id,
|
|
5995
|
+
channel_id: envelope.channel_id,
|
|
5996
|
+
sender_id: envelope.sender_id,
|
|
5997
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
5998
|
+
replay_nonce: envelope.replay_nonce,
|
|
5999
|
+
policy_binding: envelope.policy_binding
|
|
6000
|
+
});
|
|
6001
|
+
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
6002
|
+
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
6003
|
+
this.recordDecryptSuccess(appChannelId);
|
|
6004
|
+
const entry = {
|
|
6005
|
+
queueId: row.queue_id,
|
|
6006
|
+
messageId: envelope.message_id,
|
|
6007
|
+
channelId: appChannelId,
|
|
6008
|
+
plaintext,
|
|
6009
|
+
envelope,
|
|
6010
|
+
receivedAt: Date.now(),
|
|
6011
|
+
retryCount: 0,
|
|
6012
|
+
expires_at: row.expires_at,
|
|
6013
|
+
voiceMode: row.voice_mode ?? void 0,
|
|
6014
|
+
voiceDefaultResponder: row.voice_default_responder ?? void 0
|
|
6015
|
+
};
|
|
6016
|
+
this.custody.set(row.queue_id, entry);
|
|
6017
|
+
return entry;
|
|
6018
|
+
}
|
|
4739
6019
|
/**
|
|
4740
6020
|
* Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
|
|
4741
6021
|
*
|
|
@@ -4745,22 +6025,39 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4745
6025
|
* (which would re-acquire the same channel lock for that channel's commit rows
|
|
4746
6026
|
* and self-deadlock — the queue is not reentrant). This helper does the commit
|
|
4747
6027
|
* half of receiveOnce for ONLY the committer's own channel, without taking the
|
|
4748
|
-
* lock (the caller already holds it).
|
|
4749
|
-
*
|
|
4750
|
-
*
|
|
6028
|
+
* lock (the caller already holds it). Welcome rows are left for the join path,
|
|
6029
|
+
* exactly as receiveOnce leaves them. Rows of OTHER channels are untouched:
|
|
6030
|
+
* this drain never moves their epoch, so they stay decryptable.
|
|
6031
|
+
*
|
|
6032
|
+
* C1: application rows of THIS channel are decrypted in seq order, before
|
|
6033
|
+
* any commit behind them applies, which puts them in {@link custody} (never
|
|
6034
|
+
* acked here); the next receiveOnce serves them. Walking past them, as this
|
|
6035
|
+
* drain used to, then applying the commit behind them destroyed their key:
|
|
6036
|
+
* the redelivered row failed AEAD and was acked as permanentlySkipped. A row
|
|
6037
|
+
* that fails to decrypt here is left exactly as before (un-acked, not
|
|
6038
|
+
* counted), for receiveOnce to classify.
|
|
4751
6039
|
*/
|
|
4752
6040
|
async drainChannelCommitsUnlocked(deviceId, channelId) {
|
|
4753
6041
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
4754
6042
|
const acked = [];
|
|
4755
6043
|
for (const row of rows) {
|
|
4756
|
-
if (row.message_type !== "commit")
|
|
4757
|
-
continue;
|
|
4758
6044
|
if (row.connect_channel_id !== channelId)
|
|
4759
6045
|
continue;
|
|
6046
|
+
if (row.message_type === "application") {
|
|
6047
|
+
if (this.custody.has(row.queue_id))
|
|
6048
|
+
continue;
|
|
6049
|
+
try {
|
|
6050
|
+
await this.decryptApplicationRow(row, { lockHeld: true });
|
|
6051
|
+
} catch {
|
|
6052
|
+
}
|
|
6053
|
+
continue;
|
|
6054
|
+
}
|
|
6055
|
+
if (row.message_type !== "commit")
|
|
6056
|
+
continue;
|
|
4760
6057
|
try {
|
|
4761
6058
|
const mgr = await this.manager(channelId);
|
|
4762
6059
|
await mgr.decrypt(hexToBytes(row.payload));
|
|
4763
|
-
await this.
|
|
6060
|
+
await this.saveAdvancedOrEvict(channelId, mgr);
|
|
4764
6061
|
this.recordDecryptSuccess(channelId);
|
|
4765
6062
|
acked.push(row.queue_id);
|
|
4766
6063
|
} catch (err) {
|
|
@@ -4770,6 +6067,60 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
4770
6067
|
if (acked.length)
|
|
4771
6068
|
await this.o.backend.ackDelivery(deviceId, acked);
|
|
4772
6069
|
}
|
|
6070
|
+
/**
|
|
6071
|
+
* C1b: take custody of this channel's still-queued application rows at the
|
|
6072
|
+
* COMMITTED epoch, before a committer's OWN commit is persisted locally.
|
|
6073
|
+
*
|
|
6074
|
+
* The message key comes from the live exporter secret only. A committer that
|
|
6075
|
+
* persists its own commit N->N+1 while a peer's epoch-N application row sits
|
|
6076
|
+
* un-pulled in its queue can never decrypt that row again: its next
|
|
6077
|
+
* receiveOnce fails AEAD, classifies it permanentlySkipped and acks it.
|
|
6078
|
+
*
|
|
6079
|
+
* ORDERING (the committer-safety rule, B11): called only AFTER distribute has
|
|
6080
|
+
* succeeded and BEFORE the local save/promote, still inside the channel lock.
|
|
6081
|
+
* - After distribute: the server's group epoch is N+1, and the send path
|
|
6082
|
+
* rejects any application message claiming epoch < N+1
|
|
6083
|
+
* (validate_application_epoch, connect_messages.py step 6). So the set of
|
|
6084
|
+
* epoch-N rows is closed; one pull sees all of them (residual: a send whose
|
|
6085
|
+
* request read epoch N before our commit landed but commits after this pull;
|
|
6086
|
+
* see the C1 report).
|
|
6087
|
+
* - It is NOT a receiveOnce and applies NO commit: it only reads the queue
|
|
6088
|
+
* and derives exporter keys. The in-flight commit is already accepted, so
|
|
6089
|
+
* nothing here can race it or move the tree between resolve and
|
|
6090
|
+
* distribute; the cached (advanced) manager is never touched.
|
|
6091
|
+
* - The key comes from a TRANSIENT manager rebuilt from the store's MAIN
|
|
6092
|
+
* slot, which still holds the pre-commit epoch-N state (add: saved only
|
|
6093
|
+
* after this; removes: load() never returns staging). It is dropped when
|
|
6094
|
+
* this returns; no epoch-N secret is retained beyond the state that is
|
|
6095
|
+
* already at rest until the save/promote below.
|
|
6096
|
+
*
|
|
6097
|
+
* Best effort: a failed pull is retried once, then the commit is persisted
|
|
6098
|
+
* anyway (not persisting an accepted commit would diverge this device). Rows
|
|
6099
|
+
* that do not decrypt at N are left untouched for receiveOnce.
|
|
6100
|
+
*/
|
|
6101
|
+
async custodyAppRowsBeforeOwnCommit(channelId, committedEpoch) {
|
|
6102
|
+
try {
|
|
6103
|
+
const saved = await this.o.store.load(channelId);
|
|
6104
|
+
if (!saved)
|
|
6105
|
+
return;
|
|
6106
|
+
const atCommitted = new MLSGroupManager2(saved.ciphersuite);
|
|
6107
|
+
atCommitted.importState(saved);
|
|
6108
|
+
if (Number(atCommitted.epoch) !== committedEpoch)
|
|
6109
|
+
return;
|
|
6110
|
+
let rows;
|
|
6111
|
+
for (let attempt = 0; attempt < 2 && !rows; attempt++) {
|
|
6112
|
+
rows = await this.o.backend.pullDelivery(this.o.deviceId).catch(() => void 0);
|
|
6113
|
+
}
|
|
6114
|
+
for (const row of rows ?? []) {
|
|
6115
|
+
if (row.message_type !== "application" || row.connect_channel_id !== channelId)
|
|
6116
|
+
continue;
|
|
6117
|
+
if (this.custody.has(row.queue_id))
|
|
6118
|
+
continue;
|
|
6119
|
+
await this.decryptApplicationRow(row, { lockHeld: true, keyFrom: atCommitted }).catch(() => void 0);
|
|
6120
|
+
}
|
|
6121
|
+
} catch {
|
|
6122
|
+
}
|
|
6123
|
+
}
|
|
4773
6124
|
/** Delegate so the receive pump can ack without reaching the private backend. */
|
|
4774
6125
|
async ackDelivery(deviceId, queueIds) {
|
|
4775
6126
|
await this.o.backend.ackDelivery(deviceId, queueIds);
|
|
@@ -5293,6 +6644,12 @@ function describeType(v) {
|
|
|
5293
6644
|
return "array";
|
|
5294
6645
|
return typeof v;
|
|
5295
6646
|
}
|
|
6647
|
+
function isAbsence404(err) {
|
|
6648
|
+
return err instanceof MlsBackendHttpError && err.status === 404;
|
|
6649
|
+
}
|
|
6650
|
+
function failureFields(err) {
|
|
6651
|
+
return err instanceof MlsBackendHttpError ? { status: err.status } : {};
|
|
6652
|
+
}
|
|
5296
6653
|
function parseGroupRoster(endpoint, channelId, value) {
|
|
5297
6654
|
if (!Array.isArray(value)) {
|
|
5298
6655
|
throw new MalformedGroupRosterError(endpoint, channelId, describeType(value));
|
|
@@ -5328,7 +6685,7 @@ var RestMlsBackend = class {
|
|
|
5328
6685
|
async get(path) {
|
|
5329
6686
|
const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
|
|
5330
6687
|
if (!res.ok)
|
|
5331
|
-
throw new
|
|
6688
|
+
throw new MlsBackendHttpError(path, res.status);
|
|
5332
6689
|
return await res.json();
|
|
5333
6690
|
}
|
|
5334
6691
|
async getRegisterChallenge() {
|
|
@@ -5383,25 +6740,68 @@ var RestMlsBackend = class {
|
|
|
5383
6740
|
member_device_ids: parseGroupRoster("init", channelId, j.member_device_ids),
|
|
5384
6741
|
// Back-compat: servers that predate the created field return nothing;
|
|
5385
6742
|
// treat absence as true (they always created a fresh group).
|
|
5386
|
-
created: j.created === void 0 ? true : Boolean(j.created)
|
|
6743
|
+
created: j.created === void 0 ? true : Boolean(j.created),
|
|
6744
|
+
// C2 T3b: passed through ONLY as a real boolean. Absent (a backend that
|
|
6745
|
+
// predates the field) or any other type stays undefined, which the
|
|
6746
|
+
// provider reads as ATTESTED (fail closed). Never coerced: `Boolean("false")`
|
|
6747
|
+
// would read a malformed value as attested-true, and a default of `false`
|
|
6748
|
+
// would read a missing one as adoptable.
|
|
6749
|
+
...typeof j.reset_attested === "boolean" ? { reset_attested: j.reset_attested } : {}
|
|
5387
6750
|
};
|
|
5388
6751
|
}
|
|
5389
6752
|
async reinitConnectChannelGroup() {
|
|
5390
6753
|
throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
|
|
5391
6754
|
}
|
|
6755
|
+
/**
|
|
6756
|
+
* C2 T3e (item 1): does its own fetch, mirroring {@link distribute}, so a
|
|
6757
|
+
* non-2xx becomes a typed {@link MlsBackendHttpError} instead of a plain
|
|
6758
|
+
* `Error` whose status lives only in a `failed: <status>` message. Before
|
|
6759
|
+
* this, `resetSessionSupersede`'s `isDefiniteReinitRefusal` classified the
|
|
6760
|
+
* outcome by regex-parsing that text, which is safe only as long as
|
|
6761
|
+
* neither `post()` nor the web's `makeAuthedFetch` ever retries this call —
|
|
6762
|
+
* a retry would replay the request and the classifier has no way to know
|
|
6763
|
+
* that happened. Typing the error lets the classifier read `instanceof
|
|
6764
|
+
* MlsBackendHttpError` + `.status` directly, matching every other C2
|
|
6765
|
+
* definite-refusal site (`isSupersededRefusal`, `isEpochConflict`). The
|
|
6766
|
+
* message is `<path> failed: <status>` — no body text (ids stay out of
|
|
6767
|
+
* logs) — which is SHORTER than what `post()` used to throw (that included
|
|
6768
|
+
* the raw response body). PR #681 review: the web's `friendlyResetError`
|
|
6769
|
+
* used to substring-match `surviving_device_exists` /
|
|
6770
|
+
* `reset_member_set_incomplete` / `bad_reset_attestation` out of that body
|
|
6771
|
+
* text; it now reads them off this error's typed `.code` field instead
|
|
6772
|
+
* (with the old substring match kept as a fallback for other error shapes).
|
|
6773
|
+
*/
|
|
5392
6774
|
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
5393
6775
|
const committer = memberDeviceIds[0];
|
|
5394
6776
|
if (!committer)
|
|
5395
6777
|
throw new Error("resetReinitConnectChannelGroup: empty member set");
|
|
5396
|
-
const
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
6778
|
+
const path = `/api/v1/mls/connect-channels/${channelId}/reinit`;
|
|
6779
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
6780
|
+
method: "POST",
|
|
6781
|
+
headers: this.headers(),
|
|
6782
|
+
body: JSON.stringify({
|
|
6783
|
+
member_device_ids: memberDeviceIds,
|
|
6784
|
+
committer_device_id: committer,
|
|
6785
|
+
mode: "reset",
|
|
6786
|
+
new_group_id: att.newGroupId,
|
|
6787
|
+
reset_attestation_signature: att.signatureHex,
|
|
6788
|
+
reset_attestation_nonce: att.nonceHex,
|
|
6789
|
+
reset_attestation_content: att.signedContentHex
|
|
6790
|
+
})
|
|
5404
6791
|
});
|
|
6792
|
+
if (!res.ok) {
|
|
6793
|
+
let code;
|
|
6794
|
+
let reason;
|
|
6795
|
+
try {
|
|
6796
|
+
const body = JSON.parse(await res.text());
|
|
6797
|
+
code = parseErrorCode(body);
|
|
6798
|
+
const r = body !== null && typeof body === "object" ? body.reason : void 0;
|
|
6799
|
+
reason = typeof r === "string" ? r : void 0;
|
|
6800
|
+
} catch {
|
|
6801
|
+
}
|
|
6802
|
+
throw new MlsBackendHttpError(path, res.status, code, reason);
|
|
6803
|
+
}
|
|
6804
|
+
const j = res.status === 204 ? {} : await res.json();
|
|
5405
6805
|
return {
|
|
5406
6806
|
group_id: String(j.group_id),
|
|
5407
6807
|
current_epoch: Number(j.current_epoch),
|
|
@@ -5424,7 +6824,10 @@ var RestMlsBackend = class {
|
|
|
5424
6824
|
try {
|
|
5425
6825
|
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
|
|
5426
6826
|
return String(j.key_package);
|
|
5427
|
-
} catch {
|
|
6827
|
+
} catch (err) {
|
|
6828
|
+
if (!isAbsence404(err)) {
|
|
6829
|
+
writeServiceLog2("warn", "keypackage.by_id_fetch_failed", failureFields(err), err);
|
|
6830
|
+
}
|
|
5428
6831
|
return null;
|
|
5429
6832
|
}
|
|
5430
6833
|
}
|
|
@@ -5440,15 +6843,44 @@ var RestMlsBackend = class {
|
|
|
5440
6843
|
if (Number.isNaN(ms))
|
|
5441
6844
|
return null;
|
|
5442
6845
|
return new Date(ms);
|
|
5443
|
-
} catch {
|
|
6846
|
+
} catch (err) {
|
|
6847
|
+
if (!isAbsence404(err)) {
|
|
6848
|
+
writeServiceLog2("warn", "keypackage.expiry_fetch_failed", failureFields(err), err);
|
|
6849
|
+
}
|
|
5444
6850
|
return null;
|
|
5445
6851
|
}
|
|
5446
6852
|
}
|
|
6853
|
+
/**
|
|
6854
|
+
* C2 T3 (spec §4.3): `/distribute` does its own fetch so a non-2xx becomes a
|
|
6855
|
+
* typed {@link MlsBackendHttpError} carrying the body's error `code` (and a
|
|
6856
|
+
* 409's `reason`), which the pending-commit outcome classifier reads. `post()`
|
|
6857
|
+
* is left alone: its other callers keep their current plain-`Error` shape.
|
|
6858
|
+
*
|
|
6859
|
+
* The message is `<path> failed: <status>` with NO body text (ids stay out of
|
|
6860
|
+
* logs; `friendlyResetError` matches the status substring). A network failure
|
|
6861
|
+
* or abort propagates as the fetch threw it, which the classifier reads as
|
|
6862
|
+
* ambiguous. A 2xx resolves whatever its body is (`replayed: true` included):
|
|
6863
|
+
* 2xx means landed, and the SDK never reads the success body.
|
|
6864
|
+
*/
|
|
5447
6865
|
async distribute(groupId, req) {
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
6866
|
+
const path = `/api/v1/mls/groups/${groupId}/distribute`;
|
|
6867
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
6868
|
+
method: "POST",
|
|
6869
|
+
headers: this.headers(),
|
|
6870
|
+
body: JSON.stringify({ ...req, group_id: groupId })
|
|
5451
6871
|
});
|
|
6872
|
+
if (res.ok)
|
|
6873
|
+
return;
|
|
6874
|
+
let code;
|
|
6875
|
+
let reason;
|
|
6876
|
+
try {
|
|
6877
|
+
const body = JSON.parse(await res.text());
|
|
6878
|
+
code = parseErrorCode(body);
|
|
6879
|
+
const r = body !== null && typeof body === "object" ? body.reason : void 0;
|
|
6880
|
+
reason = typeof r === "string" ? r : void 0;
|
|
6881
|
+
} catch {
|
|
6882
|
+
}
|
|
6883
|
+
throw new MlsBackendHttpError(path, res.status, code, reason);
|
|
5452
6884
|
}
|
|
5453
6885
|
async pullDelivery(deviceId) {
|
|
5454
6886
|
const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
|
|
@@ -5500,7 +6932,8 @@ var RestMlsBackend = class {
|
|
|
5500
6932
|
return String(j.token);
|
|
5501
6933
|
}
|
|
5502
6934
|
async releaseCommitLease(channelId, token) {
|
|
5503
|
-
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(() => {
|
|
6935
|
+
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) => {
|
|
6936
|
+
writeServiceLog2("warn", "committer.lease_release_failed", {}, err);
|
|
5504
6937
|
});
|
|
5505
6938
|
}
|
|
5506
6939
|
async fetchResetAttestation(groupId) {
|
|
@@ -5511,7 +6944,10 @@ var RestMlsBackend = class {
|
|
|
5511
6944
|
signatureHex: String(j.signature_hex),
|
|
5512
6945
|
signerIdentityId: String(j.signer_identity_id)
|
|
5513
6946
|
};
|
|
5514
|
-
} catch {
|
|
6947
|
+
} catch (err) {
|
|
6948
|
+
if (!isAbsence404(err)) {
|
|
6949
|
+
writeServiceLog2("warn", "supersede.attestation_fetch_failed", failureFields(err), err);
|
|
6950
|
+
}
|
|
5515
6951
|
return null;
|
|
5516
6952
|
}
|
|
5517
6953
|
}
|
|
@@ -5519,6 +6955,7 @@ var RestMlsBackend = class {
|
|
|
5519
6955
|
|
|
5520
6956
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
5521
6957
|
import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
6958
|
+
var DELIVERY_PULL_LIMIT = 100;
|
|
5522
6959
|
var FakeConnectBackend = class _FakeConnectBackend {
|
|
5523
6960
|
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
5524
6961
|
kps = /* @__PURE__ */ new Map();
|
|
@@ -5649,7 +7086,10 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5649
7086
|
// Real semantics: created=true on first call (group was just created),
|
|
5650
7087
|
// created=false on idempotent return (group already existed). Without
|
|
5651
7088
|
// this, race tests silently pass wrong (fake is laxer than the server).
|
|
5652
|
-
created: !alreadyExists
|
|
7089
|
+
created: !alreadyExists,
|
|
7090
|
+
// C2 T3b: mirrors T1b's `reset_attested` (the group carries an owner
|
|
7091
|
+
// reset attestation). Always present, as T1b's field has no default.
|
|
7092
|
+
reset_attested: this.resetAttestations.has(groupId)
|
|
5653
7093
|
};
|
|
5654
7094
|
}
|
|
5655
7095
|
async reinitConnectChannelGroup(channelId, memberDeviceIds) {
|
|
@@ -5717,16 +7157,58 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5717
7157
|
}))
|
|
5718
7158
|
};
|
|
5719
7159
|
}
|
|
7160
|
+
/** C2 T3: every `/distribute` request, in order, including resends and
|
|
7161
|
+
* injected failures, so a test can assert exactly what was sent. */
|
|
7162
|
+
distributeCalls = [];
|
|
7163
|
+
/** C2 T3: one fault per upcoming `distribute` call, FIFO. See
|
|
7164
|
+
* {@link failNextDistribute}. */
|
|
7165
|
+
distributeFaults = [];
|
|
7166
|
+
/**
|
|
7167
|
+
* C2 T3 test seam: make the NEXT `distribute` call fail once.
|
|
7168
|
+
* - `{status, code?, reason?}`: a typed `MlsBackendHttpError`, exactly what
|
|
7169
|
+
* `RestMlsBackend.distribute` throws for that response body.
|
|
7170
|
+
* - `{network: true}`: a `TypeError`, what `fetch` throws on a network
|
|
7171
|
+
* failure.
|
|
7172
|
+
* - `afterApply: true` on either: the commit is APPLIED first (it landed)
|
|
7173
|
+
* and the failure is what the client sees, i.e. a lost 2xx.
|
|
7174
|
+
* Call it several times to queue several faults.
|
|
7175
|
+
*/
|
|
7176
|
+
failNextDistribute(fault) {
|
|
7177
|
+
this.distributeFaults.push(fault);
|
|
7178
|
+
}
|
|
7179
|
+
static distributePath(groupId) {
|
|
7180
|
+
return `/api/v1/mls/groups/${groupId}/distribute`;
|
|
7181
|
+
}
|
|
5720
7182
|
async distribute(groupId, req) {
|
|
7183
|
+
this.distributeCalls.push({ groupId, req: structuredClone(req) });
|
|
7184
|
+
const fault = this.distributeFaults.shift();
|
|
7185
|
+
if (fault && !fault.afterApply)
|
|
7186
|
+
throw _FakeConnectBackend.faultError(groupId, fault);
|
|
7187
|
+
await this.distributeApply(groupId, req);
|
|
7188
|
+
if (fault)
|
|
7189
|
+
throw _FakeConnectBackend.faultError(groupId, fault);
|
|
7190
|
+
}
|
|
7191
|
+
static faultError(groupId, fault) {
|
|
7192
|
+
if ("network" in fault)
|
|
7193
|
+
return new TypeError("fetch failed");
|
|
7194
|
+
return new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), fault.status, fault.code, fault.reason);
|
|
7195
|
+
}
|
|
7196
|
+
async distributeApply(groupId, req) {
|
|
5721
7197
|
const channelId = this.channelByGroup.get(groupId);
|
|
5722
7198
|
if (!channelId)
|
|
5723
7199
|
throw new Error(`FakeConnectBackend.distribute: unknown group ${groupId}`);
|
|
7200
|
+
if (req.commit) {
|
|
7201
|
+
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));
|
|
7202
|
+
if (hit)
|
|
7203
|
+
return;
|
|
7204
|
+
}
|
|
7205
|
+
if (req.commit && this.groupIdByChannel.get(channelId) !== groupId) {
|
|
7206
|
+
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "superseded");
|
|
7207
|
+
}
|
|
5724
7208
|
if (req.commit && req.expected_epoch != null) {
|
|
5725
7209
|
const current = this.epochByChannel.get(channelId) ?? 0;
|
|
5726
7210
|
if (req.expected_epoch !== current) {
|
|
5727
|
-
|
|
5728
|
-
e.status = 409;
|
|
5729
|
-
throw e;
|
|
7211
|
+
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
|
|
5730
7212
|
}
|
|
5731
7213
|
}
|
|
5732
7214
|
if (req.commit) {
|
|
@@ -5789,7 +7271,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
5789
7271
|
}
|
|
5790
7272
|
}
|
|
5791
7273
|
async pullDelivery(deviceId) {
|
|
5792
|
-
return this.queues.get(deviceId) ?? [];
|
|
7274
|
+
return (this.queues.get(deviceId) ?? []).slice(0, DELIVERY_PULL_LIMIT);
|
|
5793
7275
|
}
|
|
5794
7276
|
async ackDelivery(deviceId, queueIds) {
|
|
5795
7277
|
const queue = this.queues.get(deviceId);
|
|
@@ -5972,8 +7454,10 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
5972
7454
|
import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
|
|
5973
7455
|
|
|
5974
7456
|
// ../connect-mls-store/dist/schema.js
|
|
5975
|
-
var SCHEMA_VERSION =
|
|
7457
|
+
var SCHEMA_VERSION = 9;
|
|
7458
|
+
var BUSY_TIMEOUT_MS = 5e3;
|
|
5976
7459
|
function openSchema(db) {
|
|
7460
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
5977
7461
|
db.exec("PRAGMA journal_mode=WAL");
|
|
5978
7462
|
const row = db.prepare("PRAGMA user_version").get();
|
|
5979
7463
|
if (row.user_version === 0) {
|
|
@@ -6075,8 +7559,58 @@ function openSchema(db) {
|
|
|
6075
7559
|
);
|
|
6076
7560
|
`);
|
|
6077
7561
|
}
|
|
7562
|
+
if (row.user_version < 8) {
|
|
7563
|
+
db.exec(`
|
|
7564
|
+
CREATE TABLE IF NOT EXISTS runtime_inbox (
|
|
7565
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7566
|
+
identity_id TEXT NOT NULL,
|
|
7567
|
+
message_id TEXT NOT NULL,
|
|
7568
|
+
sealed_message BLOB NOT NULL,
|
|
7569
|
+
received_at INTEGER NOT NULL,
|
|
7570
|
+
UNIQUE (identity_id, message_id)
|
|
7571
|
+
);
|
|
7572
|
+
`);
|
|
7573
|
+
db.exec(`
|
|
7574
|
+
CREATE TABLE IF NOT EXISTS runtime_reply_receipt (
|
|
7575
|
+
identity_id TEXT NOT NULL,
|
|
7576
|
+
in_reply_to TEXT NOT NULL,
|
|
7577
|
+
channel_id TEXT NOT NULL,
|
|
7578
|
+
reply_message_id TEXT NOT NULL,
|
|
7579
|
+
replied_at INTEGER NOT NULL,
|
|
7580
|
+
PRIMARY KEY (identity_id, in_reply_to)
|
|
7581
|
+
);
|
|
7582
|
+
`);
|
|
7583
|
+
}
|
|
7584
|
+
if (row.user_version < 9) {
|
|
7585
|
+
db.exec(`
|
|
7586
|
+
CREATE TABLE IF NOT EXISTS mls_pending_commit (
|
|
7587
|
+
identity_id TEXT NOT NULL,
|
|
7588
|
+
channel_id TEXT NOT NULL,
|
|
7589
|
+
sealed_state BLOB NOT NULL,
|
|
7590
|
+
sealed_request BLOB NOT NULL,
|
|
7591
|
+
created_at INTEGER NOT NULL,
|
|
7592
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
7593
|
+
);
|
|
7594
|
+
`);
|
|
7595
|
+
db.exec(`
|
|
7596
|
+
CREATE TABLE IF NOT EXISTS mls_owed_followup (
|
|
7597
|
+
identity_id TEXT NOT NULL,
|
|
7598
|
+
channel_id TEXT NOT NULL,
|
|
7599
|
+
sealed_followup BLOB NOT NULL,
|
|
7600
|
+
created_at INTEGER NOT NULL,
|
|
7601
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
7602
|
+
);
|
|
7603
|
+
`);
|
|
7604
|
+
}
|
|
7605
|
+
ensureRuntimeInboxDeliveryAttempts(db);
|
|
6078
7606
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
6079
7607
|
}
|
|
7608
|
+
function ensureRuntimeInboxDeliveryAttempts(db) {
|
|
7609
|
+
const cols = db.prepare("PRAGMA table_info(runtime_inbox)").all();
|
|
7610
|
+
if (cols.length === 0 || cols.some((c) => c.name === "delivery_attempts"))
|
|
7611
|
+
return;
|
|
7612
|
+
db.exec("ALTER TABLE runtime_inbox ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0");
|
|
7613
|
+
}
|
|
6080
7614
|
|
|
6081
7615
|
// ../connect-mls-store/dist/errors.js
|
|
6082
7616
|
var MlsStoreCorruptError = class extends Error {
|
|
@@ -6114,10 +7648,24 @@ function decodeBundle(bytes) {
|
|
|
6114
7648
|
}
|
|
6115
7649
|
};
|
|
6116
7650
|
}
|
|
7651
|
+
function sameRow(a, b) {
|
|
7652
|
+
if (!a || !b)
|
|
7653
|
+
return a === b;
|
|
7654
|
+
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);
|
|
7655
|
+
}
|
|
7656
|
+
function sameBytes(a, b) {
|
|
7657
|
+
if (!a || !b)
|
|
7658
|
+
return a === b;
|
|
7659
|
+
return Buffer.compare(a, b) === 0;
|
|
7660
|
+
}
|
|
7661
|
+
function sameTag(prior, tag) {
|
|
7662
|
+
return prior !== null && prior.groupId === tag.groupId && prior.epoch === tag.epoch;
|
|
7663
|
+
}
|
|
6117
7664
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
6118
7665
|
var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
|
|
6119
7666
|
var DEFAULT_MEMORY_FACT_RETENTION_K = 200;
|
|
6120
7667
|
var DEFAULT_MEMORY_FACT_RETENTION_MS = 90 * DAY_MS;
|
|
7668
|
+
var RUNTIME_REPLY_RECEIPT_MAX = 5e3;
|
|
6121
7669
|
var SqliteMlsStateStore = class {
|
|
6122
7670
|
db;
|
|
6123
7671
|
id;
|
|
@@ -6182,6 +7730,9 @@ var SqliteMlsStateStore = class {
|
|
|
6182
7730
|
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
|
|
6183
7731
|
}
|
|
6184
7732
|
async unseal(blob) {
|
|
7733
|
+
return this.unsealJson(blob);
|
|
7734
|
+
}
|
|
7735
|
+
async unsealJson(blob) {
|
|
6185
7736
|
let bytes;
|
|
6186
7737
|
try {
|
|
6187
7738
|
bytes = await this.cipher.open(blob);
|
|
@@ -6251,7 +7802,7 @@ var SqliteMlsStateStore = class {
|
|
|
6251
7802
|
try {
|
|
6252
7803
|
bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
|
|
6253
7804
|
} catch (e) {
|
|
6254
|
-
|
|
7805
|
+
writeServiceLog2("warn", "store.pending_bundle_open_failed", {}, e);
|
|
6255
7806
|
return null;
|
|
6256
7807
|
}
|
|
6257
7808
|
this.bundles.set(ref, bundle);
|
|
@@ -6366,6 +7917,96 @@ var SqliteMlsStateStore = class {
|
|
|
6366
7917
|
const rows = this.db.prepare("SELECT DISTINCT enclave_id FROM channel_meta WHERE identity_id=? AND enclave_id IS NOT NULL").all(this.id);
|
|
6367
7918
|
return rows.map((r) => r.enclave_id);
|
|
6368
7919
|
}
|
|
7920
|
+
/**
|
|
7921
|
+
* Commit one decrypted daemon→runtime frame before the backend delivery row
|
|
7922
|
+
* is acknowledged. The payload is sealed at rest. A redelivery is opened and
|
|
7923
|
+
* compared so a same-id/different-payload collision becomes a loud refusal
|
|
7924
|
+
* without persisting a plaintext-derived digest.
|
|
7925
|
+
*/
|
|
7926
|
+
async stageRuntimeInbox(messageId, payload, receivedAt = Date.now()) {
|
|
7927
|
+
const prior = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
|
|
7928
|
+
if (prior) {
|
|
7929
|
+
const priorPayload = await this.openRuntimeInboxPayload(prior.sealed_message);
|
|
7930
|
+
if (!Buffer.from(priorPayload).equals(Buffer.from(payload))) {
|
|
7931
|
+
throw new Error("runtime inbox message-id payload conflict");
|
|
7932
|
+
}
|
|
7933
|
+
return false;
|
|
7934
|
+
}
|
|
7935
|
+
const sealed = await this.cipher.seal(payload);
|
|
7936
|
+
const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
|
|
7937
|
+
(identity_id, message_id, sealed_message, received_at)
|
|
7938
|
+
VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
|
|
7939
|
+
if (info.changes === 0) {
|
|
7940
|
+
const raced = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
|
|
7941
|
+
const racedPayload = await this.openRuntimeInboxPayload(raced.sealed_message);
|
|
7942
|
+
if (!Buffer.from(racedPayload).equals(Buffer.from(payload))) {
|
|
7943
|
+
throw new Error("runtime inbox message-id payload conflict");
|
|
7944
|
+
}
|
|
7945
|
+
return false;
|
|
7946
|
+
}
|
|
7947
|
+
return true;
|
|
7948
|
+
}
|
|
7949
|
+
async loadRuntimeInbox() {
|
|
7950
|
+
const rows = this.db.prepare(`SELECT message_id, sealed_message, received_at, delivery_attempts
|
|
7951
|
+
FROM runtime_inbox WHERE identity_id=? ORDER BY seq ASC`).all(this.id);
|
|
7952
|
+
const out = [];
|
|
7953
|
+
for (const row of rows) {
|
|
7954
|
+
const payload = await this.openRuntimeInboxPayload(row.sealed_message);
|
|
7955
|
+
out.push({
|
|
7956
|
+
messageId: row.message_id,
|
|
7957
|
+
payload,
|
|
7958
|
+
receivedAt: row.received_at,
|
|
7959
|
+
deliveryAttempts: Number(row.delivery_attempts)
|
|
7960
|
+
});
|
|
7961
|
+
}
|
|
7962
|
+
return out;
|
|
7963
|
+
}
|
|
7964
|
+
/**
|
|
7965
|
+
* Count one delivery of a staged runtime frame and return the new total.
|
|
7966
|
+
* Returns 0 when the message is not staged (already acknowledged, or never
|
|
7967
|
+
* durably handed off), so the caller can fall back to a process-local count.
|
|
7968
|
+
* Content-free: only the counter changes, never the sealed payload.
|
|
7969
|
+
*/
|
|
7970
|
+
noteRuntimeInboxDelivery(messageId) {
|
|
7971
|
+
const row = this.db.prepare(`UPDATE runtime_inbox SET delivery_attempts = delivery_attempts + 1
|
|
7972
|
+
WHERE identity_id=? AND message_id=?
|
|
7973
|
+
RETURNING delivery_attempts`).get(this.id, messageId);
|
|
7974
|
+
return row ? Number(row.delivery_attempts) : 0;
|
|
7975
|
+
}
|
|
7976
|
+
async openRuntimeInboxPayload(sealed) {
|
|
7977
|
+
try {
|
|
7978
|
+
return await this.cipher.open(sealed);
|
|
7979
|
+
} catch (error) {
|
|
7980
|
+
throw new MlsStoreSealError(String(error?.message ?? error));
|
|
7981
|
+
}
|
|
7982
|
+
}
|
|
7983
|
+
ackRuntimeInbox(messageId) {
|
|
7984
|
+
return this.db.prepare("DELETE FROM runtime_inbox WHERE identity_id=? AND message_id=?").run(this.id, messageId).changes === 1;
|
|
7985
|
+
}
|
|
7986
|
+
recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId, repliedAt = Date.now()) {
|
|
7987
|
+
this.db.prepare(`INSERT INTO runtime_reply_receipt
|
|
7988
|
+
(identity_id, in_reply_to, channel_id, reply_message_id, replied_at)
|
|
7989
|
+
VALUES (?, ?, ?, ?, ?)
|
|
7990
|
+
ON CONFLICT(identity_id, in_reply_to) DO UPDATE SET
|
|
7991
|
+
channel_id=excluded.channel_id,
|
|
7992
|
+
reply_message_id=excluded.reply_message_id,
|
|
7993
|
+
replied_at=excluded.replied_at`).run(this.id, inReplyTo, channelId, replyMessageId, repliedAt);
|
|
7994
|
+
const receiptCount = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_receipt WHERE identity_id=?").get(this.id);
|
|
7995
|
+
if (receiptCount.n > RUNTIME_REPLY_RECEIPT_MAX) {
|
|
7996
|
+
this.db.prepare(`DELETE FROM runtime_reply_receipt
|
|
7997
|
+
WHERE identity_id=? AND rowid IN (
|
|
7998
|
+
SELECT rowid FROM runtime_reply_receipt
|
|
7999
|
+
WHERE identity_id=?
|
|
8000
|
+
ORDER BY replied_at DESC, rowid DESC
|
|
8001
|
+
LIMIT -1 OFFSET ?
|
|
8002
|
+
)`).run(this.id, this.id, RUNTIME_REPLY_RECEIPT_MAX);
|
|
8003
|
+
}
|
|
8004
|
+
return { in_reply_to: inReplyTo, channel_id: channelId, reply_message_id: replyMessageId, replied_at: repliedAt };
|
|
8005
|
+
}
|
|
8006
|
+
runtimeReplyReceipt(inReplyTo) {
|
|
8007
|
+
return this.db.prepare(`SELECT in_reply_to, channel_id, reply_message_id, replied_at
|
|
8008
|
+
FROM runtime_reply_receipt WHERE identity_id=? AND in_reply_to=?`).get(this.id, inReplyTo) ?? null;
|
|
8009
|
+
}
|
|
6369
8010
|
async recordMemory(input) {
|
|
6370
8011
|
const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
|
|
6371
8012
|
if (!dedupKey)
|
|
@@ -6491,6 +8132,231 @@ var SqliteMlsStateStore = class {
|
|
|
6491
8132
|
}
|
|
6492
8133
|
}
|
|
6493
8134
|
}
|
|
8135
|
+
// ── C2 pending commit (schema v9) ─────────────────────────────────────
|
|
8136
|
+
// One sealed record per (identity, channel) in mls_pending_commit, and the
|
|
8137
|
+
// owed follow-up slot of a landed commit in mls_owed_followup. The
|
|
8138
|
+
// reference semantics are InMemoryMlsStateStore (connect-sdk-ts
|
|
8139
|
+
// src/mls/store.ts); the shared conformance suite pins them.
|
|
8140
|
+
//
|
|
8141
|
+
// THE AWAIT TRAP. seal/open are async. An await inside an open
|
|
8142
|
+
// DatabaseSync transaction lets another async call on this connection run
|
|
8143
|
+
// statements inside it, and another process can write between any read
|
|
8144
|
+
// and BEGIN. So every guarded method reads the record bytes, does ALL of
|
|
8145
|
+
// its crypto, and only then runs BEGIN IMMEDIATE, re-reads the record and
|
|
8146
|
+
// compares BYTES with what it read before the await (a reseal of the same
|
|
8147
|
+
// content has new bytes, so any rewrite is seen). Mismatch → ROLLBACK and
|
|
8148
|
+
// retry the whole method once; a second mismatch refuses. Inside the
|
|
8149
|
+
// transaction there is no await, so the decision and the write are one
|
|
8150
|
+
// step against every writer (MEDIUM-1).
|
|
8151
|
+
async sealJson(v) {
|
|
8152
|
+
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(v)));
|
|
8153
|
+
}
|
|
8154
|
+
readPendingRow(channelId) {
|
|
8155
|
+
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);
|
|
8156
|
+
}
|
|
8157
|
+
async openPendingRow(row) {
|
|
8158
|
+
const meta = await this.unsealJson(row.sealed_request);
|
|
8159
|
+
const state = await this.unseal(row.sealed_state);
|
|
8160
|
+
const rec = {
|
|
8161
|
+
groupId: meta.groupId,
|
|
8162
|
+
op: meta.op,
|
|
8163
|
+
request: meta.request,
|
|
8164
|
+
state,
|
|
8165
|
+
commitEpoch: meta.commitEpoch,
|
|
8166
|
+
prior: meta.prior,
|
|
8167
|
+
createdAt: Number(row.created_at)
|
|
8168
|
+
};
|
|
8169
|
+
if (meta.followUp !== void 0)
|
|
8170
|
+
rec.followUp = meta.followUp;
|
|
8171
|
+
return rec;
|
|
8172
|
+
}
|
|
8173
|
+
/** Run `fn` inside BEGIN IMMEDIATE. `fn` is synchronous by type: nothing
|
|
8174
|
+
* can interleave with it. Any throw rolls back and propagates. */
|
|
8175
|
+
immediate(fn) {
|
|
8176
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
8177
|
+
try {
|
|
8178
|
+
const result = fn();
|
|
8179
|
+
this.db.exec("COMMIT");
|
|
8180
|
+
return result;
|
|
8181
|
+
} catch (e) {
|
|
8182
|
+
this.db.exec("ROLLBACK");
|
|
8183
|
+
throw e;
|
|
8184
|
+
}
|
|
8185
|
+
}
|
|
8186
|
+
async savePendingCommit(channelId, rec, opts) {
|
|
8187
|
+
assertPendingCommitShape(rec);
|
|
8188
|
+
const meta = {
|
|
8189
|
+
groupId: rec.groupId,
|
|
8190
|
+
op: rec.op,
|
|
8191
|
+
request: rec.request,
|
|
8192
|
+
commitEpoch: rec.commitEpoch,
|
|
8193
|
+
prior: rec.prior,
|
|
8194
|
+
followUp: rec.followUp
|
|
8195
|
+
};
|
|
8196
|
+
const sealedState = await this.seal(rec.state);
|
|
8197
|
+
const sealedRequest = await this.sealJson(meta);
|
|
8198
|
+
if (opts?.replacing) {
|
|
8199
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8200
|
+
const row = this.readPendingRow(channelId);
|
|
8201
|
+
if (!row)
|
|
8202
|
+
return false;
|
|
8203
|
+
if (!matchesPendingCommitKey(await this.openPendingRow(row), opts.replacing))
|
|
8204
|
+
return false;
|
|
8205
|
+
const outcome = this.immediate(() => {
|
|
8206
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
8207
|
+
return "retry";
|
|
8208
|
+
this.db.prepare(`UPDATE mls_pending_commit SET sealed_state=?, sealed_request=?, created_at=?
|
|
8209
|
+
WHERE identity_id=? AND channel_id=?`).run(sealedState, sealedRequest, rec.createdAt, this.id, channelId);
|
|
8210
|
+
return "written";
|
|
8211
|
+
});
|
|
8212
|
+
if (outcome === "written")
|
|
8213
|
+
return true;
|
|
8214
|
+
}
|
|
8215
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8216
|
+
}
|
|
8217
|
+
const res = this.db.prepare(opts?.onlyIfAbsent ? `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
|
|
8218
|
+
VALUES (?,?,?,?,?)
|
|
8219
|
+
ON CONFLICT(identity_id, channel_id) DO NOTHING` : `INSERT INTO mls_pending_commit (identity_id, channel_id, sealed_state, sealed_request, created_at)
|
|
8220
|
+
VALUES (?,?,?,?,?)
|
|
8221
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET
|
|
8222
|
+
sealed_state=excluded.sealed_state, sealed_request=excluded.sealed_request, created_at=excluded.created_at`).run(this.id, channelId, sealedState, sealedRequest, rec.createdAt);
|
|
8223
|
+
return Number(res.changes) > 0;
|
|
8224
|
+
}
|
|
8225
|
+
async loadPendingCommit(channelId) {
|
|
8226
|
+
const row = this.readPendingRow(channelId);
|
|
8227
|
+
return row ? this.openPendingRow(row) : null;
|
|
8228
|
+
}
|
|
8229
|
+
async listPendingCommits() {
|
|
8230
|
+
const rows = this.db.prepare(`SELECT channel_id, sealed_state, sealed_request, created_at FROM mls_pending_commit
|
|
8231
|
+
WHERE identity_id=? ORDER BY channel_id`).all(this.id);
|
|
8232
|
+
const out = [];
|
|
8233
|
+
for (const row of rows)
|
|
8234
|
+
out.push({ channelId: row.channel_id, rec: await this.openPendingRow(row) });
|
|
8235
|
+
return out;
|
|
8236
|
+
}
|
|
8237
|
+
readMainBytes(channelId) {
|
|
8238
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
8239
|
+
return row?.sealed_main ?? void 0;
|
|
8240
|
+
}
|
|
8241
|
+
/**
|
|
8242
|
+
* C2 T3c (security review C1): promote ONLY the expected record, and only
|
|
8243
|
+
* while MAIN is exactly the state the caller checked against its `prior`.
|
|
8244
|
+
* Both checks are decided on bytes read BEFORE the await (the record opened
|
|
8245
|
+
* and matched on its key; MAIN unsealed and compared to `expect.main`) and
|
|
8246
|
+
* then re-proven INSIDE `BEGIN IMMEDIATE` by byte-comparing both rows, so
|
|
8247
|
+
* no writer in this or another process can slip between check and write.
|
|
8248
|
+
*/
|
|
8249
|
+
async commitPending(channelId, expect) {
|
|
8250
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8251
|
+
const row = this.readPendingRow(channelId);
|
|
8252
|
+
if (!row)
|
|
8253
|
+
return "record_mismatch";
|
|
8254
|
+
const rec = await this.openPendingRow(row);
|
|
8255
|
+
if (!matchesPendingCommitKey(rec, expect))
|
|
8256
|
+
return "record_mismatch";
|
|
8257
|
+
const mainBytes = this.readMainBytes(channelId);
|
|
8258
|
+
const main2 = mainBytes ? await this.unseal(mainBytes) : null;
|
|
8259
|
+
if (!sameMainState(main2, expect.main))
|
|
8260
|
+
return "main_moved";
|
|
8261
|
+
const sealedOwed = rec.followUp ? await this.sealJson({ op: rec.op, followUp: rec.followUp }) : null;
|
|
8262
|
+
const done = this.immediate(() => {
|
|
8263
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
8264
|
+
return false;
|
|
8265
|
+
if (!sameBytes(mainBytes, this.readMainBytes(channelId)))
|
|
8266
|
+
return false;
|
|
8267
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
8268
|
+
VALUES (?,?,?,?)
|
|
8269
|
+
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());
|
|
8270
|
+
this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
8271
|
+
if (sealedOwed) {
|
|
8272
|
+
this.db.prepare(`INSERT INTO mls_owed_followup (identity_id, channel_id, sealed_followup, created_at)
|
|
8273
|
+
VALUES (?,?,?,?)
|
|
8274
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET
|
|
8275
|
+
sealed_followup=excluded.sealed_followup, created_at=excluded.created_at`).run(this.id, channelId, sealedOwed, rec.createdAt);
|
|
8276
|
+
}
|
|
8277
|
+
return true;
|
|
8278
|
+
});
|
|
8279
|
+
if (done)
|
|
8280
|
+
return "committed";
|
|
8281
|
+
}
|
|
8282
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8283
|
+
}
|
|
8284
|
+
/** C2 T3c (C1): with `expect`, delete only the record carrying that key
|
|
8285
|
+
* (matched on the opened record, re-proven by byte-compare inside
|
|
8286
|
+
* `BEGIN IMMEDIATE`); without it, delete whatever is there (the owner's
|
|
8287
|
+
* abandon, which must work even when the record does not unseal). */
|
|
8288
|
+
async clearPendingCommit(channelId, expect) {
|
|
8289
|
+
if (!expect) {
|
|
8290
|
+
const res = this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
8291
|
+
return Number(res.changes) > 0;
|
|
8292
|
+
}
|
|
8293
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8294
|
+
const row = this.readPendingRow(channelId);
|
|
8295
|
+
if (!row)
|
|
8296
|
+
return false;
|
|
8297
|
+
const rec = await this.openPendingRow(row);
|
|
8298
|
+
if (!matchesPendingCommitKey(rec, expect))
|
|
8299
|
+
return false;
|
|
8300
|
+
const outcome = this.immediate(() => {
|
|
8301
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
8302
|
+
return "retry";
|
|
8303
|
+
this.db.prepare("DELETE FROM mls_pending_commit WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
8304
|
+
return "deleted";
|
|
8305
|
+
});
|
|
8306
|
+
if (outcome === "deleted")
|
|
8307
|
+
return true;
|
|
8308
|
+
}
|
|
8309
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8310
|
+
}
|
|
8311
|
+
async saveMainGuarded(channelId, state, tag) {
|
|
8312
|
+
let row = this.readPendingRow(channelId);
|
|
8313
|
+
const sealedMain = await this.seal(state);
|
|
8314
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8315
|
+
if (attempt > 0)
|
|
8316
|
+
row = this.readPendingRow(channelId);
|
|
8317
|
+
const prior = row ? (await this.unsealJson(row.sealed_request)).prior : void 0;
|
|
8318
|
+
const outcome = this.immediate(() => {
|
|
8319
|
+
if (!sameRow(row, this.readPendingRow(channelId)))
|
|
8320
|
+
return "retry";
|
|
8321
|
+
if (row && !sameTag(prior ?? null, tag))
|
|
8322
|
+
return "refused";
|
|
8323
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
8324
|
+
VALUES (?,?,?,?)
|
|
8325
|
+
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());
|
|
8326
|
+
return "written";
|
|
8327
|
+
});
|
|
8328
|
+
if (outcome === "written")
|
|
8329
|
+
return;
|
|
8330
|
+
if (outcome === "refused")
|
|
8331
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8332
|
+
}
|
|
8333
|
+
throw new PendingCommitUnresolvedError(channelId);
|
|
8334
|
+
}
|
|
8335
|
+
async listOwedFollowUps() {
|
|
8336
|
+
const rows = this.db.prepare(`SELECT channel_id, sealed_followup, created_at FROM mls_owed_followup
|
|
8337
|
+
WHERE identity_id=? ORDER BY channel_id`).all(this.id);
|
|
8338
|
+
const out = [];
|
|
8339
|
+
for (const row of rows) {
|
|
8340
|
+
const o = await this.unsealJson(row.sealed_followup);
|
|
8341
|
+
out.push({ channelId: row.channel_id, owed: { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) } });
|
|
8342
|
+
}
|
|
8343
|
+
return out;
|
|
8344
|
+
}
|
|
8345
|
+
/** C2 T3c (M2): one channel's slot, so another channel's unreadable slot
|
|
8346
|
+
* cannot fail this read. */
|
|
8347
|
+
async getOwedFollowUp(channelId) {
|
|
8348
|
+
const row = this.db.prepare("SELECT sealed_followup, created_at FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
8349
|
+
if (!row)
|
|
8350
|
+
return null;
|
|
8351
|
+
const o = await this.unsealJson(row.sealed_followup);
|
|
8352
|
+
return { op: o.op, followUp: o.followUp, createdAt: Number(row.created_at) };
|
|
8353
|
+
}
|
|
8354
|
+
/** C2 T3c (M1): with `expectCreatedAt`, delete only that slot, in ONE
|
|
8355
|
+
* statement (a newer slot written meanwhile has another created_at). */
|
|
8356
|
+
async clearOwedFollowUp(channelId, expectCreatedAt) {
|
|
8357
|
+
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);
|
|
8358
|
+
return Number(res.changes) > 0;
|
|
8359
|
+
}
|
|
6494
8360
|
/** @internal test-only */
|
|
6495
8361
|
_rawMainForTest(identityId, channelId) {
|
|
6496
8362
|
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
|
|
@@ -6628,12 +8494,13 @@ var LatencyRecorder = class {
|
|
|
6628
8494
|
};
|
|
6629
8495
|
|
|
6630
8496
|
// src/coordination.ts
|
|
8497
|
+
var TRAILING_PUNCT = /[^\w]+$/;
|
|
6631
8498
|
function parseMentions(text) {
|
|
6632
8499
|
const mentions = [];
|
|
6633
|
-
const re = /@(\w[\w]*)/
|
|
8500
|
+
const re = /@(\w[\w.-]*)/g;
|
|
6634
8501
|
let match;
|
|
6635
8502
|
while ((match = re.exec(text)) !== null) {
|
|
6636
|
-
mentions.push(match[1].toLowerCase());
|
|
8503
|
+
mentions.push(match[1].replace(TRAILING_PUNCT, "").toLowerCase());
|
|
6637
8504
|
}
|
|
6638
8505
|
return mentions;
|
|
6639
8506
|
}
|
|
@@ -6643,21 +8510,84 @@ function ownRoomNames(agentName, accountId, roomDisplayName) {
|
|
|
6643
8510
|
if (!n) return;
|
|
6644
8511
|
const lower = n.toLowerCase();
|
|
6645
8512
|
names.add(lower);
|
|
6646
|
-
|
|
8513
|
+
const first = lower.split(/[\s(]/)[0];
|
|
8514
|
+
names.add(first);
|
|
8515
|
+
const trimmed = first.replace(TRAILING_PUNCT, "");
|
|
8516
|
+
if (trimmed) names.add(trimmed);
|
|
6647
8517
|
};
|
|
6648
8518
|
add(agentName);
|
|
6649
8519
|
add(accountId);
|
|
6650
8520
|
add(roomDisplayName);
|
|
6651
8521
|
return names;
|
|
6652
8522
|
}
|
|
8523
|
+
var DEFAULT_MAX_AGENT_HOPS = 3;
|
|
8524
|
+
function agentHopAllowed(agentHops, maxAgentHops = DEFAULT_MAX_AGENT_HOPS) {
|
|
8525
|
+
return agentHops < maxAgentHops;
|
|
8526
|
+
}
|
|
6653
8527
|
function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
|
|
6654
8528
|
const mentions = parseMentions(plaintext);
|
|
8529
|
+
const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
|
|
8530
|
+
const mentionsMe = mentions.some((m) => ownNames.has(m));
|
|
8531
|
+
if (opts?.senderIsAgent === true) {
|
|
8532
|
+
if (!mentionsMe) return false;
|
|
8533
|
+
return agentHopAllowed(opts.agentHops ?? 1, opts.maxAgentHops);
|
|
8534
|
+
}
|
|
6655
8535
|
if (mentions.length === 0) return true;
|
|
6656
8536
|
if (mentions.includes("all") || mentions.includes("everyone")) return true;
|
|
6657
|
-
|
|
6658
|
-
if (mentions.some((m) => ownNames.has(m))) return true;
|
|
8537
|
+
if (mentionsMe) return true;
|
|
6659
8538
|
return false;
|
|
6660
8539
|
}
|
|
8540
|
+
var AGENT_HOP_QUIET_MS = 10 * 6e4;
|
|
8541
|
+
var AgentHopTracker = class {
|
|
8542
|
+
constructor(maxChannels = 1e4, opts = {}) {
|
|
8543
|
+
this.maxChannels = maxChannels;
|
|
8544
|
+
this.now = opts.now ?? (() => Date.now());
|
|
8545
|
+
this.quietMs = opts.quietMs ?? AGENT_HOP_QUIET_MS;
|
|
8546
|
+
}
|
|
8547
|
+
hops = /* @__PURE__ */ new Map();
|
|
8548
|
+
now;
|
|
8549
|
+
quietMs;
|
|
8550
|
+
/** Record an inbound message; returns the consecutive agent hops including it. */
|
|
8551
|
+
noteInbound(channelId, senderIsAgent) {
|
|
8552
|
+
if (senderIsAgent !== true) {
|
|
8553
|
+
this.set(channelId, { count: 0, lastAgentAt: 0 });
|
|
8554
|
+
return 0;
|
|
8555
|
+
}
|
|
8556
|
+
return this.bump(channelId);
|
|
8557
|
+
}
|
|
8558
|
+
/**
|
|
8559
|
+
* Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
|
|
8560
|
+
* channel). Callers do not report status frames, daily-limit or dead-letter
|
|
8561
|
+
* notices, or continuation parts of one multi-part reply.
|
|
8562
|
+
*/
|
|
8563
|
+
noteOwnSend(channelId) {
|
|
8564
|
+
this.bump(channelId);
|
|
8565
|
+
}
|
|
8566
|
+
size() {
|
|
8567
|
+
return this.hops.size;
|
|
8568
|
+
}
|
|
8569
|
+
bump(channelId) {
|
|
8570
|
+
const now = this.now();
|
|
8571
|
+
const prev = this.hops.get(channelId);
|
|
8572
|
+
const base = prev && prev.count > 0 && now - prev.lastAgentAt < this.quietMs ? prev.count : 0;
|
|
8573
|
+
const count = base + 1;
|
|
8574
|
+
this.set(channelId, { count, lastAgentAt: now });
|
|
8575
|
+
return count;
|
|
8576
|
+
}
|
|
8577
|
+
set(channelId, value) {
|
|
8578
|
+
this.hops.delete(channelId);
|
|
8579
|
+
this.hops.set(channelId, value);
|
|
8580
|
+
if (this.hops.size > this.maxChannels) {
|
|
8581
|
+
const oldest = this.hops.keys().next();
|
|
8582
|
+
if (!oldest.done) this.hops.delete(oldest.value);
|
|
8583
|
+
}
|
|
8584
|
+
}
|
|
8585
|
+
};
|
|
8586
|
+
function isAgentTurn(receipts, frame) {
|
|
8587
|
+
if (frame.notice === true) return false;
|
|
8588
|
+
if (typeof frame.in_reply_to === "string" && receipts.runtimeReplyReceipt(frame.in_reply_to)) return false;
|
|
8589
|
+
return true;
|
|
8590
|
+
}
|
|
6661
8591
|
|
|
6662
8592
|
// src/rate-limit.ts
|
|
6663
8593
|
var TokenBucketFloor = class {
|
|
@@ -6721,9 +8651,6 @@ var TokenBucketFloor = class {
|
|
|
6721
8651
|
// src/decrypt-error-log-limiter.ts
|
|
6722
8652
|
var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
|
|
6723
8653
|
var MAX_DISTINCT_PER_WINDOW = 4;
|
|
6724
|
-
function normalizeMessage(m) {
|
|
6725
|
-
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>");
|
|
6726
|
-
}
|
|
6727
8654
|
var DecryptErrorLogLimiter = class {
|
|
6728
8655
|
windowMs;
|
|
6729
8656
|
now;
|
|
@@ -6732,63 +8659,56 @@ var DecryptErrorLogLimiter = class {
|
|
|
6732
8659
|
constructor(opts = {}) {
|
|
6733
8660
|
this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
|
|
6734
8661
|
this.now = opts.now ?? Date.now;
|
|
6735
|
-
this.log = opts.log ??
|
|
8662
|
+
this.log = opts.log ?? writeServiceLog;
|
|
6736
8663
|
}
|
|
6737
|
-
/**
|
|
6738
|
-
* Record one decrypt error for `channelId`. Logs immediately if this is
|
|
6739
|
-
* the channel's first error since its window opened (or its prior window
|
|
6740
|
-
* had already elapsed, in which case a summary for that just-closed
|
|
6741
|
-
* window is flushed first); otherwise the error is counted silently
|
|
6742
|
-
* toward the window's eventual summary line.
|
|
6743
|
-
*/
|
|
6744
8664
|
record(channelId, info) {
|
|
6745
8665
|
const t = this.now();
|
|
8666
|
+
const kind = errorClass(info.error);
|
|
6746
8667
|
const w = this.windows.get(channelId);
|
|
6747
8668
|
if (!w) {
|
|
6748
|
-
this.logFirst(
|
|
6749
|
-
this.windows.set(channelId, {
|
|
8669
|
+
this.logFirst(info);
|
|
8670
|
+
this.windows.set(channelId, {
|
|
8671
|
+
windowStart: t,
|
|
8672
|
+
count: 1,
|
|
8673
|
+
lastEpoch: info.epoch,
|
|
8674
|
+
loggedClasses: /* @__PURE__ */ new Set([kind])
|
|
8675
|
+
});
|
|
6750
8676
|
return;
|
|
6751
8677
|
}
|
|
6752
8678
|
if (t - w.windowStart >= this.windowMs) {
|
|
6753
|
-
this.flushSummary(
|
|
6754
|
-
this.logFirst(
|
|
6755
|
-
this.windows.set(channelId, {
|
|
8679
|
+
this.flushSummary(w);
|
|
8680
|
+
this.logFirst(info);
|
|
8681
|
+
this.windows.set(channelId, {
|
|
8682
|
+
windowStart: t,
|
|
8683
|
+
count: 1,
|
|
8684
|
+
lastEpoch: info.epoch,
|
|
8685
|
+
loggedClasses: /* @__PURE__ */ new Set([kind])
|
|
8686
|
+
});
|
|
6756
8687
|
return;
|
|
6757
8688
|
}
|
|
6758
8689
|
w.count += 1;
|
|
6759
8690
|
w.lastEpoch = info.epoch;
|
|
6760
|
-
w.
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
this.logFirst(channelId, info);
|
|
6764
|
-
w.loggedKeys.add(key);
|
|
8691
|
+
if (!w.loggedClasses.has(kind) && w.loggedClasses.size < MAX_DISTINCT_PER_WINDOW) {
|
|
8692
|
+
this.logFirst(info);
|
|
8693
|
+
w.loggedClasses.add(kind);
|
|
6765
8694
|
}
|
|
6766
8695
|
}
|
|
6767
|
-
/**
|
|
6768
|
-
* Clear a channel's window (e.g. once the caller learns the channel
|
|
6769
|
-
* cleared/healed) so its NEXT error logs immediately as a fresh "first"
|
|
6770
|
-
* rather than being folded into a stale window's summary. There is
|
|
6771
|
-
* currently no onDecryptSuccess-style hook from the SDK to wire this to
|
|
6772
|
-
* automatically (see bridge-agent.ts's onDecryptError wiring comment) —
|
|
6773
|
-
* exposed here so a future success signal (or a caller with its own
|
|
6774
|
-
* channel-cleared signal) can reset explicitly. In its absence, the
|
|
6775
|
-
* window simply rolls over on interval elapse, which is an accepted
|
|
6776
|
-
* simplification (see report).
|
|
6777
|
-
*/
|
|
6778
8696
|
reset(channelId) {
|
|
6779
8697
|
this.windows.delete(channelId);
|
|
6780
8698
|
}
|
|
6781
|
-
logFirst(
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
);
|
|
8699
|
+
logFirst(info) {
|
|
8700
|
+
const fields = {};
|
|
8701
|
+
if (typeof info.epoch === "number" && Number.isFinite(info.epoch)) fields.epoch = info.epoch;
|
|
8702
|
+
this.log("warn", "decrypt.failed", fields, info.error);
|
|
6785
8703
|
}
|
|
6786
|
-
flushSummary(
|
|
8704
|
+
flushSummary(w) {
|
|
6787
8705
|
if (w.count <= 1) return;
|
|
6788
|
-
const
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
8706
|
+
const fields = {
|
|
8707
|
+
count: w.count,
|
|
8708
|
+
window_seconds: Math.round(this.windowMs / 1e3)
|
|
8709
|
+
};
|
|
8710
|
+
if (typeof w.lastEpoch === "number" && Number.isFinite(w.lastEpoch)) fields.epoch = w.lastEpoch;
|
|
8711
|
+
this.log("warn", "decrypt.suppressed", fields);
|
|
6792
8712
|
}
|
|
6793
8713
|
};
|
|
6794
8714
|
|
|
@@ -6992,8 +8912,7 @@ var HermesMemoryProvider = class {
|
|
|
6992
8912
|
try {
|
|
6993
8913
|
this.store.probeWrite();
|
|
6994
8914
|
} catch (e) {
|
|
6995
|
-
|
|
6996
|
-
`);
|
|
8915
|
+
writeServiceLog("error", "memory.hermes_writeback_unavailable", {}, e);
|
|
6997
8916
|
}
|
|
6998
8917
|
}
|
|
6999
8918
|
store;
|
|
@@ -7510,9 +9429,7 @@ function parseSummarizeBody(parsed, selfIdentityId, log = () => {
|
|
|
7510
9429
|
return null;
|
|
7511
9430
|
}
|
|
7512
9431
|
if (typeof body.requested_by !== "string" || body.requested_by !== transcript.host_identity_id) {
|
|
7513
|
-
log(
|
|
7514
|
-
`summarize_not_host: ${String(body.requested_by)} is not the host of ${body.transcript_message_id}`
|
|
7515
|
-
);
|
|
9432
|
+
log("warn", "summarize.requester_not_host");
|
|
7516
9433
|
return null;
|
|
7517
9434
|
}
|
|
7518
9435
|
return {
|
|
@@ -7567,9 +9484,8 @@ async function runSummarize(opts) {
|
|
|
7567
9484
|
);
|
|
7568
9485
|
} catch (e) {
|
|
7569
9486
|
const aborted = controller.signal.aborted;
|
|
7570
|
-
opts.log(
|
|
7571
|
-
|
|
7572
|
-
);
|
|
9487
|
+
if (aborted) opts.log("warn", "summarize.timeout", { timeout_ms: timeoutMs });
|
|
9488
|
+
else opts.log("warn", "summarize.failed", {}, e);
|
|
7573
9489
|
return null;
|
|
7574
9490
|
} finally {
|
|
7575
9491
|
clearTimeout(timer);
|
|
@@ -7843,51 +9759,24 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
|
|
|
7843
9759
|
|
|
7844
9760
|
// src/bridge-agent.ts
|
|
7845
9761
|
import { homedir as homedir2 } from "node:os";
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
record(inReplyTo, channelId, replyMessageId) {
|
|
7863
|
-
const receipt = Object.freeze({
|
|
7864
|
-
in_reply_to: inReplyTo,
|
|
7865
|
-
reply_message_id: replyMessageId,
|
|
7866
|
-
channel_id: channelId,
|
|
7867
|
-
replied_at: this.now()
|
|
7868
|
-
});
|
|
7869
|
-
this.prune(receipt.replied_at);
|
|
7870
|
-
this.receipts.delete(inReplyTo);
|
|
7871
|
-
this.receipts.set(inReplyTo, receipt);
|
|
7872
|
-
while (this.receipts.size > this.capacity) {
|
|
7873
|
-
const oldest = this.receipts.keys().next().value;
|
|
7874
|
-
if (oldest === void 0) break;
|
|
7875
|
-
this.receipts.delete(oldest);
|
|
7876
|
-
}
|
|
7877
|
-
return receipt;
|
|
7878
|
-
}
|
|
7879
|
-
get(inReplyTo) {
|
|
7880
|
-
this.prune(this.now());
|
|
7881
|
-
return this.receipts.get(inReplyTo) ?? null;
|
|
7882
|
-
}
|
|
7883
|
-
prune(now) {
|
|
7884
|
-
for (const [key, receipt] of this.receipts) {
|
|
7885
|
-
if (now - receipt.replied_at >= this.ttlMs) this.receipts.delete(key);
|
|
7886
|
-
}
|
|
9762
|
+
function logPendingCommitResolved(outcome) {
|
|
9763
|
+
switch (outcome) {
|
|
9764
|
+
case "landed":
|
|
9765
|
+
writeServiceLog("info", "mls.pending_commit.resolved.landed");
|
|
9766
|
+
return;
|
|
9767
|
+
case "not_landed":
|
|
9768
|
+
writeServiceLog("info", "mls.pending_commit.resolved.not_landed");
|
|
9769
|
+
return;
|
|
9770
|
+
case "stale_prior":
|
|
9771
|
+
writeServiceLog("info", "mls.pending_commit.resolved.stale_prior");
|
|
9772
|
+
return;
|
|
9773
|
+
case "abandoned":
|
|
9774
|
+
writeServiceLog("info", "mls.pending_commit.resolved.abandoned");
|
|
9775
|
+
return;
|
|
9776
|
+
default:
|
|
9777
|
+
writeServiceLog("info", "mls.pending_commit.resolved.unknown");
|
|
7887
9778
|
}
|
|
7888
|
-
}
|
|
7889
|
-
|
|
7890
|
-
// src/bridge-agent.ts
|
|
9779
|
+
}
|
|
7891
9780
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
7892
9781
|
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.";
|
|
7893
9782
|
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.";
|
|
@@ -7899,6 +9788,14 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
|
|
|
7899
9788
|
return VOICE_MODE_GUIDANCE;
|
|
7900
9789
|
}
|
|
7901
9790
|
var DEFAULT_MAX_BUFFER = 1e3;
|
|
9791
|
+
var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
|
|
9792
|
+
var REDELIVERY_BACKOFF_BASE_MS = 5e3;
|
|
9793
|
+
function redeliveryDelayMs(attempts) {
|
|
9794
|
+
return 2 ** Math.min(Math.max(attempts, 0), 16) * REDELIVERY_BACKOFF_BASE_MS;
|
|
9795
|
+
}
|
|
9796
|
+
var DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
|
|
9797
|
+
var DEAD_LETTER_NOTICE_INTERVAL_MS = 60 * 6e4;
|
|
9798
|
+
var PAUSE_RECHECK_MS = 3e4;
|
|
7902
9799
|
var CHANNEL_META_TTL_MS = 5 * 6e4;
|
|
7903
9800
|
var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
|
|
7904
9801
|
var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
|
|
@@ -7978,6 +9875,13 @@ var AgentPausedSendError = class extends Error {
|
|
|
7978
9875
|
this.name = "AgentPausedSendError";
|
|
7979
9876
|
}
|
|
7980
9877
|
};
|
|
9878
|
+
var SendForbiddenError = class extends Error {
|
|
9879
|
+
constructor(channelId) {
|
|
9880
|
+
super(`send refused for channel ${channelId} (credential revoked or not a member); no retry`);
|
|
9881
|
+
this.channelId = channelId;
|
|
9882
|
+
this.name = "SendForbiddenError";
|
|
9883
|
+
}
|
|
9884
|
+
};
|
|
7981
9885
|
var UnknownActionRunError = class extends Error {
|
|
7982
9886
|
constructor(runId) {
|
|
7983
9887
|
super(`unknown or already-settled agent action run: ${runId}`);
|
|
@@ -8008,8 +9912,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8008
9912
|
// half-open + wake + auth war-cap). On by default for the daemon;
|
|
8009
9913
|
// SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
|
|
8010
9914
|
hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
|
|
8011
|
-
onWsLifecycle: (event
|
|
8012
|
-
|
|
9915
|
+
onWsLifecycle: (event) => {
|
|
9916
|
+
if (event === "wake") writeServiceLog("warn", "transport.delivery_ping_wake");
|
|
9917
|
+
else if (event === "halfopen") writeServiceLog("warn", "transport.delivery_ping_halfopen");
|
|
9918
|
+
else if (event === "terminal") writeServiceLog("error", "transport.delivery_ping_terminal");
|
|
9919
|
+
else if (event === "reconnect") writeServiceLog("warn", "transport.delivery_ping_reconnect");
|
|
9920
|
+
else writeServiceLog("warn", "transport.delivery_ping_lifecycle_unknown");
|
|
9921
|
+
}
|
|
8013
9922
|
});
|
|
8014
9923
|
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
8015
9924
|
this.store = new SqliteMlsStateStore({
|
|
@@ -8026,11 +9935,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8026
9935
|
backend: this.backend,
|
|
8027
9936
|
deviceId: cfg.deviceId,
|
|
8028
9937
|
identity: new TextEncoder().encode(cfg.identityId),
|
|
9938
|
+
// C2 T0: required by the SDK. This host never commits (no committer
|
|
9939
|
+
// entry is reachable from its src/), so the hook only records the
|
|
9940
|
+
// outcome (see logPendingCommitResolved).
|
|
9941
|
+
onPendingCommitResolved: (_channelId, ev) => {
|
|
9942
|
+
logPendingCommitResolved(ev.outcome);
|
|
9943
|
+
},
|
|
8029
9944
|
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
8030
9945
|
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
8031
9946
|
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
8032
9947
|
// P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
|
|
8033
|
-
// rather than a raw
|
|
9948
|
+
// rather than a raw diagnostic per failure — a wedged channel used to
|
|
8034
9949
|
// redeliver the same undecryptable row on every pullDelivery cycle and
|
|
8035
9950
|
// produced a 114 MB / 765,000-line daemon log in a single day. The
|
|
8036
9951
|
// limiter still logs the first failure immediately and collapses every
|
|
@@ -8040,8 +9955,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8040
9955
|
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
8041
9956
|
this.decryptErrorLogLimiter.record(cid, {
|
|
8042
9957
|
epoch: row.epoch,
|
|
8043
|
-
|
|
8044
|
-
message: err instanceof Error ? err.message : String(err)
|
|
9958
|
+
error: err
|
|
8045
9959
|
});
|
|
8046
9960
|
},
|
|
8047
9961
|
// P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
|
|
@@ -8052,9 +9966,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8052
9966
|
// healWedgedChannel below) — never throws back into the SDK caller.
|
|
8053
9967
|
onWedged: (channelId) => {
|
|
8054
9968
|
try {
|
|
8055
|
-
|
|
8056
|
-
`[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
|
|
8057
|
-
);
|
|
9969
|
+
writeServiceLog("warn", "decrypt.channel_quarantined");
|
|
8058
9970
|
void this.healWedgedChannel(channelId);
|
|
8059
9971
|
} catch {
|
|
8060
9972
|
}
|
|
@@ -8079,6 +9991,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8079
9991
|
this.voiceSettingsSource = cfg.voice ? "file" : "none";
|
|
8080
9992
|
this.latency = deps.latency ?? new LatencyRecorder();
|
|
8081
9993
|
this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
9994
|
+
this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
|
|
9995
|
+
this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
|
|
8082
9996
|
this.rateFloor = new TokenBucketFloor({
|
|
8083
9997
|
capacity: cfg.rateLimitCapacity ?? 20,
|
|
8084
9998
|
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
@@ -8146,6 +10060,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8146
10060
|
* per channel on a miss.
|
|
8147
10061
|
*/
|
|
8148
10062
|
channelRoster = /* @__PURE__ */ new Map();
|
|
10063
|
+
/**
|
|
10064
|
+
* 2026-09-25: this agent's own display_name (`ConnectIdentity.display_name`,
|
|
10065
|
+
* identity-level, so the same in every channel) from the MOST RECENT roster
|
|
10066
|
+
* refresh that listed it. Read by the room @mention filter as its
|
|
10067
|
+
* `roomDisplayName`. Kept across a later failed refresh, so a transient
|
|
10068
|
+
* members-fetch error does not make the agent deaf to its own name; a
|
|
10069
|
+
* successful refresh after a rename replaces it.
|
|
10070
|
+
*/
|
|
10071
|
+
lastOwnDisplayName;
|
|
8149
10072
|
/**
|
|
8150
10073
|
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
8151
10074
|
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
@@ -8156,13 +10079,37 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8156
10079
|
*/
|
|
8157
10080
|
channelOwner = /* @__PURE__ */ new Map();
|
|
8158
10081
|
buffer = [];
|
|
10082
|
+
/** Message ids durably staged for the attached runtime but not yet acked. */
|
|
10083
|
+
runtimePendingIds = /* @__PURE__ */ new Set();
|
|
8159
10084
|
maxBuffer;
|
|
10085
|
+
/** Phase 3 F1: deliveries so far per pending runtime message (seeded from the store). */
|
|
10086
|
+
deliveryAttempts = /* @__PURE__ */ new Map();
|
|
10087
|
+
maxDeliveryAttempts;
|
|
10088
|
+
/** Phase 3 F1: cumulative messages dead-lettered at the delivery cap. */
|
|
10089
|
+
deadLetteredMessages = 0;
|
|
10090
|
+
/** Final-wave I1(a): message id → earliest redelivery time (epoch ms). */
|
|
10091
|
+
redeliverNotBefore = /* @__PURE__ */ new Map();
|
|
10092
|
+
/** Final-wave I1(a): one timer poking consumers when the earliest backoff ends. */
|
|
10093
|
+
redeliveryTimer = null;
|
|
10094
|
+
redeliveryTimerAt = Number.POSITIVE_INFINITY;
|
|
10095
|
+
/** Final-wave I1(c): channel → when its last dead-letter notice was posted. */
|
|
10096
|
+
deadLetterNoticeAt = /* @__PURE__ */ new Map();
|
|
10097
|
+
/** Phase 3 F1: channel → when a pause was last confirmed (send 403 or probe). */
|
|
10098
|
+
pausedChannels = /* @__PURE__ */ new Map();
|
|
8160
10099
|
/**
|
|
8161
|
-
*
|
|
8162
|
-
*
|
|
8163
|
-
*
|
|
10100
|
+
* Phase 3 F1: runtime messages retired by a TERMINAL reply failure reported
|
|
10101
|
+
* outside the /v1/ws socket that holds them in flight (HTTP /v1/send). The
|
|
10102
|
+
* socket consults this so it neither redelivers them nor stays busy on them.
|
|
10103
|
+
* Bounded; oldest forgotten first.
|
|
8164
10104
|
*/
|
|
8165
|
-
|
|
10105
|
+
retiredRuntimeIds = /* @__PURE__ */ new Set();
|
|
10106
|
+
/** Phase 3 F1: one pending membership recheck per paused channel holding work. */
|
|
10107
|
+
pauseRecheckTimers = /* @__PURE__ */ new Map();
|
|
10108
|
+
/** Phase 3 F3: consecutive agent turns per channel. */
|
|
10109
|
+
agentHops = new AgentHopTracker();
|
|
10110
|
+
maxAgentHops;
|
|
10111
|
+
/** Phase 3 F3: cumulative agent-authored messages not answered (no explicit mention / hop limit). */
|
|
10112
|
+
agentLoopSuppressed = 0;
|
|
8166
10113
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
8167
10114
|
droppedMessages = 0;
|
|
8168
10115
|
/**
|
|
@@ -8300,7 +10247,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8300
10247
|
// H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
|
|
8301
10248
|
static GRACEFUL_DRAIN_MS = 5e3;
|
|
8302
10249
|
/**
|
|
8303
|
-
* P0b (daemon-stability): collapses the onDecryptError
|
|
10250
|
+
* P0b (daemon-stability): collapses the onDecryptError diagnostic from
|
|
8304
10251
|
* "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
|
|
8305
10252
|
* most one line immediately + one summary line per 60s per channel. LOG
|
|
8306
10253
|
* OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
|
|
@@ -8464,16 +10411,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8464
10411
|
if (!this.injectedFastLlm) {
|
|
8465
10412
|
this.fastLlm = new FastLlmClient(this.cfg.voice.fastPath, this.fetchImpl);
|
|
8466
10413
|
}
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
8470
|
-
);
|
|
10414
|
+
writeServiceLog("info", "voice.settings_loaded", {
|
|
10415
|
+
version: this.voiceSettingsVersion,
|
|
10416
|
+
source_backend: this.voiceSettingsSource === "backend"
|
|
10417
|
+
});
|
|
8471
10418
|
} catch (e) {
|
|
8472
10419
|
if (this.voiceSettingsSource === "backend") return;
|
|
8473
|
-
|
|
8474
|
-
`[bridge] voice settings unavailable (${e.message}); using ${this.voiceSettingsSource}
|
|
8475
|
-
`
|
|
8476
|
-
);
|
|
10420
|
+
writeServiceLog("warn", "voice.settings_unavailable", {}, e);
|
|
8477
10421
|
}
|
|
8478
10422
|
}
|
|
8479
10423
|
/**
|
|
@@ -8499,23 +10443,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8499
10443
|
if (!res.ok) throw new Error(`runtime-config ${res.status}`);
|
|
8500
10444
|
const payload = await res.json();
|
|
8501
10445
|
if (!payload) {
|
|
8502
|
-
|
|
10446
|
+
writeServiceLog("warn", "runtime_config.empty_response");
|
|
8503
10447
|
return;
|
|
8504
10448
|
}
|
|
8505
10449
|
if (typeof payload.version !== "number") {
|
|
8506
|
-
|
|
8507
|
-
`[bridge] runtime config not applied (version ${JSON.stringify(payload.version ?? null)} \u2014 invalid response)
|
|
8508
|
-
`
|
|
8509
|
-
);
|
|
10450
|
+
writeServiceLog("warn", "runtime_config.invalid_version");
|
|
8510
10451
|
return;
|
|
8511
10452
|
}
|
|
8512
10453
|
const version = payload.version;
|
|
8513
10454
|
if (!version) {
|
|
8514
10455
|
writeManagedFiles(this.home, this.cfg.identityId, {});
|
|
8515
|
-
|
|
8516
|
-
`[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
|
|
8517
|
-
`
|
|
8518
|
-
);
|
|
10456
|
+
writeServiceLog("info", "runtime_config.not_saved");
|
|
8519
10457
|
}
|
|
8520
10458
|
const skillsJson = payload.skills_json ?? {};
|
|
8521
10459
|
if (version) {
|
|
@@ -8548,10 +10486,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8548
10486
|
}
|
|
8549
10487
|
);
|
|
8550
10488
|
if (result.sourcesDeleted > 0) {
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
);
|
|
10489
|
+
writeServiceLog("info", "runtime_config.legacy_memory_disposition_applied", {
|
|
10490
|
+
assigned: result.factsAssigned,
|
|
10491
|
+
sources: result.sourcesDeleted
|
|
10492
|
+
});
|
|
8555
10493
|
}
|
|
8556
10494
|
}
|
|
8557
10495
|
const local = readLocalReport(this.home, this.cfg.identityId);
|
|
@@ -8590,22 +10528,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8590
10528
|
body: JSON.stringify(report)
|
|
8591
10529
|
});
|
|
8592
10530
|
if (!posted.ok) {
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
10531
|
+
if (version) {
|
|
10532
|
+
writeServiceLog("warn", "runtime_config.report_failed", { status: posted.status, version });
|
|
10533
|
+
} else {
|
|
10534
|
+
writeServiceLog("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
|
|
10535
|
+
}
|
|
8598
10536
|
return;
|
|
8599
10537
|
}
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
` : "[bridge] legacy-memory inventory reported; no saved runtime config was applied\n"
|
|
8603
|
-
);
|
|
10538
|
+
if (version) writeServiceLog("info", "runtime_config.applied", { version });
|
|
10539
|
+
else writeServiceLog("info", "runtime_config.legacy_memory_reported");
|
|
8604
10540
|
} catch (e) {
|
|
8605
|
-
|
|
8606
|
-
`[bridge] runtime config refetch failed: ${e?.message ?? e}
|
|
8607
|
-
`
|
|
8608
|
-
);
|
|
10541
|
+
writeServiceLog("warn", "runtime_config.refetch_failed", {}, e);
|
|
8609
10542
|
}
|
|
8610
10543
|
}
|
|
8611
10544
|
/**
|
|
@@ -8623,12 +10556,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8623
10556
|
if (this.maxBuffer <= 0) return;
|
|
8624
10557
|
const overflow = this.buffer.length - this.maxBuffer;
|
|
8625
10558
|
if (overflow > 0) {
|
|
8626
|
-
this.buffer.splice(0, overflow);
|
|
10559
|
+
for (const shed of this.buffer.splice(0, overflow)) this.deliveryAttempts.delete(shed.message_id);
|
|
8627
10560
|
this.droppedMessages += overflow;
|
|
8628
10561
|
}
|
|
8629
10562
|
}
|
|
8630
10563
|
async start() {
|
|
8631
10564
|
this.startedAt = Date.now();
|
|
10565
|
+
await this.restoreRuntimeInbox();
|
|
8632
10566
|
try {
|
|
8633
10567
|
await this.ingestOnce();
|
|
8634
10568
|
} catch {
|
|
@@ -8647,7 +10581,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8647
10581
|
// See the safetyTimer/selfHealTimer comment near close(): swallow the
|
|
8648
10582
|
// expected post-close store-access rejection, log anything else.
|
|
8649
10583
|
void this.selfHealSweep().catch((e) => {
|
|
8650
|
-
if (!this.closingDown)
|
|
10584
|
+
if (!this.closingDown) writeServiceLog("error", "self_heal.sweep_failed", {}, e);
|
|
8651
10585
|
})
|
|
8652
10586
|
),
|
|
8653
10587
|
this.sweepJitter()
|
|
@@ -8674,8 +10608,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8674
10608
|
if (mapped) return mapped;
|
|
8675
10609
|
if (!this.enclaveFallbackWarned.has(channelId)) {
|
|
8676
10610
|
this.enclaveFallbackWarned.add(channelId);
|
|
8677
|
-
|
|
8678
|
-
`);
|
|
10611
|
+
writeServiceLog("warn", "send.enclave_fallback");
|
|
8679
10612
|
}
|
|
8680
10613
|
return this.cfg.enclaveId;
|
|
8681
10614
|
}
|
|
@@ -8686,7 +10619,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8686
10619
|
this.joinState.set(channelId, "JOINED");
|
|
8687
10620
|
return true;
|
|
8688
10621
|
} catch (e) {
|
|
8689
|
-
|
|
10622
|
+
writeServiceLog("warn", "join.reconcile_failed", {}, e);
|
|
8690
10623
|
this.joinState.set(channelId, "PENDING");
|
|
8691
10624
|
return false;
|
|
8692
10625
|
}
|
|
@@ -8759,15 +10692,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8759
10692
|
}
|
|
8760
10693
|
this.lastWipe.set(channelId, now);
|
|
8761
10694
|
} else {
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
10695
|
+
writeServiceLog("warn", "self_heal.wipe_suppressed", {
|
|
10696
|
+
proven_forked: provenForked,
|
|
10697
|
+
remaining_seconds: Math.round((floor - (now - lastWipe)) / 1e3)
|
|
10698
|
+
});
|
|
8765
10699
|
}
|
|
8766
10700
|
this.joinState.delete(channelId);
|
|
8767
10701
|
if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
|
|
8768
|
-
|
|
8769
|
-
`[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)`
|
|
8770
|
-
);
|
|
10702
|
+
writeServiceLog("warn", "self_heal.request_suppressed_state_held");
|
|
8771
10703
|
return false;
|
|
8772
10704
|
}
|
|
8773
10705
|
const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
|
|
@@ -8776,7 +10708,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8776
10708
|
await this.signalHeldKpWelcome(g.group_id);
|
|
8777
10709
|
return repudiated;
|
|
8778
10710
|
} catch (e) {
|
|
8779
|
-
|
|
10711
|
+
writeServiceLog("warn", "self_heal.channel_failed", {}, e);
|
|
8780
10712
|
return false;
|
|
8781
10713
|
}
|
|
8782
10714
|
}
|
|
@@ -8803,7 +10735,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8803
10735
|
this.lastWelcomeRequest.set(channelId, now);
|
|
8804
10736
|
await this.signalHeldKpWelcome(groupId);
|
|
8805
10737
|
return true;
|
|
8806
|
-
} catch {
|
|
10738
|
+
} catch (e) {
|
|
10739
|
+
writeServiceLog("warn", "self_heal.supersede_signal_failed", {}, e);
|
|
8807
10740
|
return false;
|
|
8808
10741
|
}
|
|
8809
10742
|
}
|
|
@@ -8875,40 +10808,50 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8875
10808
|
* reads `null` and therefore degraded.
|
|
8876
10809
|
*/
|
|
8877
10810
|
keypackageExpired = null;
|
|
10811
|
+
/** #335: dedupes the expiry-measurement warning to once per failure STREAK —
|
|
10812
|
+
* any non-throwing resolution (including the known-unknown `null` returns,
|
|
10813
|
+
* which are answers, not failures) re-arms it. Mirrors `enclaveListWarned`. */
|
|
10814
|
+
keypackageExpiryUnknownWarned = false;
|
|
8878
10815
|
/** Resolve the flag from the SAME two inputs the guard uses, with every unknown
|
|
8879
10816
|
* mapped to `null`. Never throws: a failure to measure is itself unknown. */
|
|
8880
10817
|
async resolveKeypackageExpired() {
|
|
8881
10818
|
try {
|
|
8882
10819
|
const kpId = await this.provider.getLastPublishedKpId();
|
|
8883
|
-
if (kpId === null || kpId === "") return null;
|
|
10820
|
+
if (kpId === null || kpId === "") return this.kpExpiryResolved(null);
|
|
8884
10821
|
const exp = await this.backend.fetchKeyPackageExpiry(kpId);
|
|
8885
|
-
if (exp === null) return null;
|
|
8886
|
-
return isServerExpired(exp, Date.now());
|
|
8887
|
-
} catch {
|
|
10822
|
+
if (exp === null) return this.kpExpiryResolved(null);
|
|
10823
|
+
return this.kpExpiryResolved(isServerExpired(exp, Date.now()));
|
|
10824
|
+
} catch (e) {
|
|
10825
|
+
if (!this.keypackageExpiryUnknownWarned) {
|
|
10826
|
+
this.keypackageExpiryUnknownWarned = true;
|
|
10827
|
+
writeServiceLog("warn", "self_heal.keypackage_expiry_unknown", {}, e);
|
|
10828
|
+
}
|
|
8888
10829
|
return null;
|
|
8889
10830
|
}
|
|
8890
10831
|
}
|
|
10832
|
+
/** Every non-throwing resolution ends the failure streak (the next throw is
|
|
10833
|
+
* a NEW streak and warns again) and passes the value through unchanged. */
|
|
10834
|
+
kpExpiryResolved(value) {
|
|
10835
|
+
this.keypackageExpiryUnknownWarned = false;
|
|
10836
|
+
return value;
|
|
10837
|
+
}
|
|
8891
10838
|
async rotateDeviceKeyPackage() {
|
|
8892
10839
|
try {
|
|
8893
10840
|
await this.provider.publishKeyPackage();
|
|
8894
10841
|
} catch (e) {
|
|
8895
|
-
|
|
10842
|
+
writeServiceLog("error", "self_heal.keypackage_rotation_failed", {}, e);
|
|
8896
10843
|
}
|
|
8897
10844
|
}
|
|
8898
10845
|
async selfHealSweep() {
|
|
8899
10846
|
if (this.closingDown) return;
|
|
8900
10847
|
if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
|
|
8901
10848
|
this.providerCapabilityMissing = true;
|
|
8902
|
-
|
|
8903
|
-
"[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."
|
|
8904
|
-
);
|
|
10849
|
+
writeServiceLog("error", "self_heal.provider_capability_missing");
|
|
8905
10850
|
return;
|
|
8906
10851
|
}
|
|
8907
10852
|
if (typeof this.backend?.fetchKeyPackageExpiry !== "function") {
|
|
8908
10853
|
this.providerCapabilityMissing = true;
|
|
8909
|
-
|
|
8910
|
-
"[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."
|
|
8911
|
-
);
|
|
10854
|
+
writeServiceLog("error", "self_heal.backend_capability_missing");
|
|
8912
10855
|
return;
|
|
8913
10856
|
}
|
|
8914
10857
|
this.providerCapabilityMissing = false;
|
|
@@ -8935,13 +10878,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8935
10878
|
failed.push(encId);
|
|
8936
10879
|
if (!this.enclaveListWarned.has(encId)) {
|
|
8937
10880
|
this.enclaveListWarned.add(encId);
|
|
8938
|
-
|
|
10881
|
+
writeServiceLog("warn", "self_heal.enclave_listing_failed", {}, e);
|
|
8939
10882
|
}
|
|
8940
10883
|
}
|
|
8941
10884
|
}
|
|
8942
10885
|
this.sweepEnclavesFailed = failed;
|
|
8943
10886
|
if (!listedAny) {
|
|
8944
|
-
|
|
10887
|
+
writeServiceLog("warn", "self_heal.all_enclave_listings_failed", { count: failed.length });
|
|
8945
10888
|
return;
|
|
8946
10889
|
}
|
|
8947
10890
|
for (const c of chans) {
|
|
@@ -8992,26 +10935,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
8992
10935
|
}
|
|
8993
10936
|
obs.state = "healthy";
|
|
8994
10937
|
obs.diverged = false;
|
|
8995
|
-
|
|
8996
|
-
`[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
|
|
8997
|
-
);
|
|
10938
|
+
writeServiceLog("info", "self_heal.replay_succeeded", { epoch: obs.held_epoch });
|
|
8998
10939
|
continue;
|
|
8999
10940
|
}
|
|
9000
10941
|
const repudiated = await this.healWedgedChannel(c.channel_id, true);
|
|
9001
|
-
|
|
9002
|
-
`[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"}`
|
|
9003
|
-
);
|
|
10942
|
+
writeServiceLog("warn", "self_heal.stuck_channel", { repudiated });
|
|
9004
10943
|
}
|
|
9005
10944
|
continue;
|
|
9006
10945
|
}
|
|
9007
|
-
|
|
9008
|
-
`[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`
|
|
9009
|
-
);
|
|
10946
|
+
writeServiceLog("debug", "self_heal.channel_healthy");
|
|
9010
10947
|
continue;
|
|
9011
10948
|
}
|
|
9012
10949
|
obs.state = "diverged";
|
|
9013
10950
|
obs.diverged = true;
|
|
9014
|
-
|
|
10951
|
+
writeServiceLog("warn", "self_heal.stale_group_state");
|
|
9015
10952
|
} else {
|
|
9016
10953
|
obs.state = "no_local_state";
|
|
9017
10954
|
obs.diverged = false;
|
|
@@ -9034,7 +10971,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9034
10971
|
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
9035
10972
|
await this.signalHeldKpWelcome(g.group_id);
|
|
9036
10973
|
} catch (e) {
|
|
9037
|
-
|
|
10974
|
+
writeServiceLog("warn", "self_heal.channel_probe_failed", {}, e);
|
|
9038
10975
|
} finally {
|
|
9039
10976
|
this.observations.set(c.channel_id, obs);
|
|
9040
10977
|
}
|
|
@@ -9077,16 +11014,28 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9077
11014
|
async sendWithSelfHealTerminal(...args2) {
|
|
9078
11015
|
const [, channelId] = args2;
|
|
9079
11016
|
try {
|
|
9080
|
-
|
|
11017
|
+
const result = await sendWithSelfHeal(...args2);
|
|
11018
|
+
if (this.pausedChannels.has(channelId)) this.clearPause(channelId);
|
|
11019
|
+
return result;
|
|
9081
11020
|
} catch (e) {
|
|
9082
11021
|
if (e instanceof AgentPausedError) {
|
|
9083
|
-
|
|
11022
|
+
writeServiceLog("warn", "send.agent_paused");
|
|
11023
|
+
this.pausedChannels.set(channelId, Date.now());
|
|
9084
11024
|
throw new AgentPausedSendError(channelId);
|
|
9085
11025
|
}
|
|
11026
|
+
if (e instanceof Error && /^publish failed: 40[13]$/.test(e.message)) {
|
|
11027
|
+
writeServiceLog("warn", "send.forbidden");
|
|
11028
|
+
throw new SendForbiddenError(channelId);
|
|
11029
|
+
}
|
|
9086
11030
|
throw e;
|
|
9087
11031
|
}
|
|
9088
11032
|
}
|
|
9089
|
-
|
|
11033
|
+
/**
|
|
11034
|
+
* `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
|
|
11035
|
+
* turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
|
|
11036
|
+
* continuation parts of one multi-part reply pass false (final-wave I2).
|
|
11037
|
+
*/
|
|
11038
|
+
async send(channelId, text, messageId, opts = {}) {
|
|
9090
11039
|
const meta = this.freshMeta(channelId);
|
|
9091
11040
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
9092
11041
|
const joined = await this.reconcileJoin(channelId);
|
|
@@ -9121,10 +11070,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9121
11070
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
9122
11071
|
);
|
|
9123
11072
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
11073
|
+
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
9124
11074
|
void recordOutboundMemory(this.memory, channelId, text, id);
|
|
9125
11075
|
return { message_id: id, status: "sent" };
|
|
9126
11076
|
}
|
|
9127
|
-
async sendA2A(channelId, a2a, messageId) {
|
|
11077
|
+
async sendA2A(channelId, a2a, messageId, opts = {}) {
|
|
9128
11078
|
this.enforceRateFloor("a2a", channelId);
|
|
9129
11079
|
const joined = await this.reconcileJoin(channelId);
|
|
9130
11080
|
if (!joined) throw new ChannelPendingError(channelId);
|
|
@@ -9158,6 +11108,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9158
11108
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
9159
11109
|
);
|
|
9160
11110
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
11111
|
+
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
9161
11112
|
return { message_id: id, status: "sent" };
|
|
9162
11113
|
}
|
|
9163
11114
|
sendDeps() {
|
|
@@ -9223,6 +11174,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9223
11174
|
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
9224
11175
|
);
|
|
9225
11176
|
this.latency.record("send.total", performance.now() - sendStart);
|
|
11177
|
+
this.agentHops.noteOwnSend(channelId);
|
|
9226
11178
|
return { message_id: id, status: "sent" };
|
|
9227
11179
|
}
|
|
9228
11180
|
// ── B7: FileSafe vault commands (loopback boundary) ──────────────────────
|
|
@@ -9551,10 +11503,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9551
11503
|
return defer ? "deferred" : "answered";
|
|
9552
11504
|
} catch (e) {
|
|
9553
11505
|
this.fastPathFailed++;
|
|
9554
|
-
|
|
9555
|
-
`[bridge] voice fast path failed for ${m.channelId}/${m.messageId}: ${e.message ?? "unknown error"}
|
|
9556
|
-
`
|
|
9557
|
-
);
|
|
11506
|
+
writeServiceLog("warn", "voice.fast_path_failed", {}, e);
|
|
9558
11507
|
return "failed";
|
|
9559
11508
|
}
|
|
9560
11509
|
}
|
|
@@ -9743,10 +11692,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9743
11692
|
} catch (e) {
|
|
9744
11693
|
if (!this.enclaveEnumWarned) {
|
|
9745
11694
|
this.enclaveEnumWarned = true;
|
|
9746
|
-
|
|
9747
|
-
"[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
|
|
9748
|
-
e
|
|
9749
|
-
);
|
|
11695
|
+
writeServiceLog("warn", "self_heal.enclave_enumeration_failed", {}, e);
|
|
9750
11696
|
}
|
|
9751
11697
|
}
|
|
9752
11698
|
}
|
|
@@ -9794,6 +11740,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9794
11740
|
});
|
|
9795
11741
|
}
|
|
9796
11742
|
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
11743
|
+
const self = members.get(this.cfg.identityId);
|
|
11744
|
+
if (self?.name) this.lastOwnDisplayName = self.name;
|
|
9797
11745
|
await this.resolveAndPinOwner(channelId, rows);
|
|
9798
11746
|
} catch {
|
|
9799
11747
|
}
|
|
@@ -9854,9 +11802,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9854
11802
|
keyAlgo: owner.key_algo ?? "ed25519"
|
|
9855
11803
|
});
|
|
9856
11804
|
} else if (existing.publicKeyHex !== publicKeyHex) {
|
|
9857
|
-
|
|
9858
|
-
`[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
|
|
9859
|
-
);
|
|
11805
|
+
writeServiceLog("warn", "identity.owner_pin_mismatch");
|
|
9860
11806
|
this.channelOwner.set(channelId, "anomalous");
|
|
9861
11807
|
}
|
|
9862
11808
|
} catch {
|
|
@@ -9874,19 +11820,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9874
11820
|
const cached = this.channelOwner.get(channelId);
|
|
9875
11821
|
const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
|
|
9876
11822
|
if (!cached || cached === "anomalous") {
|
|
9877
|
-
if (TRACE
|
|
11823
|
+
if (TRACE && !cached) writeServiceLog("debug", "identity.owner_cache_missing");
|
|
11824
|
+
else if (TRACE) writeServiceLog("debug", "identity.owner_cache_anomalous");
|
|
9878
11825
|
return null;
|
|
9879
11826
|
}
|
|
9880
11827
|
const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
|
|
9881
11828
|
if (!pin) {
|
|
9882
|
-
if (TRACE)
|
|
11829
|
+
if (TRACE) writeServiceLog("debug", "identity.owner_pin_missing");
|
|
9883
11830
|
return null;
|
|
9884
11831
|
}
|
|
9885
11832
|
if (pin.publicKeyHex !== cached.publicKeyHex) {
|
|
9886
|
-
if (TRACE)
|
|
11833
|
+
if (TRACE) writeServiceLog("debug", "identity.owner_pin_mismatch");
|
|
9887
11834
|
return null;
|
|
9888
11835
|
}
|
|
9889
|
-
if (TRACE)
|
|
11836
|
+
if (TRACE) writeServiceLog("debug", "identity.owner_pin_resolved");
|
|
9890
11837
|
return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
|
|
9891
11838
|
}
|
|
9892
11839
|
async reportStatus(channelId, state, detail) {
|
|
@@ -9977,13 +11924,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
9977
11924
|
const body = await runSummarize({
|
|
9978
11925
|
request,
|
|
9979
11926
|
complete: (prompt, o) => this.completeForSummary(prompt, o.signal),
|
|
9980
|
-
log:
|
|
11927
|
+
log: writeServiceLog
|
|
9981
11928
|
});
|
|
9982
11929
|
if (body === null) return;
|
|
9983
11930
|
try {
|
|
9984
11931
|
await this.sendSummary(channelId, body);
|
|
9985
11932
|
} catch (e) {
|
|
9986
|
-
|
|
11933
|
+
writeServiceLog("warn", "summarize.send_failed", {}, e);
|
|
9987
11934
|
}
|
|
9988
11935
|
}
|
|
9989
11936
|
/**
|
|
@@ -10000,7 +11947,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10000
11947
|
*/
|
|
10001
11948
|
async completeForSummary(prompt, signal) {
|
|
10002
11949
|
if (!this.fastLlm) {
|
|
10003
|
-
|
|
11950
|
+
writeServiceLog("warn", "summarize.runtime_unavailable");
|
|
10004
11951
|
throw new Error("summarize_no_runtime");
|
|
10005
11952
|
}
|
|
10006
11953
|
const messages = [{ role: "user", content: prompt }];
|
|
@@ -10049,12 +11996,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10049
11996
|
(envelope) => this.transport.publish(envelope)
|
|
10050
11997
|
);
|
|
10051
11998
|
}
|
|
10052
|
-
/** Minimal stderr logger for non-fatal daemon events (mirrors the ad hoc
|
|
10053
|
-
* `process.stderr.write` calls elsewhere in this file). */
|
|
10054
|
-
log(message) {
|
|
10055
|
-
process.stderr.write(`[bridge] ${message}
|
|
10056
|
-
`);
|
|
10057
|
-
}
|
|
10058
11999
|
/**
|
|
10059
12000
|
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
10060
12001
|
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
@@ -10196,7 +12137,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10196
12137
|
for await (const ping of sub.pings) {
|
|
10197
12138
|
this.pingsReceived++;
|
|
10198
12139
|
if (ping?.reconnected) {
|
|
10199
|
-
|
|
12140
|
+
writeServiceLog("info", "transport.delivery_ping_connected");
|
|
10200
12141
|
await this.refetchRuntimeConfig();
|
|
10201
12142
|
}
|
|
10202
12143
|
if (ping?.voiceSettingsChanged) {
|
|
@@ -10212,21 +12153,19 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10212
12153
|
} catch {
|
|
10213
12154
|
}
|
|
10214
12155
|
if (!this.closingDown) {
|
|
10215
|
-
|
|
10216
|
-
"[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"
|
|
10217
|
-
);
|
|
12156
|
+
writeServiceLog("error", "transport.delivery_ping_stream_ended");
|
|
10218
12157
|
}
|
|
10219
12158
|
})();
|
|
10220
12159
|
this.safetyTimer = setInterval(() => {
|
|
10221
12160
|
void this.ingestOnce().catch((e) => {
|
|
10222
|
-
if (!this.closingDown)
|
|
12161
|
+
if (!this.closingDown) writeServiceLog("error", "receive.ingest_failed", {}, e);
|
|
10223
12162
|
});
|
|
10224
12163
|
}, 25e3);
|
|
10225
12164
|
if (this.selfHealSweepEnabled) {
|
|
10226
12165
|
const scheduleSweep = () => {
|
|
10227
12166
|
this.selfHealTimer = setTimeout(() => {
|
|
10228
12167
|
void this.selfHealSweep().catch((e) => {
|
|
10229
|
-
if (!this.closingDown)
|
|
12168
|
+
if (!this.closingDown) writeServiceLog("error", "self_heal.sweep_failed", {}, e);
|
|
10230
12169
|
});
|
|
10231
12170
|
scheduleSweep();
|
|
10232
12171
|
}, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
|
|
@@ -10266,7 +12205,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10266
12205
|
try {
|
|
10267
12206
|
await this.signalHeldKpWelcome(gid);
|
|
10268
12207
|
} catch (e) {
|
|
10269
|
-
|
|
12208
|
+
writeServiceLog("warn", "self_heal.welcome_signal_failed", {}, e);
|
|
10270
12209
|
}
|
|
10271
12210
|
}
|
|
10272
12211
|
} catch {
|
|
@@ -10307,11 +12246,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10307
12246
|
);
|
|
10308
12247
|
if (parsed.type && parsed.type !== "text") {
|
|
10309
12248
|
if (parsed.type === "summarize") {
|
|
10310
|
-
const request = parseSummarizeBody(
|
|
10311
|
-
parsed,
|
|
10312
|
-
this.cfg.identityId,
|
|
10313
|
-
(msg) => this.log(msg)
|
|
10314
|
-
);
|
|
12249
|
+
const request = parseSummarizeBody(parsed, this.cfg.identityId, writeServiceLog);
|
|
10315
12250
|
if (request !== null) {
|
|
10316
12251
|
void this.handleSummarize(m.channelId, request);
|
|
10317
12252
|
}
|
|
@@ -10324,12 +12259,38 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10324
12259
|
const senderType = m.envelope.sender_type;
|
|
10325
12260
|
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
10326
12261
|
const chanMeta = this.freshMeta(m.channelId);
|
|
12262
|
+
const loopSenderIsAgent = this.loopSenderIsAgent(
|
|
12263
|
+
m.channelId,
|
|
12264
|
+
m.envelope.sender_id,
|
|
12265
|
+
senderIsAgent,
|
|
12266
|
+
false
|
|
12267
|
+
);
|
|
12268
|
+
const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
|
|
12269
|
+
if (loopSenderIsAgent === true) {
|
|
12270
|
+
const shareText = typeof parsed.text === "string" ? parsed.text : "";
|
|
12271
|
+
const answerable = chanMeta && chanMeta.is_dm === false ? shouldProcessRoomMessage(
|
|
12272
|
+
shareText,
|
|
12273
|
+
this.cfg.agentName ?? this.cfg.identityId,
|
|
12274
|
+
this.cfg.identityId,
|
|
12275
|
+
{
|
|
12276
|
+
senderIsAgent: true,
|
|
12277
|
+
roomDisplayName: this.ownRoomDisplayName(),
|
|
12278
|
+
agentHops,
|
|
12279
|
+
maxAgentHops: this.maxAgentHops
|
|
12280
|
+
}
|
|
12281
|
+
) : agentHopAllowed(agentHops, this.maxAgentHops);
|
|
12282
|
+
if (!answerable) {
|
|
12283
|
+
this.noteAgentTurnSuppressed(agentHops);
|
|
12284
|
+
acked.push(m.queueId);
|
|
12285
|
+
continue;
|
|
12286
|
+
}
|
|
12287
|
+
}
|
|
10327
12288
|
const attachment = await materializeAttachment(
|
|
10328
12289
|
parsed,
|
|
10329
12290
|
{ attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
|
|
10330
12291
|
this.receiveDeps()
|
|
10331
12292
|
);
|
|
10332
|
-
|
|
12293
|
+
const runtimeMessage = {
|
|
10333
12294
|
channel_id: m.channelId,
|
|
10334
12295
|
message_id: m.messageId,
|
|
10335
12296
|
sender_id: m.envelope.sender_id,
|
|
@@ -10343,14 +12304,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10343
12304
|
voice: true,
|
|
10344
12305
|
guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
|
|
10345
12306
|
} : {}
|
|
10346
|
-
}
|
|
10347
|
-
this.
|
|
12307
|
+
};
|
|
12308
|
+
if (!await this.stageRuntimeMessage(runtimeMessage)) continue;
|
|
10348
12309
|
} catch (e) {
|
|
10349
12310
|
this.failedAttachments++;
|
|
10350
|
-
|
|
10351
|
-
`[bridge] attachment failed on ${m.messageId}: ${e.message}
|
|
10352
|
-
`
|
|
10353
|
-
);
|
|
12311
|
+
writeServiceLog("warn", "attachment.materialize_failed", {}, e);
|
|
10354
12312
|
}
|
|
10355
12313
|
}
|
|
10356
12314
|
acked.push(m.queueId);
|
|
@@ -10360,16 +12318,35 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10360
12318
|
const meta = this.freshMeta(m.channelId);
|
|
10361
12319
|
const senderType = m.envelope.sender_type;
|
|
10362
12320
|
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
12321
|
+
const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
|
|
12322
|
+
identityId: parsed.speaker_identity_id,
|
|
12323
|
+
displayName: parsed.speaker_display_name
|
|
12324
|
+
} : void 0;
|
|
12325
|
+
const loopSenderIsAgent = this.loopSenderIsAgent(
|
|
12326
|
+
m.channelId,
|
|
12327
|
+
m.envelope.sender_id,
|
|
12328
|
+
senderIsAgent,
|
|
12329
|
+
speaker !== void 0
|
|
12330
|
+
);
|
|
12331
|
+
const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent);
|
|
10363
12332
|
let shouldBuffer = true;
|
|
10364
12333
|
if (meta && meta.is_dm === false) {
|
|
10365
12334
|
shouldBuffer = shouldProcessRoomMessage(
|
|
10366
12335
|
parsed.text,
|
|
10367
12336
|
this.cfg.agentName ?? this.cfg.identityId,
|
|
10368
12337
|
this.cfg.identityId,
|
|
10369
|
-
{
|
|
12338
|
+
{
|
|
12339
|
+
senderIsAgent: loopSenderIsAgent,
|
|
12340
|
+
roomDisplayName: this.ownRoomDisplayName(),
|
|
12341
|
+
agentHops,
|
|
12342
|
+
maxAgentHops: this.maxAgentHops
|
|
12343
|
+
}
|
|
10370
12344
|
);
|
|
10371
12345
|
if (!shouldBuffer) this.filteredRoomMessages++;
|
|
12346
|
+
} else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
|
|
12347
|
+
shouldBuffer = false;
|
|
10372
12348
|
}
|
|
12349
|
+
if (!shouldBuffer && loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
|
|
10373
12350
|
if (shouldBuffer) {
|
|
10374
12351
|
const senderName = this.freshRoster(m.channelId)?.get(
|
|
10375
12352
|
m.envelope.sender_id
|
|
@@ -10379,10 +12356,6 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10379
12356
|
m.envelope.sender_id
|
|
10380
12357
|
);
|
|
10381
12358
|
const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
|
|
10382
|
-
const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
|
|
10383
|
-
identityId: parsed.speaker_identity_id,
|
|
10384
|
-
displayName: parsed.speaker_display_name
|
|
10385
|
-
} : void 0;
|
|
10386
12359
|
const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
|
|
10387
12360
|
const humanTurn = senderIsAgent !== true || speaker !== void 0;
|
|
10388
12361
|
const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
|
|
@@ -10391,7 +12364,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10391
12364
|
if (this.rateFloor.available(`room:${m.channelId}`) < 2) fastPathRoomBlocked = true;
|
|
10392
12365
|
}
|
|
10393
12366
|
if (fastPathRoomBlocked) this.fastPathSkipped++;
|
|
10394
|
-
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked;
|
|
12367
|
+
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
|
|
10395
12368
|
const turnText = parsed.text;
|
|
10396
12369
|
const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
|
|
10397
12370
|
const fastGreeting = isCallStart && fastPathFiring;
|
|
@@ -10415,7 +12388,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10415
12388
|
});
|
|
10416
12389
|
if (fastPathFiring) {
|
|
10417
12390
|
const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
|
|
10418
|
-
|
|
12391
|
+
const outcome = await this.runFastPath(
|
|
10419
12392
|
{
|
|
10420
12393
|
channelId: m.channelId,
|
|
10421
12394
|
messageId: m.messageId,
|
|
@@ -10425,16 +12398,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10425
12398
|
fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text,
|
|
10426
12399
|
voiceGuidance,
|
|
10427
12400
|
fastSpeaker
|
|
10428
|
-
)
|
|
10429
|
-
|
|
10430
|
-
this.
|
|
10431
|
-
|
|
10432
|
-
|
|
10433
|
-
}
|
|
12401
|
+
);
|
|
12402
|
+
if (outcome !== "answered") {
|
|
12403
|
+
if (!await this.stageRuntimeMessage(runtimeEntry(outcome === "deferred", await runtimeMemory))) {
|
|
12404
|
+
continue;
|
|
12405
|
+
}
|
|
12406
|
+
}
|
|
10434
12407
|
} else {
|
|
10435
|
-
this.
|
|
12408
|
+
if (!await this.stageRuntimeMessage(runtimeEntry(false, await runtimeMemory))) continue;
|
|
10436
12409
|
}
|
|
10437
|
-
this.capBuffer();
|
|
10438
12410
|
void recordInboundMemory(
|
|
10439
12411
|
this.memory,
|
|
10440
12412
|
m.channelId,
|
|
@@ -10465,7 +12437,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10465
12437
|
}
|
|
10466
12438
|
async drain(channelId, waitSeconds) {
|
|
10467
12439
|
let got = this.takeBuffered(channelId);
|
|
10468
|
-
if (got.length || waitSeconds <= 0)
|
|
12440
|
+
if (got.length || waitSeconds <= 0) {
|
|
12441
|
+
this.ackRuntimeMessages(got.map((message) => message.message_id));
|
|
12442
|
+
return got;
|
|
12443
|
+
}
|
|
10469
12444
|
const cap = Math.min(waitSeconds, 50) * 1e3;
|
|
10470
12445
|
await new Promise((resolve3) => {
|
|
10471
12446
|
let fired = false;
|
|
@@ -10481,7 +12456,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10481
12456
|
timer = setTimeout(wake, cap);
|
|
10482
12457
|
this.waiters.push(wake);
|
|
10483
12458
|
});
|
|
10484
|
-
|
|
12459
|
+
got = this.takeBuffered(channelId);
|
|
12460
|
+
this.ackRuntimeMessages(got.map((message) => message.message_id));
|
|
12461
|
+
return got;
|
|
10485
12462
|
}
|
|
10486
12463
|
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
10487
12464
|
subscribe(listener) {
|
|
@@ -10490,19 +12467,319 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10490
12467
|
}
|
|
10491
12468
|
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
10492
12469
|
takeBuffered(channelId) {
|
|
10493
|
-
if (channelId === void 0) return this.buffer.splice(0);
|
|
12470
|
+
if (channelId === void 0 && this.pausedChannels.size === 0) return this.buffer.splice(0);
|
|
10494
12471
|
const keep = [];
|
|
10495
12472
|
const out = [];
|
|
10496
|
-
for (const m of this.buffer)
|
|
12473
|
+
for (const m of this.buffer) {
|
|
12474
|
+
const wanted = channelId === void 0 || m.channel_id === channelId;
|
|
12475
|
+
(wanted && !this.heldForPause(m.channel_id) ? out : keep).push(m);
|
|
12476
|
+
}
|
|
10497
12477
|
this.buffer.length = 0;
|
|
10498
12478
|
this.buffer.push(...keep);
|
|
10499
12479
|
return out;
|
|
10500
12480
|
}
|
|
12481
|
+
/**
|
|
12482
|
+
* Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
|
|
12483
|
+
* Splices out, oldest first, at most ONE buffered message per channel that
|
|
12484
|
+
* is not in `busyChannels` (the channels the consumer still holds a message
|
|
12485
|
+
* in flight for); everything else stays buffered in order. A runtime is
|
|
12486
|
+
* therefore never handed a second message for a channel it is still
|
|
12487
|
+
* working on — which it used to refuse with a "busy" reply and ack — and a
|
|
12488
|
+
* backlog replays one message per channel at a time after a restart.
|
|
12489
|
+
*/
|
|
12490
|
+
takeDeliverable(busyChannels) {
|
|
12491
|
+
const picked = /* @__PURE__ */ new Set();
|
|
12492
|
+
const out = [];
|
|
12493
|
+
const keep = [];
|
|
12494
|
+
const backingOff = /* @__PURE__ */ new Set();
|
|
12495
|
+
const now = Date.now();
|
|
12496
|
+
let wakeAt = Number.POSITIVE_INFINITY;
|
|
12497
|
+
for (const m of this.buffer) {
|
|
12498
|
+
const ch = m.channel_id;
|
|
12499
|
+
if (busyChannels.has(ch) || picked.has(ch) || backingOff.has(ch) || this.heldForPause(ch)) {
|
|
12500
|
+
keep.push(m);
|
|
12501
|
+
continue;
|
|
12502
|
+
}
|
|
12503
|
+
const notBefore = this.redeliverNotBefore.get(m.message_id);
|
|
12504
|
+
if (notBefore !== void 0 && notBefore > now) {
|
|
12505
|
+
backingOff.add(ch);
|
|
12506
|
+
wakeAt = Math.min(wakeAt, notBefore);
|
|
12507
|
+
keep.push(m);
|
|
12508
|
+
continue;
|
|
12509
|
+
}
|
|
12510
|
+
picked.add(ch);
|
|
12511
|
+
out.push(m);
|
|
12512
|
+
}
|
|
12513
|
+
if (wakeAt !== Number.POSITIVE_INFINITY) this.armRedeliveryWake(wakeAt);
|
|
12514
|
+
if (out.length) {
|
|
12515
|
+
this.buffer.length = 0;
|
|
12516
|
+
this.buffer.push(...keep);
|
|
12517
|
+
}
|
|
12518
|
+
return out;
|
|
12519
|
+
}
|
|
12520
|
+
/**
|
|
12521
|
+
* Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
|
|
12522
|
+
* true when it may be sent (and counts the attempt durably), false when the
|
|
12523
|
+
* message already reached the delivery cap — then it is dead-lettered:
|
|
12524
|
+
* acknowledged, never redelivered, never reaching a model. (Paused channels
|
|
12525
|
+
* never get here: takeDeliverable holds them, so a pause spends no attempts.)
|
|
12526
|
+
* Logs are content-free (event + counts only).
|
|
12527
|
+
*/
|
|
12528
|
+
admitRuntimeDelivery(message) {
|
|
12529
|
+
const prior = this.deliveryAttempts.get(message.message_id) ?? 0;
|
|
12530
|
+
if (prior >= this.maxDeliveryAttempts) {
|
|
12531
|
+
this.deadLetter(message, prior);
|
|
12532
|
+
return false;
|
|
12533
|
+
}
|
|
12534
|
+
this.redeliverNotBefore.delete(message.message_id);
|
|
12535
|
+
let durable = 0;
|
|
12536
|
+
try {
|
|
12537
|
+
durable = this.store.noteRuntimeInboxDelivery(message.message_id);
|
|
12538
|
+
} catch (e) {
|
|
12539
|
+
writeServiceLog("warn", "runtime_inbox.attempt_persist_failed", {}, e);
|
|
12540
|
+
}
|
|
12541
|
+
this.deliveryAttempts.set(message.message_id, Math.max(durable, prior + 1));
|
|
12542
|
+
return true;
|
|
12543
|
+
}
|
|
12544
|
+
/**
|
|
12545
|
+
* Final-wave I1: put back runtime deliveries that FAILED — the runtime sent a
|
|
12546
|
+
* `nack` for the message, or the socket closed while it was in flight (an
|
|
12547
|
+
* older runtime closes on any failed turn). Each one's attempt was already
|
|
12548
|
+
* counted durably at delivery; here it either dead-letters (cap reached) or
|
|
12549
|
+
* is requeued at the front behind a 2^attempts × 5 s redelivery backoff.
|
|
12550
|
+
* Messages that were never handed to the runtime go back via requeueFront
|
|
12551
|
+
* instead and are not affected.
|
|
12552
|
+
*/
|
|
12553
|
+
requeueFailedRuntimeDeliveries(messages) {
|
|
12554
|
+
const back = [];
|
|
12555
|
+
const now = Date.now();
|
|
12556
|
+
for (const m of messages) {
|
|
12557
|
+
const attempts = this.deliveryAttempts.get(m.message_id) ?? 0;
|
|
12558
|
+
if (attempts >= this.maxDeliveryAttempts) {
|
|
12559
|
+
this.deadLetter(m, attempts);
|
|
12560
|
+
continue;
|
|
12561
|
+
}
|
|
12562
|
+
if (attempts > 0) this.redeliverNotBefore.set(m.message_id, now + redeliveryDelayMs(attempts));
|
|
12563
|
+
back.push(m);
|
|
12564
|
+
}
|
|
12565
|
+
this.requeueFront(back);
|
|
12566
|
+
}
|
|
12567
|
+
/**
|
|
12568
|
+
* Phase 3 F1 dead letter: acknowledged, never redelivered, never reaching a
|
|
12569
|
+
* model — plus (final-wave I1(c)) one content-free notice to the channel,
|
|
12570
|
+
* at most once per channel per hour. Logs carry counts only.
|
|
12571
|
+
*/
|
|
12572
|
+
deadLetter(message, attempts) {
|
|
12573
|
+
this.deadLetteredMessages++;
|
|
12574
|
+
writeServiceLog("warn", "runtime_inbox.dead_letter", { attempts });
|
|
12575
|
+
this.ackRuntimeMessages([message.message_id]);
|
|
12576
|
+
this.noticeDeadLetter(message.channel_id);
|
|
12577
|
+
}
|
|
12578
|
+
noticeDeadLetter(channelId) {
|
|
12579
|
+
if (this.closingDown) return;
|
|
12580
|
+
const now = Date.now();
|
|
12581
|
+
const last = this.deadLetterNoticeAt.get(channelId);
|
|
12582
|
+
if (last !== void 0 && now - last < DEAD_LETTER_NOTICE_INTERVAL_MS) return;
|
|
12583
|
+
this.deadLetterNoticeAt.delete(channelId);
|
|
12584
|
+
this.deadLetterNoticeAt.set(channelId, now);
|
|
12585
|
+
if (this.deadLetterNoticeAt.size > 5e3) {
|
|
12586
|
+
const oldest = this.deadLetterNoticeAt.keys().next();
|
|
12587
|
+
if (!oldest.done) this.deadLetterNoticeAt.delete(oldest.value);
|
|
12588
|
+
}
|
|
12589
|
+
let pending;
|
|
12590
|
+
try {
|
|
12591
|
+
pending = this.send(channelId, DEAD_LETTER_NOTICE, void 0, { agentTurn: false });
|
|
12592
|
+
} catch (e) {
|
|
12593
|
+
pending = Promise.reject(e);
|
|
12594
|
+
}
|
|
12595
|
+
void pending.then(
|
|
12596
|
+
() => writeServiceLog("info", "runtime_inbox.dead_letter_notice_sent"),
|
|
12597
|
+
(e) => writeServiceLog("warn", "runtime_inbox.dead_letter_notice_failed", {}, e)
|
|
12598
|
+
);
|
|
12599
|
+
}
|
|
12600
|
+
/** Poke consumers once the earliest redelivery backoff ends. */
|
|
12601
|
+
armRedeliveryWake(at) {
|
|
12602
|
+
if (this.closingDown) return;
|
|
12603
|
+
if (this.redeliveryTimer !== null && this.redeliveryTimerAt <= at) return;
|
|
12604
|
+
if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
|
|
12605
|
+
this.redeliveryTimerAt = at;
|
|
12606
|
+
const timer = setTimeout(() => {
|
|
12607
|
+
this.redeliveryTimer = null;
|
|
12608
|
+
this.redeliveryTimerAt = Number.POSITIVE_INFINITY;
|
|
12609
|
+
this.wakeListeners();
|
|
12610
|
+
}, Math.max(0, at - Date.now()));
|
|
12611
|
+
timer.unref?.();
|
|
12612
|
+
this.redeliveryTimer = timer;
|
|
12613
|
+
}
|
|
12614
|
+
/**
|
|
12615
|
+
* 2026-09-25: this agent's OWN display_name for the room @mention filter
|
|
12616
|
+
* (`roomDisplayName`). Never the channel's name: a channel called
|
|
12617
|
+
* "Hermesav, Cortina011" must not make `@hermesav` address this agent. A pure
|
|
12618
|
+
* field read; the roster refresh that fills it runs once per ingest cycle,
|
|
12619
|
+
* TTL-gated, before the filter. Undefined until any roster has listed this
|
|
12620
|
+
* agent, in which case the filter still matches the credential agentName and
|
|
12621
|
+
* the identity id.
|
|
12622
|
+
*/
|
|
12623
|
+
ownRoomDisplayName() {
|
|
12624
|
+
return this.lastOwnDisplayName;
|
|
12625
|
+
}
|
|
12626
|
+
/** Phase 3 F3: the loop guard's view of the sender — server roster first. */
|
|
12627
|
+
loopSenderIsAgent(channelId, senderId, envelopeSaysAgent, relayedHumanTurn) {
|
|
12628
|
+
if (relayedHumanTurn) return false;
|
|
12629
|
+
const row = this.freshRoster(channelId)?.get(senderId);
|
|
12630
|
+
if (row) return row.is_agent;
|
|
12631
|
+
return envelopeSaysAgent;
|
|
12632
|
+
}
|
|
12633
|
+
noteAgentTurnSuppressed(agentHops) {
|
|
12634
|
+
this.agentLoopSuppressed++;
|
|
12635
|
+
writeServiceLog("info", "inbound.agent_turn_suppressed", { agent_hops: agentHops });
|
|
12636
|
+
}
|
|
12637
|
+
/**
|
|
12638
|
+
* Phase 3 F1: whether `channelId`'s buffered work must be HELD because the
|
|
12639
|
+
* agent is paused there. Held work stays durably pending (never acked, no
|
|
12640
|
+
* delivery attempt spent) and is released by {@link recheckPause} once the
|
|
12641
|
+
* owner resumes the agent. Only a channel whose send was refused with
|
|
12642
|
+
* `agent_paused` is ever held. Holding arms a recheck at the end of the
|
|
12643
|
+
* PAUSE_RECHECK_MS window, so release does not depend on new traffic.
|
|
12644
|
+
*/
|
|
12645
|
+
heldForPause(channelId) {
|
|
12646
|
+
const at = this.pausedChannels.get(channelId);
|
|
12647
|
+
if (at === void 0) return false;
|
|
12648
|
+
this.schedulePauseRecheck(channelId, at);
|
|
12649
|
+
return true;
|
|
12650
|
+
}
|
|
12651
|
+
schedulePauseRecheck(channelId, confirmedAt) {
|
|
12652
|
+
if (this.closingDown || this.pauseRecheckTimers.has(channelId)) return;
|
|
12653
|
+
const delay = Math.max(0, confirmedAt + PAUSE_RECHECK_MS - Date.now());
|
|
12654
|
+
const timer = setTimeout(() => {
|
|
12655
|
+
void this.recheckPause(channelId).catch((e) => {
|
|
12656
|
+
writeServiceLog("warn", "pause.recheck_failed", {}, e);
|
|
12657
|
+
});
|
|
12658
|
+
}, delay);
|
|
12659
|
+
timer.unref?.();
|
|
12660
|
+
this.pauseRecheckTimers.set(channelId, timer);
|
|
12661
|
+
}
|
|
12662
|
+
/**
|
|
12663
|
+
* Re-read this agent's own membership row. Still paused → keep holding and
|
|
12664
|
+
* re-arm while work is held. Resumed — or a failed/inconclusive probe, which
|
|
12665
|
+
* fails OPEN so a channel is never left deaf (the worst case is one model
|
|
12666
|
+
* call whose reply is refused again, re-arming the pause) — releases the
|
|
12667
|
+
* held work and pokes consumers to drain it now.
|
|
12668
|
+
*/
|
|
12669
|
+
async recheckPause(channelId) {
|
|
12670
|
+
const timer = this.pauseRecheckTimers.get(channelId);
|
|
12671
|
+
if (timer) clearTimeout(timer);
|
|
12672
|
+
this.pauseRecheckTimers.delete(channelId);
|
|
12673
|
+
if (!this.pausedChannels.has(channelId) || this.closingDown) return;
|
|
12674
|
+
if (await this.probeOwnPause(channelId) === true) {
|
|
12675
|
+
const now = Date.now();
|
|
12676
|
+
this.pausedChannels.set(channelId, now);
|
|
12677
|
+
if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
|
|
12678
|
+
return;
|
|
12679
|
+
}
|
|
12680
|
+
this.clearPause(channelId);
|
|
12681
|
+
}
|
|
12682
|
+
clearPause(channelId) {
|
|
12683
|
+
this.pausedChannels.delete(channelId);
|
|
12684
|
+
const timer = this.pauseRecheckTimers.get(channelId);
|
|
12685
|
+
if (timer) clearTimeout(timer);
|
|
12686
|
+
this.pauseRecheckTimers.delete(channelId);
|
|
12687
|
+
writeServiceLog("info", "pause.released");
|
|
12688
|
+
this.wakeListeners();
|
|
12689
|
+
}
|
|
12690
|
+
/** Read this agent's own `paused_at` from the enclave roster; undefined = unknown. */
|
|
12691
|
+
async probeOwnPause(channelId) {
|
|
12692
|
+
try {
|
|
12693
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves/${encodeURIComponent(
|
|
12694
|
+
this.resolveEnclave(channelId)
|
|
12695
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
12696
|
+
const res = await this.fetchImpl(url, {
|
|
12697
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
12698
|
+
});
|
|
12699
|
+
if (!res.ok) return void 0;
|
|
12700
|
+
const rows = await res.json();
|
|
12701
|
+
if (!Array.isArray(rows)) return void 0;
|
|
12702
|
+
const own = rows.find(
|
|
12703
|
+
(r) => r?.identity_id === this.cfg.identityId
|
|
12704
|
+
);
|
|
12705
|
+
if (!own) return void 0;
|
|
12706
|
+
return typeof own.paused_at === "string" && own.paused_at.length > 0;
|
|
12707
|
+
} catch (e) {
|
|
12708
|
+
writeServiceLog("warn", "pause.probe_failed", {}, e);
|
|
12709
|
+
return void 0;
|
|
12710
|
+
}
|
|
12711
|
+
}
|
|
10501
12712
|
recordRuntimeReply(inReplyTo, channelId, replyMessageId) {
|
|
10502
|
-
return this.
|
|
12713
|
+
return this.store.recordRuntimeReplyReceipt(inReplyTo, channelId, replyMessageId);
|
|
10503
12714
|
}
|
|
10504
12715
|
runtimeReplyReceipt(inReplyTo) {
|
|
10505
|
-
return this.
|
|
12716
|
+
return this.store.runtimeReplyReceipt(inReplyTo);
|
|
12717
|
+
}
|
|
12718
|
+
/**
|
|
12719
|
+
* Phase 3 F1: retire a runtime message whose reply failed TERMINALLY
|
|
12720
|
+
* (agent_paused / send_forbidden): acknowledged durably, never redelivered,
|
|
12721
|
+
* so the model is not run again for it. Pokes consumers so a socket holding
|
|
12722
|
+
* it in flight frees the channel for its next message.
|
|
12723
|
+
*/
|
|
12724
|
+
retireRuntimeMessage(messageId) {
|
|
12725
|
+
this.ackRuntimeMessages([messageId]);
|
|
12726
|
+
if (this.retiredRuntimeIds.size >= 5e3) {
|
|
12727
|
+
const oldest = this.retiredRuntimeIds.values().next();
|
|
12728
|
+
if (!oldest.done) this.retiredRuntimeIds.delete(oldest.value);
|
|
12729
|
+
}
|
|
12730
|
+
this.retiredRuntimeIds.add(messageId);
|
|
12731
|
+
this.wakeListeners();
|
|
12732
|
+
}
|
|
12733
|
+
isRuntimeMessageRetired(messageId) {
|
|
12734
|
+
return this.retiredRuntimeIds.has(messageId);
|
|
12735
|
+
}
|
|
12736
|
+
/** Complete the durable handoff only after the runtime explicitly acks. */
|
|
12737
|
+
ackRuntimeMessages(messageIds) {
|
|
12738
|
+
for (const messageId of messageIds) {
|
|
12739
|
+
this.store.ackRuntimeInbox(messageId);
|
|
12740
|
+
this.runtimePendingIds.delete(messageId);
|
|
12741
|
+
this.deliveryAttempts.delete(messageId);
|
|
12742
|
+
this.redeliverNotBefore.delete(messageId);
|
|
12743
|
+
}
|
|
12744
|
+
}
|
|
12745
|
+
async restoreRuntimeInbox() {
|
|
12746
|
+
const rows = await this.store.loadRuntimeInbox();
|
|
12747
|
+
for (const row of rows) {
|
|
12748
|
+
const message = JSON.parse(new TextDecoder().decode(row.payload));
|
|
12749
|
+
if (!message || message.message_id !== row.messageId || typeof message.channel_id !== "string") {
|
|
12750
|
+
throw new Error("restored runtime inbox row has an invalid message binding");
|
|
12751
|
+
}
|
|
12752
|
+
this.runtimePendingIds.add(message.message_id);
|
|
12753
|
+
if (row.deliveryAttempts > 0) {
|
|
12754
|
+
this.deliveryAttempts.set(message.message_id, row.deliveryAttempts);
|
|
12755
|
+
if (row.deliveryAttempts < this.maxDeliveryAttempts) {
|
|
12756
|
+
this.redeliverNotBefore.set(message.message_id, Date.now() + redeliveryDelayMs(row.deliveryAttempts));
|
|
12757
|
+
}
|
|
12758
|
+
}
|
|
12759
|
+
this.buffer.push(message);
|
|
12760
|
+
}
|
|
12761
|
+
}
|
|
12762
|
+
/**
|
|
12763
|
+
* Seal a runtime frame before the backend queue id may be acknowledged.
|
|
12764
|
+
* Returning false is backpressure: the caller leaves the backend row unacked
|
|
12765
|
+
* so it remains the durable queue rather than shedding accepted work.
|
|
12766
|
+
*/
|
|
12767
|
+
async stageRuntimeMessage(message) {
|
|
12768
|
+
if (this.runtimePendingIds.has(message.message_id)) return true;
|
|
12769
|
+
if (this.runtimePendingIds.size >= this.maxBuffer) {
|
|
12770
|
+
writeServiceLog("warn", "runtime_inbox.full");
|
|
12771
|
+
return false;
|
|
12772
|
+
}
|
|
12773
|
+
try {
|
|
12774
|
+
const payload = new TextEncoder().encode(JSON.stringify(message));
|
|
12775
|
+
await this.store.stageRuntimeInbox(message.message_id, payload, message.ts);
|
|
12776
|
+
this.runtimePendingIds.add(message.message_id);
|
|
12777
|
+
this.buffer.push(message);
|
|
12778
|
+
return true;
|
|
12779
|
+
} catch (error) {
|
|
12780
|
+
writeServiceLog("error", "runtime_inbox.stage_failed", {}, error);
|
|
12781
|
+
return false;
|
|
12782
|
+
}
|
|
10506
12783
|
}
|
|
10507
12784
|
/**
|
|
10508
12785
|
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
@@ -10590,6 +12867,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10590
12867
|
buffered: this.buffer.length,
|
|
10591
12868
|
dropped: this.droppedMessages,
|
|
10592
12869
|
filtered_room: this.filteredRoomMessages,
|
|
12870
|
+
dead_lettered: this.deadLetteredMessages,
|
|
12871
|
+
paused_channels: this.pausedChannels.size,
|
|
12872
|
+
agent_loop_suppressed: this.agentLoopSuppressed,
|
|
10593
12873
|
rate_limited: this.rateLimitedSends,
|
|
10594
12874
|
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
10595
12875
|
quarantine_drained: this.provider.quarantineDrainedCount(),
|
|
@@ -10642,6 +12922,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10642
12922
|
errors: this.provider.errorStats(),
|
|
10643
12923
|
dropped: this.droppedMessages,
|
|
10644
12924
|
filteredRoomMessages: this.filteredRoomMessages,
|
|
12925
|
+
deadLetteredMessages: this.deadLetteredMessages,
|
|
12926
|
+
pausedChannels: this.pausedChannels.size,
|
|
12927
|
+
agentLoopSuppressed: this.agentLoopSuppressed,
|
|
10645
12928
|
rateLimitedSends: this.rateLimitedSends,
|
|
10646
12929
|
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
10647
12930
|
quarantineDrained: this.provider.quarantineDrainedCount(),
|
|
@@ -10653,6 +12936,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
10653
12936
|
}
|
|
10654
12937
|
async close() {
|
|
10655
12938
|
this.closingDown = true;
|
|
12939
|
+
for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
|
|
12940
|
+
this.pauseRecheckTimers.clear();
|
|
12941
|
+
if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
|
|
12942
|
+
this.redeliveryTimer = null;
|
|
10656
12943
|
this.pingHandle?.close();
|
|
10657
12944
|
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
10658
12945
|
if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
|
|
@@ -10720,6 +13007,9 @@ var BRIDGE_ERRORS = {
|
|
|
10720
13007
|
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
10721
13008
|
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
10722
13009
|
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
13010
|
+
// Phase 3 F1: TERMINAL send outcomes — do not retry the turn.
|
|
13011
|
+
agent_paused: { http: 403, message: "agent is paused in this channel; not sent" },
|
|
13012
|
+
send_forbidden: { http: 403, message: "send refused (credential revoked or not a member); not sent" },
|
|
10723
13013
|
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
10724
13014
|
// FileSafe vault (B7): digest mismatch, DEK-wrap AEAD failure (bad/dropped
|
|
10725
13015
|
// nonce, wrong KEK), owner-pin/signature/rollback rejection, or "no wrap
|
|
@@ -10742,7 +13032,7 @@ function wsErrorFrame(code, detail) {
|
|
|
10742
13032
|
}
|
|
10743
13033
|
|
|
10744
13034
|
// src/http-server.ts
|
|
10745
|
-
function writeSse(res, msgs) {
|
|
13035
|
+
function writeSse(res, msgs, onWriteError) {
|
|
10746
13036
|
for (let i = 0; i < msgs.length; i++) {
|
|
10747
13037
|
if (res.destroyed || res.writableEnded) return msgs.slice(i);
|
|
10748
13038
|
try {
|
|
@@ -10750,12 +13040,24 @@ function writeSse(res, msgs) {
|
|
|
10750
13040
|
data: ${JSON.stringify(msgs[i])}
|
|
10751
13041
|
|
|
10752
13042
|
`);
|
|
10753
|
-
} catch {
|
|
13043
|
+
} catch (err) {
|
|
13044
|
+
try {
|
|
13045
|
+
onWriteError?.(err);
|
|
13046
|
+
} catch {
|
|
13047
|
+
}
|
|
10754
13048
|
return msgs.slice(i);
|
|
10755
13049
|
}
|
|
10756
13050
|
}
|
|
10757
13051
|
return [];
|
|
10758
13052
|
}
|
|
13053
|
+
function sseWriteFailureReporter() {
|
|
13054
|
+
let warned = false;
|
|
13055
|
+
return (error) => {
|
|
13056
|
+
if (warned) return;
|
|
13057
|
+
warned = true;
|
|
13058
|
+
writeServiceLog("warn", "sse.write_failed", {}, error);
|
|
13059
|
+
};
|
|
13060
|
+
}
|
|
10759
13061
|
function capabilities(opts) {
|
|
10760
13062
|
return {
|
|
10761
13063
|
api_version: "1",
|
|
@@ -10782,6 +13084,8 @@ var sendSchema = z3.object({
|
|
|
10782
13084
|
text: z3.string().optional(),
|
|
10783
13085
|
idempotency_key: z3.string().optional(),
|
|
10784
13086
|
in_reply_to: z3.string().min(1).max(200).optional(),
|
|
13087
|
+
/** Final-wave I2: a notice (e.g. a daily-limit reply) is not an agent turn. */
|
|
13088
|
+
notice: z3.boolean().optional(),
|
|
10785
13089
|
a2a: a2aSchema.optional()
|
|
10786
13090
|
}).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
|
|
10787
13091
|
message: "send requires either text or a2a"
|
|
@@ -10907,7 +13211,16 @@ function createHttpServer(agent, opts) {
|
|
|
10907
13211
|
if (method === "POST" && path === "/v1/send") {
|
|
10908
13212
|
const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
|
|
10909
13213
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
10910
|
-
|
|
13214
|
+
let r;
|
|
13215
|
+
const turn = { agentTurn: isAgentTurn(agent, parsed.data) };
|
|
13216
|
+
try {
|
|
13217
|
+
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);
|
|
13218
|
+
} catch (e) {
|
|
13219
|
+
if (parsed.data.in_reply_to && (e instanceof AgentPausedSendError || e instanceof SendForbiddenError)) {
|
|
13220
|
+
agent.retireRuntimeMessage(parsed.data.in_reply_to);
|
|
13221
|
+
}
|
|
13222
|
+
throw e;
|
|
13223
|
+
}
|
|
10911
13224
|
if (parsed.data.in_reply_to) {
|
|
10912
13225
|
agent.recordRuntimeReply(parsed.data.in_reply_to, parsed.data.channel_id, r.message_id);
|
|
10913
13226
|
}
|
|
@@ -10983,8 +13296,12 @@ function createHttpServer(agent, opts) {
|
|
|
10983
13296
|
connection: "keep-alive"
|
|
10984
13297
|
});
|
|
10985
13298
|
res.write(":connected\n\n");
|
|
13299
|
+
const onWriteError = sseWriteFailureReporter();
|
|
10986
13300
|
const drainToClient = () => {
|
|
10987
|
-
const
|
|
13301
|
+
const batch = agent.takeBuffered(channel);
|
|
13302
|
+
const left2 = writeSse(res, batch, onWriteError);
|
|
13303
|
+
const leftIds = new Set(left2.map((message) => message.message_id));
|
|
13304
|
+
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
10988
13305
|
if (left2.length) agent.requeueFront(left2);
|
|
10989
13306
|
};
|
|
10990
13307
|
const unsubscribe = agent.subscribe(drainToClient);
|
|
@@ -11012,11 +13329,12 @@ function createHttpServer(agent, opts) {
|
|
|
11012
13329
|
}
|
|
11013
13330
|
if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
|
|
11014
13331
|
if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
|
|
13332
|
+
if (e instanceof AgentPausedSendError) return jsonError(res, "agent_paused");
|
|
13333
|
+
if (e instanceof SendForbiddenError) return jsonError(res, "send_forbidden");
|
|
11015
13334
|
if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
|
|
11016
13335
|
if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
|
|
11017
13336
|
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
11018
|
-
|
|
11019
|
-
`);
|
|
13337
|
+
writeServiceLog("error", "http.request_failed", {}, e);
|
|
11020
13338
|
return jsonError(res, "internal");
|
|
11021
13339
|
}
|
|
11022
13340
|
};
|
|
@@ -11081,21 +13399,35 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11081
13399
|
});
|
|
11082
13400
|
const inflight = /* @__PURE__ */ new Map();
|
|
11083
13401
|
const flush = () => {
|
|
11084
|
-
|
|
11085
|
-
|
|
11086
|
-
|
|
11087
|
-
|
|
11088
|
-
agent.
|
|
11089
|
-
|
|
13402
|
+
for (; ; ) {
|
|
13403
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
13404
|
+
const busy = /* @__PURE__ */ new Set();
|
|
13405
|
+
for (const [id, m] of inflight) {
|
|
13406
|
+
if (agent.isRuntimeMessageRetired(id)) inflight.delete(id);
|
|
13407
|
+
else busy.add(m.channel_id);
|
|
11090
13408
|
}
|
|
11091
|
-
const
|
|
11092
|
-
|
|
11093
|
-
|
|
11094
|
-
|
|
11095
|
-
|
|
11096
|
-
|
|
11097
|
-
|
|
13409
|
+
const batch = agent.takeDeliverable(busy);
|
|
13410
|
+
if (batch.length === 0) return;
|
|
13411
|
+
let retired = false;
|
|
13412
|
+
for (let i = 0; i < batch.length; i++) {
|
|
13413
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
13414
|
+
agent.requeueFront(batch.slice(i));
|
|
13415
|
+
return;
|
|
13416
|
+
}
|
|
13417
|
+
if (!agent.admitRuntimeDelivery(batch[i])) {
|
|
13418
|
+
retired = true;
|
|
13419
|
+
continue;
|
|
13420
|
+
}
|
|
13421
|
+
const frame = { type: "message", ...batch[i] };
|
|
13422
|
+
try {
|
|
13423
|
+
sock.send(JSON.stringify(frame));
|
|
13424
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
13425
|
+
} catch {
|
|
13426
|
+
agent.requeueFront(batch.slice(i));
|
|
13427
|
+
return;
|
|
13428
|
+
}
|
|
11098
13429
|
}
|
|
13430
|
+
if (!retired) return;
|
|
11099
13431
|
}
|
|
11100
13432
|
};
|
|
11101
13433
|
const unsubscribe = agent.subscribe(flush);
|
|
@@ -11106,7 +13438,9 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11106
13438
|
agent.noteSessionClose();
|
|
11107
13439
|
unsubscribe();
|
|
11108
13440
|
if (inflight.size) {
|
|
11109
|
-
agent.
|
|
13441
|
+
agent.requeueFailedRuntimeDeliveries(
|
|
13442
|
+
[...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
|
|
13443
|
+
);
|
|
11110
13444
|
inflight.clear();
|
|
11111
13445
|
agent.wakeListeners();
|
|
11112
13446
|
}
|
|
@@ -11116,7 +13450,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
11116
13450
|
sock.on("close", cleanup);
|
|
11117
13451
|
sock.on("error", cleanup);
|
|
11118
13452
|
sock.on("message", (data) => {
|
|
11119
|
-
void handleInbound(sock, agent, data, inflight);
|
|
13453
|
+
void handleInbound(sock, agent, data, inflight, flush);
|
|
11120
13454
|
});
|
|
11121
13455
|
flush();
|
|
11122
13456
|
});
|
|
@@ -11140,7 +13474,14 @@ function sendJson(sock, body) {
|
|
|
11140
13474
|
} catch {
|
|
11141
13475
|
}
|
|
11142
13476
|
}
|
|
11143
|
-
|
|
13477
|
+
function sendErrorCode(e) {
|
|
13478
|
+
if (e instanceof ChannelPendingError) return "channel_pending";
|
|
13479
|
+
if (e instanceof RateLimitedError) return "rate_limited";
|
|
13480
|
+
if (e instanceof AgentPausedSendError) return "agent_paused";
|
|
13481
|
+
if (e instanceof SendForbiddenError) return "send_forbidden";
|
|
13482
|
+
return "invalid_request";
|
|
13483
|
+
}
|
|
13484
|
+
async function handleInbound(sock, agent, data, inflight, flush) {
|
|
11144
13485
|
let msg;
|
|
11145
13486
|
try {
|
|
11146
13487
|
msg = JSON.parse(data.toString("utf8"));
|
|
@@ -11159,8 +13500,40 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11159
13500
|
return;
|
|
11160
13501
|
}
|
|
11161
13502
|
inflight.delete(m.message_id);
|
|
13503
|
+
agent.ackRuntimeMessages([m.message_id]);
|
|
13504
|
+
flush();
|
|
13505
|
+
return;
|
|
13506
|
+
}
|
|
13507
|
+
if (m.type === "hello") {
|
|
13508
|
+
sendJson(sock, { type: "hello", features: ["nack"] });
|
|
13509
|
+
return;
|
|
13510
|
+
}
|
|
13511
|
+
if (m.type === "nack") {
|
|
13512
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
13513
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
13514
|
+
return;
|
|
13515
|
+
}
|
|
13516
|
+
const failed = inflight.get(m.message_id);
|
|
13517
|
+
if (!failed) return;
|
|
13518
|
+
inflight.delete(m.message_id);
|
|
13519
|
+
if (agent.isRuntimeMessageRetired(m.message_id)) {
|
|
13520
|
+
flush();
|
|
13521
|
+
return;
|
|
13522
|
+
}
|
|
13523
|
+
writeServiceLog("warn", "runtime_inbox.nack", { reply_failed: m.reason === "reply_failed" });
|
|
13524
|
+
agent.requeueFailedRuntimeDeliveries([failed]);
|
|
13525
|
+
flush();
|
|
11162
13526
|
return;
|
|
11163
13527
|
}
|
|
13528
|
+
const replyFailed = (e, inReplyTo) => {
|
|
13529
|
+
const code = sendErrorCode(e);
|
|
13530
|
+
if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
|
|
13531
|
+
inflight.delete(inReplyTo);
|
|
13532
|
+
agent.ackRuntimeMessages([inReplyTo]);
|
|
13533
|
+
}
|
|
13534
|
+
sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
|
|
13535
|
+
if (code === "agent_paused" || code === "send_forbidden") flush();
|
|
13536
|
+
};
|
|
11164
13537
|
if (m.type === "send") {
|
|
11165
13538
|
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
11166
13539
|
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
@@ -11177,19 +13550,18 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11177
13550
|
return;
|
|
11178
13551
|
}
|
|
11179
13552
|
try {
|
|
11180
|
-
const r = await agent.sendA2A(
|
|
13553
|
+
const r = await agent.sendA2A(
|
|
13554
|
+
m.channel_id,
|
|
13555
|
+
{ a2a_version: a.a2a_version, kind: a.kind, object: a.object },
|
|
13556
|
+
void 0,
|
|
13557
|
+
{ agentTurn: isAgentTurn(agent, m) }
|
|
13558
|
+
);
|
|
11181
13559
|
if (typeof m.in_reply_to === "string") {
|
|
11182
13560
|
agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
|
|
11183
13561
|
}
|
|
11184
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
13562
|
+
sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
|
|
11185
13563
|
} catch (e) {
|
|
11186
|
-
|
|
11187
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
11188
|
-
} else if (e instanceof RateLimitedError) {
|
|
11189
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
11190
|
-
} else {
|
|
11191
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
11192
|
-
}
|
|
13564
|
+
replyFailed(e, m.in_reply_to);
|
|
11193
13565
|
}
|
|
11194
13566
|
return;
|
|
11195
13567
|
}
|
|
@@ -11198,19 +13570,13 @@ async function handleInbound(sock, agent, data, inflight) {
|
|
|
11198
13570
|
return;
|
|
11199
13571
|
}
|
|
11200
13572
|
try {
|
|
11201
|
-
const r = await agent.send(m.channel_id, m.text);
|
|
13573
|
+
const r = await agent.send(m.channel_id, m.text, void 0, { agentTurn: isAgentTurn(agent, m) });
|
|
11202
13574
|
if (typeof m.in_reply_to === "string") {
|
|
11203
13575
|
agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
|
|
11204
13576
|
}
|
|
11205
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
13577
|
+
sendJson(sock, { type: "sent", message_id: r.message_id, in_reply_to: m.in_reply_to });
|
|
11206
13578
|
} catch (e) {
|
|
11207
|
-
|
|
11208
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
11209
|
-
} else if (e instanceof RateLimitedError) {
|
|
11210
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
11211
|
-
} else {
|
|
11212
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
11213
|
-
}
|
|
13579
|
+
replyFailed(e, m.in_reply_to);
|
|
11214
13580
|
}
|
|
11215
13581
|
return;
|
|
11216
13582
|
}
|
|
@@ -12587,8 +14953,7 @@ async function runServiceVerb(verb) {
|
|
|
12587
14953
|
}
|
|
12588
14954
|
async function main() {
|
|
12589
14955
|
if (!nodeMajorOk(process.versions.node)) {
|
|
12590
|
-
|
|
12591
|
-
`);
|
|
14956
|
+
writeServiceLog("error", "startup.node_unsupported");
|
|
12592
14957
|
process.exit(1);
|
|
12593
14958
|
}
|
|
12594
14959
|
const verb = parseVerb(process.argv);
|
|
@@ -12606,7 +14971,7 @@ async function main() {
|
|
|
12606
14971
|
}
|
|
12607
14972
|
const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
|
|
12608
14973
|
if (!credentialPath) {
|
|
12609
|
-
|
|
14974
|
+
writeServiceLog("error", "startup.credential_required.use_--credential");
|
|
12610
14975
|
process.exit(2);
|
|
12611
14976
|
}
|
|
12612
14977
|
const storePath = argOrEnv("--store", "SECURECOMS_STORE");
|
|
@@ -12616,8 +14981,7 @@ async function main() {
|
|
|
12616
14981
|
daemon = parseDaemonArgs(process.argv, process.env);
|
|
12617
14982
|
validateBind(daemon.bind, daemon.tls);
|
|
12618
14983
|
} catch (e) {
|
|
12619
|
-
|
|
12620
|
-
`);
|
|
14984
|
+
writeServiceLog("error", "startup.config_invalid", {}, e);
|
|
12621
14985
|
process.exit(1);
|
|
12622
14986
|
}
|
|
12623
14987
|
const tokenFile = daemon.tokenFile ?? join13(dirname7(credentialPath), "bridge-token");
|
|
@@ -12648,12 +15012,8 @@ async function main() {
|
|
|
12648
15012
|
const slug = slugFromCredentialPath(credentialPath);
|
|
12649
15013
|
process.once("exit", () => removeConnectionFileIfOwned(homedir3(), identityId, instanceId));
|
|
12650
15014
|
server.listen(daemon.port, daemon.bind, () => {
|
|
12651
|
-
|
|
12652
|
-
|
|
12653
|
-
process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
|
|
12654
|
-
`);
|
|
12655
|
-
process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
|
|
12656
|
-
`);
|
|
15015
|
+
writeServiceLog("info", "startup.listening", { port: daemon.port, tls: !!tls });
|
|
15016
|
+
writeServiceLog("info", "startup.token_loaded");
|
|
12657
15017
|
const addr = server.address();
|
|
12658
15018
|
const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
|
|
12659
15019
|
try {
|
|
@@ -12666,11 +15026,9 @@ async function main() {
|
|
|
12666
15026
|
instanceId,
|
|
12667
15027
|
now: (/* @__PURE__ */ new Date()).toISOString()
|
|
12668
15028
|
}));
|
|
12669
|
-
|
|
12670
|
-
`);
|
|
15029
|
+
writeServiceLog("info", "startup.connection_file_written");
|
|
12671
15030
|
} catch (e) {
|
|
12672
|
-
|
|
12673
|
-
`);
|
|
15031
|
+
writeServiceLog("warn", "startup.connection_file_write_failed", {}, e);
|
|
12674
15032
|
}
|
|
12675
15033
|
});
|
|
12676
15034
|
}
|
|
@@ -12678,8 +15036,7 @@ var here = fileURLToPath2(import.meta.url);
|
|
|
12678
15036
|
var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync);
|
|
12679
15037
|
if (isMain) {
|
|
12680
15038
|
main().catch((e) => {
|
|
12681
|
-
|
|
12682
|
-
`);
|
|
15039
|
+
writeServiceLog("error", "startup.fatal", {}, e);
|
|
12683
15040
|
process.exit(1);
|
|
12684
15041
|
});
|
|
12685
15042
|
}
|