@securecoms/agent-bridge 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/bin.js +1264 -148
- package/dist/bridge-agent.d.ts +118 -1
- package/dist/index.js +1247 -131
- package/dist/memory/unified/item-book.d.ts +5 -2
- package/dist/version.d.ts +1 -1
- package/dist/voice-channel-binding.d.ts +201 -0
- package/dist/voice-channel-frame.d.ts +22 -0
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -15433,6 +15433,18 @@ async function sendWithSelfHeal(session, channelId, build, publish) {
|
|
|
15433
15433
|
// ../connect-sdk-ts/dist/groupkey/stub.js
|
|
15434
15434
|
import { sha256 as sha2563 } from "@noble/hashes/sha2";
|
|
15435
15435
|
|
|
15436
|
+
// ../connect-sdk-ts/dist/receive/inbox.js
|
|
15437
|
+
function parseOpenedAt(v) {
|
|
15438
|
+
if (typeof v !== "object" || v === null)
|
|
15439
|
+
return void 0;
|
|
15440
|
+
const o = v;
|
|
15441
|
+
if (typeof o.groupId !== "string" || o.groupId.length === 0)
|
|
15442
|
+
return void 0;
|
|
15443
|
+
if (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0)
|
|
15444
|
+
return void 0;
|
|
15445
|
+
return { groupId: o.groupId, epoch: o.epoch };
|
|
15446
|
+
}
|
|
15447
|
+
|
|
15436
15448
|
// ../connect-sdk-ts/dist/spans/names.js
|
|
15437
15449
|
var SPAN = {
|
|
15438
15450
|
MESSAGE_SENT: "agentvault.connect.message.sent",
|
|
@@ -18161,7 +18173,9 @@ function decodeCustodyEntry(bytes) {
|
|
|
18161
18173
|
if (typeof plaintextHex !== "string" || typeof rest.queueId !== "string" || typeof rest.channelId !== "string" || typeof rest.custodiedAt !== "number") {
|
|
18162
18174
|
throw new Error("malformed custody entry");
|
|
18163
18175
|
}
|
|
18164
|
-
|
|
18176
|
+
const { openedAt, ...others } = rest;
|
|
18177
|
+
const position = parseOpenedAt(openedAt);
|
|
18178
|
+
return { ...others, ...position ? { openedAt: position } : {}, plaintext: hexToBytes22(plaintextHex) };
|
|
18165
18179
|
}
|
|
18166
18180
|
function custodyAad(identityId, queueId) {
|
|
18167
18181
|
return new TextEncoder().encode(`securecoms-custody-v1\0${identityId}\0${queueId}`);
|
|
@@ -18209,7 +18223,17 @@ var CODES = [
|
|
|
18209
18223
|
// missed and before any write, so nothing landed. Definite: the committer's
|
|
18210
18224
|
// work cannot name an agent in a humans-only enclave (admission refuses
|
|
18211
18225
|
// one), so dropping the commit starts no resend loop.
|
|
18212
|
-
{ status: 403, code: "humans_only_enclave" }
|
|
18226
|
+
{ status: 403, code: "humans_only_enclave" },
|
|
18227
|
+
// ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
|
|
18228
|
+
// leaf-key claim map is missing, does not cover exactly the Welcome
|
|
18229
|
+
// targets, or names a key that is not the target's current vendable
|
|
18230
|
+
// KeyPackage's. Refused before any write; definite, so the committer drops
|
|
18231
|
+
// the commit, refetches the KeyPackages and rebuilds instead of resending
|
|
18232
|
+
// the same stale bytes forever.
|
|
18233
|
+
{ status: 409, code: "voice_stale_key_package" },
|
|
18234
|
+
// PR-E: a Welcome target already holds a bound leaf in this V group (the
|
|
18235
|
+
// DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
|
|
18236
|
+
{ status: 409, code: "voice_leaf_already_bound" }
|
|
18213
18237
|
];
|
|
18214
18238
|
var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
|
|
18215
18239
|
var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
|
|
@@ -19256,6 +19280,31 @@ function ownLeafIndexOf(mgr) {
|
|
|
19256
19280
|
function tagOf(mgr) {
|
|
19257
19281
|
return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
19258
19282
|
}
|
|
19283
|
+
function leafKeyClaims(targets, pkgs) {
|
|
19284
|
+
if (targets.length !== pkgs.length)
|
|
19285
|
+
throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
|
|
19286
|
+
const out = {};
|
|
19287
|
+
targets.forEach((d, i) => {
|
|
19288
|
+
out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
|
|
19289
|
+
});
|
|
19290
|
+
return out;
|
|
19291
|
+
}
|
|
19292
|
+
function leafIndicesForSignatureKey(mgr, keyHex) {
|
|
19293
|
+
const tree = mgr.state?.ratchetTree;
|
|
19294
|
+
if (!tree)
|
|
19295
|
+
return [];
|
|
19296
|
+
const want = keyHex.toLowerCase();
|
|
19297
|
+
const out = [];
|
|
19298
|
+
const numLeaves = Math.floor((tree.length + 1) / 2);
|
|
19299
|
+
for (let i = 0; i < numLeaves; i++) {
|
|
19300
|
+
const node = tree[i * 2];
|
|
19301
|
+
if (!node || node.nodeType !== "leaf")
|
|
19302
|
+
continue;
|
|
19303
|
+
if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
|
|
19304
|
+
out.push(i);
|
|
19305
|
+
}
|
|
19306
|
+
return out;
|
|
19307
|
+
}
|
|
19259
19308
|
function tagOfState(state) {
|
|
19260
19309
|
const m = new MLSGroupManager(state.ciphersuite);
|
|
19261
19310
|
m.importState(state);
|
|
@@ -19370,6 +19419,12 @@ var SelfAddRefusedError = class extends Error {
|
|
|
19370
19419
|
this.name = "SelfAddRefusedError";
|
|
19371
19420
|
}
|
|
19372
19421
|
};
|
|
19422
|
+
var VoiceMirrorAddRefusedError = class extends Error {
|
|
19423
|
+
constructor() {
|
|
19424
|
+
super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
|
|
19425
|
+
this.name = "VoiceMirrorAddRefusedError";
|
|
19426
|
+
}
|
|
19427
|
+
};
|
|
19373
19428
|
var VaultCredentialMismatchError = class extends Error {
|
|
19374
19429
|
expected;
|
|
19375
19430
|
got;
|
|
@@ -20859,10 +20914,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20859
20914
|
* The row is deliberately NOT acked here — the subsequent ensureSession(join) /
|
|
20860
20915
|
* joinAndReconcile consumes the same Welcome and acks it after joining.
|
|
20861
20916
|
*/
|
|
20862
|
-
async peekWelcomeChannel(deviceId) {
|
|
20917
|
+
async peekWelcomeChannel(deviceId, opts = {}) {
|
|
20863
20918
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
20864
|
-
const
|
|
20865
|
-
|
|
20919
|
+
const asked = /* @__PURE__ */ new Set();
|
|
20920
|
+
for (const r of rows) {
|
|
20921
|
+
if (r.message_type !== "welcome" || r.connect_channel_id == null)
|
|
20922
|
+
continue;
|
|
20923
|
+
const ch = r.connect_channel_id;
|
|
20924
|
+
if (!opts.accept)
|
|
20925
|
+
return ch;
|
|
20926
|
+
if (asked.has(ch))
|
|
20927
|
+
continue;
|
|
20928
|
+
asked.add(ch);
|
|
20929
|
+
let ok = false;
|
|
20930
|
+
try {
|
|
20931
|
+
ok = await opts.accept(ch) === true;
|
|
20932
|
+
} catch {
|
|
20933
|
+
ok = false;
|
|
20934
|
+
}
|
|
20935
|
+
if (ok)
|
|
20936
|
+
return ch;
|
|
20937
|
+
}
|
|
20938
|
+
return null;
|
|
20866
20939
|
}
|
|
20867
20940
|
/**
|
|
20868
20941
|
* Publish this device's KeyPackage if it holds none; otherwise a no-op that
|
|
@@ -20968,10 +21041,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20968
21041
|
}
|
|
20969
21042
|
const attempted = [];
|
|
20970
21043
|
const failed = [];
|
|
21044
|
+
const discarded = [];
|
|
20971
21045
|
for (const channelId of channelIds) {
|
|
20972
21046
|
attempted.push(channelId);
|
|
20973
21047
|
try {
|
|
20974
|
-
await this.ensureSession(channelId, {
|
|
21048
|
+
await this.ensureSession(channelId, {
|
|
21049
|
+
enclaveId: "welcome-drain",
|
|
21050
|
+
bootstrap: false,
|
|
21051
|
+
onUnopenableWelcome: (groupId) => {
|
|
21052
|
+
discarded.push({ channelId, groupId });
|
|
21053
|
+
}
|
|
21054
|
+
});
|
|
20975
21055
|
} catch (error) {
|
|
20976
21056
|
failed.push({ channelId, error });
|
|
20977
21057
|
}
|
|
@@ -20981,7 +21061,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20981
21061
|
receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
|
|
20982
21062
|
this.drainReceipts.set(receipt, this.nowMs());
|
|
20983
21063
|
}
|
|
20984
|
-
return { attempted, failed, skipped, unknown, pageFull, receipt };
|
|
21064
|
+
return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
|
|
20985
21065
|
}
|
|
20986
21066
|
/**
|
|
20987
21067
|
* Resolve the channel's pending commit record, under the channel lock, and
|
|
@@ -21454,7 +21534,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21454
21534
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
21455
21535
|
return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
21456
21536
|
});
|
|
21457
|
-
const
|
|
21537
|
+
const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
|
|
21538
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
|
|
21458
21539
|
if (init.created === false) {
|
|
21459
21540
|
if (!this.isAdoptableOrphan(init, others)) {
|
|
21460
21541
|
if (this.isOrphanShape(init, others))
|
|
@@ -21474,7 +21555,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21474
21555
|
commit_epoch: Number(mgr.epoch),
|
|
21475
21556
|
expected_epoch: init.current_epoch,
|
|
21476
21557
|
member_device_ids: [this.o.deviceId, ...others],
|
|
21477
|
-
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
|
|
21558
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
|
|
21559
|
+
// Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
|
|
21560
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
|
|
21478
21561
|
}, mgr, null);
|
|
21479
21562
|
const res = await this.commitWithPendingRecord(channelId, rec, mgr);
|
|
21480
21563
|
if (!res.landed) {
|
|
@@ -21488,15 +21571,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21488
21571
|
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
|
|
21489
21572
|
if (!w)
|
|
21490
21573
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
21574
|
+
const aborted = () => opts.shouldAbort?.() === true;
|
|
21575
|
+
if (aborted())
|
|
21576
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21491
21577
|
let mgr;
|
|
21492
21578
|
try {
|
|
21493
21579
|
mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
|
|
21494
21580
|
} catch (err) {
|
|
21581
|
+
if (aborted())
|
|
21582
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21495
21583
|
this.recordDecryptError(w, err);
|
|
21496
21584
|
writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
|
|
21497
21585
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
21586
|
+
try {
|
|
21587
|
+
opts.onUnopenableWelcome?.(w.group_id);
|
|
21588
|
+
} catch (e) {
|
|
21589
|
+
writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
21590
|
+
}
|
|
21591
|
+
try {
|
|
21592
|
+
this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
|
|
21593
|
+
} catch (e) {
|
|
21594
|
+
writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
21595
|
+
}
|
|
21498
21596
|
return;
|
|
21499
21597
|
}
|
|
21598
|
+
if (aborted())
|
|
21599
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21500
21600
|
this.managers.set(channelId, mgr);
|
|
21501
21601
|
await this.saveMain(channelId, mgr);
|
|
21502
21602
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
@@ -21707,6 +21807,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21707
21807
|
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
|
|
21708
21808
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
21709
21809
|
groupId = init.group_id;
|
|
21810
|
+
if (this.o.isVoiceChannel?.(channelId) === true) {
|
|
21811
|
+
const mirror = init.member_device_ids;
|
|
21812
|
+
if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
|
|
21813
|
+
writeServiceLog2("warn", "admission.voice_device_already_in_mirror");
|
|
21814
|
+
throw new VoiceMirrorAddRefusedError();
|
|
21815
|
+
}
|
|
21816
|
+
}
|
|
21710
21817
|
members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
21711
21818
|
}
|
|
21712
21819
|
if (new TextDecoder().decode(mgr.groupId) !== groupId) {
|
|
@@ -21725,7 +21832,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21725
21832
|
expected_epoch: priorEpoch,
|
|
21726
21833
|
member_device_ids: members,
|
|
21727
21834
|
op: "add",
|
|
21728
|
-
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
|
|
21835
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
|
|
21836
|
+
// Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
|
|
21837
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
|
|
21729
21838
|
}, mgr, prior), mgr);
|
|
21730
21839
|
if (!res.landed) {
|
|
21731
21840
|
const e = res.error;
|
|
@@ -22046,6 +22155,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22046
22155
|
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
22047
22156
|
}
|
|
22048
22157
|
}
|
|
22158
|
+
/**
|
|
22159
|
+
* ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
|
|
22160
|
+
* Remove of one leaf the server's committer-work lists in `remove_devices`
|
|
22161
|
+
* (on the hidden voice channel V: a stale device of an agent or relay
|
|
22162
|
+
* identity that is still on V's roster).
|
|
22163
|
+
*
|
|
22164
|
+
* ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
|
|
22165
|
+
* identity's leaf by add-order position, which RFC 9420's leftmost-blank
|
|
22166
|
+
* refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
|
|
22167
|
+
* evicts the current device's leaf while the distribute's `removed_device_id`
|
|
22168
|
+
* makes the server drop the STALE device from its mirror. The current device
|
|
22169
|
+
* is then in the roster with no leaf, outside the adds delta forever: an
|
|
22170
|
+
* unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
|
|
22171
|
+
*
|
|
22172
|
+
* - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
|
|
22173
|
+
* is matched against THIS committer's live tree (the device-level leaf
|
|
22174
|
+
* predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
|
|
22175
|
+
* mints a fresh signature keypair per call, so a match names exactly the
|
|
22176
|
+
* leaf that KeyPackage was Added as;
|
|
22177
|
+
* - exactly ONE matching leaf, or nothing is committed;
|
|
22178
|
+
* - that leaf's credential identity is read from the tree, never from the
|
|
22179
|
+
* server, and is what the distribute names as `removed_identity_id`.
|
|
22180
|
+
*
|
|
22181
|
+
* Outcomes. `"removed"`: one leaf evicted and the distribute landed.
|
|
22182
|
+
* `"unresolved"`: no KeyPackage is published for the device, or it matches no
|
|
22183
|
+
* leaf (the device rotated it after its Add, or the leaf is already gone).
|
|
22184
|
+
* Nothing is committed: the leaf, if any, stays, and is reported, never
|
|
22185
|
+
* guessed. The server's next delta lists it again (the caller's throttle
|
|
22186
|
+
* bounds the retries).
|
|
22187
|
+
*
|
|
22188
|
+
* Refusals (thrown before any commit is built, so nothing is distributed):
|
|
22189
|
+
* the committer's own device (`own_device`), any leaf of the committer's own
|
|
22190
|
+
* identity, the committer's own leaf, or an unknown own leaf
|
|
22191
|
+
* (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
|
|
22192
|
+
* or more matching leaves; a held group that is not the channel's active
|
|
22193
|
+
* group (#757 binding, checked BEFORE init).
|
|
22194
|
+
*/
|
|
22195
|
+
async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
|
|
22196
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
22197
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
22198
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
|
|
22199
|
+
}));
|
|
22200
|
+
}
|
|
22201
|
+
async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
|
|
22202
|
+
if (await this.custodyHeld(channelId))
|
|
22203
|
+
throw new CustodyPersistError(channelId);
|
|
22204
|
+
if (deviceId === this.o.deviceId)
|
|
22205
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
|
|
22206
|
+
const mgr = await this.manager(channelId);
|
|
22207
|
+
const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
|
|
22208
|
+
if (resolved === "unresolved")
|
|
22209
|
+
return "unresolved";
|
|
22210
|
+
const { leafIndex, leafIdentity } = resolved;
|
|
22211
|
+
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
|
|
22212
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
22213
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
22214
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
|
|
22215
|
+
}
|
|
22216
|
+
const priorEpoch = Number(mgr.epoch);
|
|
22217
|
+
const prior = tagOf(mgr);
|
|
22218
|
+
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
22219
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
22220
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
|
|
22221
|
+
}
|
|
22222
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
22223
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
22224
|
+
device_id: this.o.deviceId,
|
|
22225
|
+
commit: bytesToHex22(commit),
|
|
22226
|
+
commit_epoch: Number(mgr.epoch),
|
|
22227
|
+
expected_epoch: priorEpoch,
|
|
22228
|
+
member_device_ids: members,
|
|
22229
|
+
op: "remove",
|
|
22230
|
+
removed_identity_id: leafIdentity,
|
|
22231
|
+
removed_device_id: deviceId,
|
|
22232
|
+
welcomes: []
|
|
22233
|
+
}, mgr, prior), mgr);
|
|
22234
|
+
if (!res.landed) {
|
|
22235
|
+
const e = res.error;
|
|
22236
|
+
if (isSupersededRefusal(e))
|
|
22237
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
22238
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
22239
|
+
throw e;
|
|
22240
|
+
try {
|
|
22241
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
22242
|
+
} catch (e2) {
|
|
22243
|
+
if (e2 instanceof CustodyPersistError)
|
|
22244
|
+
throw e2;
|
|
22245
|
+
}
|
|
22246
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
|
|
22247
|
+
}
|
|
22248
|
+
return "removed";
|
|
22249
|
+
}
|
|
22250
|
+
/** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
|
|
22251
|
+
* the committer's tree. Unchanged from Task 24 and its fix round 1. */
|
|
22252
|
+
async resolveServedKeyPackageLeaf(mgr, deviceId) {
|
|
22253
|
+
const kps = await this.o.backend.fetchKeyPackages([deviceId]);
|
|
22254
|
+
const hex = kps[deviceId];
|
|
22255
|
+
if (!hex) {
|
|
22256
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_key_package: true });
|
|
22257
|
+
return "unresolved";
|
|
22258
|
+
}
|
|
22259
|
+
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
22260
|
+
const leaves = leafIndicesForKeyPackage(mgr, pkg);
|
|
22261
|
+
if (leaves.length === 0) {
|
|
22262
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
|
|
22263
|
+
return "unresolved";
|
|
22264
|
+
}
|
|
22265
|
+
if (leaves.length > 1) {
|
|
22266
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
|
|
22267
|
+
}
|
|
22268
|
+
const leafIndex = leaves[0];
|
|
22269
|
+
const tree = mgr.state?.ratchetTree;
|
|
22270
|
+
if (!tree)
|
|
22271
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
22272
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
22273
|
+
if (cred.credentialType !== "basic") {
|
|
22274
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
|
|
22275
|
+
}
|
|
22276
|
+
const leafIdentity = new TextDecoder().decode(cred.identity);
|
|
22277
|
+
const ownIdentity = new TextDecoder().decode(this.o.identity);
|
|
22278
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
22279
|
+
if (!leafIdentity || leafIdentity === ownIdentity) {
|
|
22280
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
|
|
22281
|
+
}
|
|
22282
|
+
if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
|
|
22283
|
+
writeServiceLog2("warn", "committer.remove_device_refused_last_leaf", {});
|
|
22284
|
+
return "unresolved";
|
|
22285
|
+
}
|
|
22286
|
+
return { leafIndex, leafIdentity };
|
|
22287
|
+
}
|
|
22288
|
+
/**
|
|
22289
|
+
* ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
|
|
22290
|
+
* resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
|
|
22291
|
+
* `remove_device_leaves`). The server recorded the key when the leaf was
|
|
22292
|
+
* born, from the KeyPackage it vended, so it survives the target's
|
|
22293
|
+
* KeyPackage rotation, revocation and deletion, which the served-KeyPackage
|
|
22294
|
+
* path cannot.
|
|
22295
|
+
*
|
|
22296
|
+
* - Leaves are matched by the bound key ONLY: exactly one match, else
|
|
22297
|
+
* `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
|
|
22298
|
+
* the served KeyPackage: two evidence sources that disagree fail closed.
|
|
22299
|
+
* - The matched leaf's basic-credential identity must equal the server's
|
|
22300
|
+
* `identityId` for the device (R4), else a throw.
|
|
22301
|
+
* - The committer's own leaf index (or an unknown one) is refused
|
|
22302
|
+
* ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
|
|
22303
|
+
* - R5: a leaf of the committer's OWN identity is admitted (a sibling
|
|
22304
|
+
* device: a revoked laptop). The brick class the own-identity refusal
|
|
22305
|
+
* guards came from positional or identity-level resolution, which this
|
|
22306
|
+
* exact-key path cannot reach, and the own leaf index is still refused.
|
|
22307
|
+
* - No last-leaf refusal: the binding is exact evidence, and an identity's
|
|
22308
|
+
* last leaf is exactly the revoked single-device case (and carry A's
|
|
22309
|
+
* Remove before its re-Add).
|
|
22310
|
+
* Synchronous and before any commit is built: a refusal distributes nothing.
|
|
22311
|
+
*/
|
|
22312
|
+
resolveBoundDeviceLeaf(mgr, bound) {
|
|
22313
|
+
const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
|
|
22314
|
+
if (leaves.length === 0) {
|
|
22315
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
|
|
22316
|
+
return "unresolved";
|
|
22317
|
+
}
|
|
22318
|
+
if (leaves.length > 1) {
|
|
22319
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
|
|
22320
|
+
}
|
|
22321
|
+
const leafIndex = leaves[0];
|
|
22322
|
+
const tree = mgr.state?.ratchetTree;
|
|
22323
|
+
if (!tree)
|
|
22324
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
22325
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
22326
|
+
const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
22327
|
+
if (!leafIdentity || leafIdentity !== bound.identityId) {
|
|
22328
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
|
|
22329
|
+
}
|
|
22330
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
22331
|
+
return { leafIndex, leafIdentity };
|
|
22332
|
+
}
|
|
22049
22333
|
/**
|
|
22050
22334
|
* Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
|
|
22051
22335
|
* targeted by its owner-observed leaf `signaturePublicKey` against the live
|
|
@@ -22814,7 +23098,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22814
23098
|
* read #750 made in here is gone (ruling §8.2).
|
|
22815
23099
|
*/
|
|
22816
23100
|
async trySupersede(channelId, row, callerHeldGroupId, owner, mode, pullRows) {
|
|
22817
|
-
const
|
|
23101
|
+
const refuse3 = (msg) => {
|
|
22818
23102
|
this.recordDecryptError(row, new Error(msg));
|
|
22819
23103
|
const now = Date.now();
|
|
22820
23104
|
for (const [k, v] of this.supersedeRefusals) {
|
|
@@ -22847,34 +23131,34 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22847
23131
|
att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
22848
23132
|
}
|
|
22849
23133
|
if (!att) {
|
|
22850
|
-
return
|
|
23134
|
+
return refuse3(`supersede refused: no attestation for group ${row.group_id}`);
|
|
22851
23135
|
}
|
|
22852
23136
|
if (att.signerIdentityId !== owner.identityId) {
|
|
22853
|
-
return
|
|
23137
|
+
return refuse3("supersede refused: signer is not the pinned owner");
|
|
22854
23138
|
}
|
|
22855
23139
|
let content;
|
|
22856
23140
|
try {
|
|
22857
23141
|
content = JSON.parse(att.signedContent);
|
|
22858
23142
|
} catch {
|
|
22859
|
-
return
|
|
23143
|
+
return refuse3("supersede refused: unparseable signed_content");
|
|
22860
23144
|
}
|
|
22861
23145
|
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
22862
|
-
return
|
|
23146
|
+
return refuse3("supersede refused: attestation channel/group mismatch");
|
|
22863
23147
|
}
|
|
22864
23148
|
if (content.purpose !== RESET_SUPERSEDE_PURPOSE) {
|
|
22865
|
-
return
|
|
23149
|
+
return refuse3("supersede refused: attestation purpose is not connect-reset-supersede");
|
|
22866
23150
|
}
|
|
22867
23151
|
const targetCreatedMs = signedCreatedAtMs(content);
|
|
22868
23152
|
if (targetCreatedMs === null) {
|
|
22869
|
-
return
|
|
23153
|
+
return refuse3("supersede refused: unparseable attestation created_at");
|
|
22870
23154
|
}
|
|
22871
23155
|
const targetPrev = signedPrevLink(content, row.group_id);
|
|
22872
23156
|
if (targetPrev === null) {
|
|
22873
|
-
return
|
|
23157
|
+
return refuse3("supersede refused: unparseable attestation prev_group_id");
|
|
22874
23158
|
}
|
|
22875
23159
|
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
22876
23160
|
if (!ok) {
|
|
22877
|
-
return
|
|
23161
|
+
return refuse3("supersede refused: signature does not verify against pinned owner key");
|
|
22878
23162
|
}
|
|
22879
23163
|
const targetLink = targetPrev.chained ? { createdAtMs: targetCreatedMs, chained: true, prevGroupId: targetPrev.prev } : { createdAtMs: targetCreatedMs, chained: false, prevGroupId: null };
|
|
22880
23164
|
if (callerHeldGroupId !== null && callerHeldGroupId !== row.group_id) {
|
|
@@ -22884,7 +23168,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22884
23168
|
if (v.kind === "refuse") {
|
|
22885
23169
|
if (v.notNewer)
|
|
22886
23170
|
this.supersedeNotNewer.add(`${channelId}:${row.group_id}`);
|
|
22887
|
-
return
|
|
23171
|
+
return refuse3(v.reason);
|
|
22888
23172
|
}
|
|
22889
23173
|
}
|
|
22890
23174
|
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
@@ -22892,7 +23176,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22892
23176
|
if (!bundle && !supersedeSlot) {
|
|
22893
23177
|
if (mode === "adopt")
|
|
22894
23178
|
return { kind: "no_bundle" };
|
|
22895
|
-
return
|
|
23179
|
+
return refuse3("supersede refused: no pending KeyPackage bundle to open the new Welcome");
|
|
22896
23180
|
}
|
|
22897
23181
|
return await this.locks.run(channelId, async () => {
|
|
22898
23182
|
let inLockHeld;
|
|
@@ -22939,7 +23223,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22939
23223
|
return { kind: "unopenable", requested };
|
|
22940
23224
|
}
|
|
22941
23225
|
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
22942
|
-
return
|
|
23226
|
+
return refuse3("supersede refused: post-join group id != attested new_group_id");
|
|
22943
23227
|
}
|
|
22944
23228
|
let dead = null;
|
|
22945
23229
|
try {
|
|
@@ -23866,6 +24150,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23866
24150
|
if (supersededChannelIds.length) {
|
|
23867
24151
|
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
23868
24152
|
}
|
|
24153
|
+
if (w2Kept.size) {
|
|
24154
|
+
Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
|
|
24155
|
+
}
|
|
23869
24156
|
return appEntries;
|
|
23870
24157
|
}
|
|
23871
24158
|
/**
|
|
@@ -24064,15 +24351,20 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
24064
24351
|
done();
|
|
24065
24352
|
};
|
|
24066
24353
|
};
|
|
24354
|
+
const fromManager = async (mgr) => {
|
|
24355
|
+
const openedAt = { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
24356
|
+
const key = await mgr.exportSecret(LABEL, buildExporterContext(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch), 32);
|
|
24357
|
+
return { key, openedAt };
|
|
24358
|
+
};
|
|
24067
24359
|
const derive = keyFrom ? () => {
|
|
24068
24360
|
takeInFlight();
|
|
24069
|
-
return keyFrom
|
|
24070
|
-
} : () => {
|
|
24361
|
+
return fromManager(keyFrom);
|
|
24362
|
+
} : async () => {
|
|
24071
24363
|
takeInFlight();
|
|
24072
|
-
return this.
|
|
24364
|
+
return fromManager(await this.manager(envelope.channel_id));
|
|
24073
24365
|
};
|
|
24074
24366
|
try {
|
|
24075
|
-
const key = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
|
|
24367
|
+
const { key, openedAt } = opts.lockHeld ? await derive() : await this.locks.run(envelope.channel_id, derive);
|
|
24076
24368
|
const aad = await buildAssociatedData({
|
|
24077
24369
|
message_id: envelope.message_id,
|
|
24078
24370
|
channel_id: envelope.channel_id,
|
|
@@ -24096,7 +24388,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
24096
24388
|
voiceMode: row.voice_mode ?? void 0,
|
|
24097
24389
|
voiceDefaultResponder: row.voice_default_responder ?? void 0,
|
|
24098
24390
|
...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
|
|
24099
|
-
payloadHex: row.payload
|
|
24391
|
+
payloadHex: row.payload,
|
|
24392
|
+
openedAt
|
|
24100
24393
|
};
|
|
24101
24394
|
const custodiedAt = this.nowMs();
|
|
24102
24395
|
this.custody.set(row.queue_id, entry);
|
|
@@ -25651,9 +25944,9 @@ var RestMlsBackend = class {
|
|
|
25651
25944
|
current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
|
|
25652
25945
|
};
|
|
25653
25946
|
}
|
|
25654
|
-
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
25947
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
25655
25948
|
const path = `/api/v1/mls/connect-channels/${channelId}/init`;
|
|
25656
|
-
const body = { member_device_ids: memberDeviceIds };
|
|
25949
|
+
const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
|
|
25657
25950
|
let j;
|
|
25658
25951
|
for (let attempt = 0; ; attempt++) {
|
|
25659
25952
|
try {
|
|
@@ -25895,6 +26188,22 @@ var RestMlsBackend = class {
|
|
|
25895
26188
|
throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
|
|
25896
26189
|
return rows;
|
|
25897
26190
|
}
|
|
26191
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
26192
|
+
const res = await this.fetchBounded("mls_voice_leaf_unusable", "control", this.opts.baseUrl + `/api/v1/mls/groups/${groupId}/voice-leaf-unusable`, { method: "POST", headers: this.headers(), body: JSON.stringify({ device_id: deviceId }) });
|
|
26193
|
+
const text = await res.text().catch(() => "");
|
|
26194
|
+
if (res.ok)
|
|
26195
|
+
return { status: res.status };
|
|
26196
|
+
let parsed = null;
|
|
26197
|
+
if (text) {
|
|
26198
|
+
try {
|
|
26199
|
+
parsed = JSON.parse(text);
|
|
26200
|
+
} catch {
|
|
26201
|
+
parsed = null;
|
|
26202
|
+
}
|
|
26203
|
+
}
|
|
26204
|
+
const code = parseErrorCode(parsed);
|
|
26205
|
+
return code === void 0 ? { status: res.status } : { status: res.status, code };
|
|
26206
|
+
}
|
|
25898
26207
|
async getCommitterWork(channelId) {
|
|
25899
26208
|
const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
25900
26209
|
const requireList = (field) => {
|
|
@@ -25904,7 +26213,29 @@ var RestMlsBackend = class {
|
|
|
25904
26213
|
}
|
|
25905
26214
|
return v.map(String);
|
|
25906
26215
|
};
|
|
25907
|
-
|
|
26216
|
+
const work = {
|
|
26217
|
+
adds: requireList("adds"),
|
|
26218
|
+
removes: requireList("removes")
|
|
26219
|
+
};
|
|
26220
|
+
if (j?.remove_devices !== void 0) {
|
|
26221
|
+
work.removeDevices = requireList("remove_devices");
|
|
26222
|
+
}
|
|
26223
|
+
const rawLeaves = j?.remove_device_leaves;
|
|
26224
|
+
if (rawLeaves !== void 0) {
|
|
26225
|
+
if (!Array.isArray(rawLeaves))
|
|
26226
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
|
|
26227
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26228
|
+
work.removeDeviceLeaves = rawLeaves.map((e) => {
|
|
26229
|
+
const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
|
|
26230
|
+
const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
|
|
26231
|
+
if (!o || typeof o.device_id !== "string" || !o.device_id || typeof o.identity_id !== "string" || !o.identity_id || key === null || key.length === 0 || key.length % 2 !== 0 || !/^[0-9a-f]+$/.test(key) || seen.has(o.device_id)) {
|
|
26232
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
|
|
26233
|
+
}
|
|
26234
|
+
seen.add(o.device_id);
|
|
26235
|
+
return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
|
|
26236
|
+
});
|
|
26237
|
+
}
|
|
26238
|
+
return work;
|
|
25908
26239
|
}
|
|
25909
26240
|
async acquireCommitLease(channelId) {
|
|
25910
26241
|
try {
|
|
@@ -25954,6 +26285,48 @@ var RestMlsBackend = class {
|
|
|
25954
26285
|
|
|
25955
26286
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
25956
26287
|
var DELIVERY_PULL_LIMIT = 100;
|
|
26288
|
+
function keyPackageSignatureKeyHex(kpHex) {
|
|
26289
|
+
const b = hexToBytes22(kpHex);
|
|
26290
|
+
let at = 0;
|
|
26291
|
+
const u16 = () => {
|
|
26292
|
+
if (at + 2 > b.length)
|
|
26293
|
+
throw new Error("t");
|
|
26294
|
+
const v = b[at] << 8 | b[at + 1];
|
|
26295
|
+
at += 2;
|
|
26296
|
+
return v;
|
|
26297
|
+
};
|
|
26298
|
+
const opaque = () => {
|
|
26299
|
+
if (at >= b.length)
|
|
26300
|
+
throw new Error("t");
|
|
26301
|
+
const prefix = b[at] >> 6;
|
|
26302
|
+
if (prefix === 3)
|
|
26303
|
+
throw new Error("t");
|
|
26304
|
+
const size = 1 << prefix;
|
|
26305
|
+
if (at + size > b.length)
|
|
26306
|
+
throw new Error("t");
|
|
26307
|
+
let n = b[at] & 63;
|
|
26308
|
+
for (let i = 1; i < size; i++)
|
|
26309
|
+
n = n << 8 | b[at + i];
|
|
26310
|
+
at += size;
|
|
26311
|
+
if (at + n > b.length)
|
|
26312
|
+
throw new Error("t");
|
|
26313
|
+
const out = b.slice(at, at + n);
|
|
26314
|
+
at += n;
|
|
26315
|
+
return out;
|
|
26316
|
+
};
|
|
26317
|
+
try {
|
|
26318
|
+
if (u16() !== 1 || u16() !== 5)
|
|
26319
|
+
return null;
|
|
26320
|
+
u16();
|
|
26321
|
+
u16();
|
|
26322
|
+
opaque();
|
|
26323
|
+
opaque();
|
|
26324
|
+
const sig = opaque();
|
|
26325
|
+
return sig.length > 0 ? bytesToHex22(sig) : null;
|
|
26326
|
+
} catch {
|
|
26327
|
+
return null;
|
|
26328
|
+
}
|
|
26329
|
+
}
|
|
25957
26330
|
var FakeConnectBackend = class _FakeConnectBackend {
|
|
25958
26331
|
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
25959
26332
|
kps = /* @__PURE__ */ new Map();
|
|
@@ -26038,6 +26411,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26038
26411
|
* server's purge_device_queue. */
|
|
26039
26412
|
linkIdentityDevice(identityId, deviceId) {
|
|
26040
26413
|
this.deviceByIdentity.set(identityId, deviceId);
|
|
26414
|
+
this.identityByDevice.set(deviceId, identityId);
|
|
26041
26415
|
}
|
|
26042
26416
|
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
26043
26417
|
this.kps.set(deviceId, keyPackageHex);
|
|
@@ -26087,8 +26461,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26087
26461
|
current_epoch: this.epochByChannel.get(channelId) ?? 0
|
|
26088
26462
|
};
|
|
26089
26463
|
}
|
|
26090
|
-
|
|
26464
|
+
/** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
|
|
26465
|
+
* voice channel V. Init on an EXISTING V returns it unchanged and merges
|
|
26466
|
+
* nothing, as the real backend does. */
|
|
26467
|
+
voiceChannelIds = /* @__PURE__ */ new Set();
|
|
26468
|
+
/** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
|
|
26469
|
+
* {@link voiceChannelIds} only, as the PR-E server does for V:
|
|
26470
|
+
* `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
|
|
26471
|
+
* when a V leaf is born (the creator's init, an Add's Welcome) from the
|
|
26472
|
+
* verified claim, and deleted when a Remove of that device (or its
|
|
26473
|
+
* identity) lands. */
|
|
26474
|
+
voiceLeafBindings = /* @__PURE__ */ new Map();
|
|
26475
|
+
/** The device -> identity the server knows (the devices table). Written by
|
|
26476
|
+
* {@link linkIdentityDevice}; read for `remove_device_leaves`. */
|
|
26477
|
+
identityByDevice = /* @__PURE__ */ new Map();
|
|
26478
|
+
static voiceRefusal(path, code) {
|
|
26479
|
+
return new MlsBackendHttpError(path, 409, code);
|
|
26480
|
+
}
|
|
26481
|
+
/** The current vendable KeyPackage key of `deviceId`, or null. */
|
|
26482
|
+
currentLeafKey(deviceId) {
|
|
26483
|
+
const hex = this.kps.get(deviceId);
|
|
26484
|
+
return hex ? keyPackageSignatureKeyHex(hex) : null;
|
|
26485
|
+
}
|
|
26486
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
26091
26487
|
const alreadyExists = this.groupIdByChannel.has(channelId);
|
|
26488
|
+
if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
|
|
26489
|
+
const creator = memberDeviceIds[0];
|
|
26490
|
+
const current = creator ? this.currentLeafKey(creator) : null;
|
|
26491
|
+
if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
|
|
26492
|
+
throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
|
|
26493
|
+
}
|
|
26494
|
+
this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
|
|
26495
|
+
}
|
|
26092
26496
|
let groupId = this.groupIdByChannel.get(channelId);
|
|
26093
26497
|
if (!groupId) {
|
|
26094
26498
|
groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
|
|
@@ -26096,7 +26500,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26096
26500
|
this.channelByGroup.set(groupId, channelId);
|
|
26097
26501
|
this.epochByChannel.set(channelId, 0);
|
|
26098
26502
|
}
|
|
26099
|
-
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
26503
|
+
const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
26100
26504
|
this.membersByChannel.set(channelId, members);
|
|
26101
26505
|
return {
|
|
26102
26506
|
group_id: groupId,
|
|
@@ -26233,6 +26637,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26233
26637
|
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
|
|
26234
26638
|
}
|
|
26235
26639
|
}
|
|
26640
|
+
const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
|
|
26641
|
+
let bindNow = [];
|
|
26642
|
+
if (vBindings && req.commit && req.op !== "remove") {
|
|
26643
|
+
const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
|
|
26644
|
+
const claims = req.added_leaf_signature_keys;
|
|
26645
|
+
const path = _FakeConnectBackend.distributePath(groupId);
|
|
26646
|
+
if (!claims)
|
|
26647
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26648
|
+
const claimed = Object.keys(claims);
|
|
26649
|
+
if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
|
|
26650
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26651
|
+
}
|
|
26652
|
+
for (const t of targets) {
|
|
26653
|
+
const current = this.currentLeafKey(t);
|
|
26654
|
+
if (current === null || claims[t].toLowerCase() !== current)
|
|
26655
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26656
|
+
if (vBindings.has(t))
|
|
26657
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
|
|
26658
|
+
}
|
|
26659
|
+
bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
|
|
26660
|
+
}
|
|
26236
26661
|
if (req.commit) {
|
|
26237
26662
|
const log = this.commitLog.get(groupId) ?? [];
|
|
26238
26663
|
log.push({
|
|
@@ -26255,6 +26680,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26255
26680
|
this.membersByChannel.set(channelId, remaining);
|
|
26256
26681
|
if (evictedDevice)
|
|
26257
26682
|
this.queues.delete(evictedDevice);
|
|
26683
|
+
if (vBindings) {
|
|
26684
|
+
for (const d of [...vBindings.keys()]) {
|
|
26685
|
+
if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
|
|
26686
|
+
vBindings.delete(d);
|
|
26687
|
+
}
|
|
26688
|
+
}
|
|
26258
26689
|
} else {
|
|
26259
26690
|
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
|
|
26260
26691
|
this.membersByChannel.set(channelId, members);
|
|
@@ -26278,6 +26709,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26278
26709
|
});
|
|
26279
26710
|
}
|
|
26280
26711
|
}
|
|
26712
|
+
if (vBindings && bindNow.length > 0) {
|
|
26713
|
+
for (const [d, k] of bindNow)
|
|
26714
|
+
vBindings.set(d, { key: k, unusableSignaledAt: null });
|
|
26715
|
+
this.voiceLeafBindings.set(channelId, vBindings);
|
|
26716
|
+
}
|
|
26281
26717
|
for (const w of req.welcomes ?? []) {
|
|
26282
26718
|
this.q(w.target_device_id).push({
|
|
26283
26719
|
queue_id: "q-" + this._seq++,
|
|
@@ -26338,6 +26774,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26338
26774
|
async getCommitterWork(_channelId) {
|
|
26339
26775
|
return { adds: [], removes: [] };
|
|
26340
26776
|
}
|
|
26777
|
+
/** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
|
|
26778
|
+
* modelled: the group is a V (else 404), a binding exists (else 409), the
|
|
26779
|
+
* device's current vendable key differs from the bound one (409
|
|
26780
|
+
* `voice_leaf_key_current` when equal, `voice_no_current_key_package`
|
|
26781
|
+
* when it has none). An already-signalled row is an idempotent 202. The
|
|
26782
|
+
* bind and roster gates are the server's (backend tests). */
|
|
26783
|
+
signalVoiceLeafUnusableCalls = [];
|
|
26784
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
26785
|
+
this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
|
|
26786
|
+
const channelId = this.channelByGroup.get(groupId);
|
|
26787
|
+
if (!channelId || !this.voiceChannelIds.has(channelId))
|
|
26788
|
+
return { status: 404, code: "Not Found" };
|
|
26789
|
+
const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
|
|
26790
|
+
if (!row)
|
|
26791
|
+
return { status: 409, code: "voice_leaf_binding_missing" };
|
|
26792
|
+
const current = this.currentLeafKey(deviceId);
|
|
26793
|
+
if (current === null)
|
|
26794
|
+
return { status: 409, code: "voice_no_current_key_package" };
|
|
26795
|
+
if (current === row.key)
|
|
26796
|
+
return { status: 409, code: "voice_leaf_key_current" };
|
|
26797
|
+
if (row.unusableSignaledAt === null)
|
|
26798
|
+
row.unusableSignaledAt = this.now();
|
|
26799
|
+
return { status: 202 };
|
|
26800
|
+
}
|
|
26801
|
+
/** Task 24b test seam: V's committer work as the PR-E server derives it,
|
|
26802
|
+
* for a test's `roster` (the server's roster devices): `adds` = roster
|
|
26803
|
+
* devices not in the mirror; `remove_devices` / `remove_device_leaves` =
|
|
26804
|
+
* roster devices whose binding is signalled unusable (NOT in `adds`: they
|
|
26805
|
+
* are still in the mirror). The re-Add follows only from a later call,
|
|
26806
|
+
* after the Remove landed and the mirror dropped the device. */
|
|
26807
|
+
voiceCommitterWork(channelId, roster) {
|
|
26808
|
+
const mirror = this.membersByChannel.get(channelId) ?? [];
|
|
26809
|
+
const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
|
|
26810
|
+
const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
|
|
26811
|
+
return {
|
|
26812
|
+
adds: roster.filter((d) => !mirror.includes(d)),
|
|
26813
|
+
removes: [],
|
|
26814
|
+
removeDevices: signalled,
|
|
26815
|
+
removeDeviceLeaves: signalled.map((d) => ({
|
|
26816
|
+
deviceId: d,
|
|
26817
|
+
identityId: this.identityByDevice.get(d) ?? "",
|
|
26818
|
+
leafSignatureKeyHex: bindings.get(d).key
|
|
26819
|
+
}))
|
|
26820
|
+
};
|
|
26821
|
+
}
|
|
26341
26822
|
async acquireCommitLease(_channelId) {
|
|
26342
26823
|
return "fake-lease-token";
|
|
26343
26824
|
}
|
|
@@ -26830,6 +27311,7 @@ function openSchema(db) {
|
|
|
26830
27311
|
ensureToolGrantTables(db);
|
|
26831
27312
|
ensureToolGrantAppliedTable(db);
|
|
26832
27313
|
ensureRoomSearchCapTable(db);
|
|
27314
|
+
ensureVoiceChannelParentTable(db);
|
|
26833
27315
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
26834
27316
|
}
|
|
26835
27317
|
function ensureMlsCustody(db) {
|
|
@@ -27015,6 +27497,16 @@ function ensureToolGrantAppliedTable(db) {
|
|
|
27015
27497
|
);
|
|
27016
27498
|
`);
|
|
27017
27499
|
}
|
|
27500
|
+
function ensureVoiceChannelParentTable(db) {
|
|
27501
|
+
db.exec(`
|
|
27502
|
+
CREATE TABLE IF NOT EXISTS voice_channel_parent (
|
|
27503
|
+
identity_id TEXT NOT NULL,
|
|
27504
|
+
voice_channel_id TEXT NOT NULL,
|
|
27505
|
+
parent_channel_id TEXT NOT NULL,
|
|
27506
|
+
PRIMARY KEY (identity_id, voice_channel_id)
|
|
27507
|
+
);
|
|
27508
|
+
`);
|
|
27509
|
+
}
|
|
27018
27510
|
function ensureRoomSearchCapTable(db) {
|
|
27019
27511
|
db.exec(`
|
|
27020
27512
|
CREATE TABLE IF NOT EXISTS room_search_cap (
|
|
@@ -27706,6 +28198,21 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27706
28198
|
VALUES (?,?,?,?)
|
|
27707
28199
|
ON CONFLICT(identity_id, channel_id) DO UPDATE SET is_dm=excluded.is_dm, updated_at=excluded.updated_at`).run(this.id, channelId, isDm ? 1 : 0, Date.now());
|
|
27708
28200
|
}
|
|
28201
|
+
/** ADR-0024 Task 20 (final review I-1): records V -> P once; a later, different parent is ignored (immutable link). */
|
|
28202
|
+
recordVoiceChannelParent(voiceChannelId, parentChannelId) {
|
|
28203
|
+
this.db.prepare(`INSERT INTO voice_channel_parent (identity_id, voice_channel_id, parent_channel_id) VALUES (?,?,?)
|
|
28204
|
+
ON CONFLICT(identity_id, voice_channel_id) DO NOTHING`).run(this.id, voiceChannelId, parentChannelId);
|
|
28205
|
+
}
|
|
28206
|
+
/** The recorded parent of a voice channel, if any. */
|
|
28207
|
+
voiceChannelParent(voiceChannelId) {
|
|
28208
|
+
const row = this.db.prepare("SELECT parent_channel_id FROM voice_channel_parent WHERE identity_id=? AND voice_channel_id=?").get(this.id, voiceChannelId);
|
|
28209
|
+
return row?.parent_channel_id;
|
|
28210
|
+
}
|
|
28211
|
+
/** Every recorded voice channel of a parent conversation. */
|
|
28212
|
+
voiceChannelsOf(parentChannelId) {
|
|
28213
|
+
const rows = this.db.prepare("SELECT voice_channel_id FROM voice_channel_parent WHERE identity_id=? AND parent_channel_id=?").all(this.id, parentChannelId);
|
|
28214
|
+
return rows.map((r) => r.voice_channel_id);
|
|
28215
|
+
}
|
|
27709
28216
|
getChannelMeta(channelId) {
|
|
27710
28217
|
const row = this.db.prepare("SELECT enclave_id, is_dm FROM channel_meta WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
27711
28218
|
if (!row)
|
|
@@ -32410,7 +32917,7 @@ var EmbedderClient = class {
|
|
|
32410
32917
|
resolve4(ok);
|
|
32411
32918
|
}
|
|
32412
32919
|
}
|
|
32413
|
-
const
|
|
32920
|
+
const refuse3 = (reason) => {
|
|
32414
32921
|
s.destroy();
|
|
32415
32922
|
if (!settled) {
|
|
32416
32923
|
this.fail(reason);
|
|
@@ -32429,22 +32936,22 @@ var EmbedderClient = class {
|
|
|
32429
32936
|
return;
|
|
32430
32937
|
}
|
|
32431
32938
|
const f2 = this.chan.open(line);
|
|
32432
|
-
if (!f2) return
|
|
32939
|
+
if (!f2) return refuse3("embedder_unavailable");
|
|
32433
32940
|
this.onFrame(f2);
|
|
32434
32941
|
return;
|
|
32435
32942
|
}
|
|
32436
32943
|
const f = JSON.parse(line);
|
|
32437
|
-
if (!f || typeof f !== "object") return
|
|
32944
|
+
if (!f || typeof f !== "object") return refuse3("embedder_unavailable");
|
|
32438
32945
|
if (!ns) {
|
|
32439
|
-
if (f.t !== "challenge" || f.protocol !== EMBEDDER_PROTOCOL || typeof f.ns !== "string" || !/^[0-9a-f]{32}$/.test(f.ns)) return
|
|
32946
|
+
if (f.t !== "challenge" || f.protocol !== EMBEDDER_PROTOCOL || typeof f.ns !== "string" || !/^[0-9a-f]{32}$/.test(f.ns)) return refuse3(f.t === "challenge" ? "embedder_mismatch" : "embedder_unavailable");
|
|
32440
32947
|
ns = f.ns;
|
|
32441
32948
|
s.write(JSON.stringify({ t: "hello", nc, mac_c: macClient(token, ns, nc) }) + "\n");
|
|
32442
32949
|
return;
|
|
32443
32950
|
}
|
|
32444
|
-
if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return
|
|
32951
|
+
if (f.t !== "hello" || typeof f.mac_s !== "string" || !macEqual(f.mac_s, macServer(token, ns, nc))) return refuse3("embedder_unavailable");
|
|
32445
32952
|
const model = this.m.files.find((x) => x.name === "model.onnx");
|
|
32446
32953
|
const ok = f.protocol === EMBEDDER_PROTOCOL && f.model_id === this.m.modelId && f.model_sha256 === model.sha256 && f.wasm_sha256 === this.m.wasmSha256 && f.dims === this.m.dims;
|
|
32447
|
-
if (!ok) return
|
|
32954
|
+
if (!ok) return refuse3("embedder_mismatch");
|
|
32448
32955
|
if (this.closed) {
|
|
32449
32956
|
s.destroy();
|
|
32450
32957
|
return done(true);
|
|
@@ -32462,9 +32969,9 @@ var EmbedderClient = class {
|
|
|
32462
32969
|
this.settleReady(true);
|
|
32463
32970
|
done(true);
|
|
32464
32971
|
} catch {
|
|
32465
|
-
|
|
32972
|
+
refuse3("embedder_unavailable");
|
|
32466
32973
|
}
|
|
32467
|
-
}, () =>
|
|
32974
|
+
}, () => refuse3("embedder_unavailable")));
|
|
32468
32975
|
});
|
|
32469
32976
|
}
|
|
32470
32977
|
onFrame(f) {
|
|
@@ -35459,6 +35966,213 @@ function roomIsCrossOrg(rows) {
|
|
|
35459
35966
|
return unknown ? null : false;
|
|
35460
35967
|
}
|
|
35461
35968
|
|
|
35969
|
+
// src/voice-channel-binding.ts
|
|
35970
|
+
var BOUND = Object.freeze({ bound: true });
|
|
35971
|
+
var refuse = (reason) => ({ bound: false, reason });
|
|
35972
|
+
function evaluateVoiceBinding(v, p, isVRelay) {
|
|
35973
|
+
if (v.ownLeafIndex === null || v.leaves.length === 0) return refuse("v_not_held");
|
|
35974
|
+
if (p.ownLeafIndex === null || p.leaves.length === 0) return refuse("p_not_held");
|
|
35975
|
+
if (v.leaves.some((l) => !l.basic || l.identity === null)) return refuse("non_basic_leaf");
|
|
35976
|
+
const pIds = /* @__PURE__ */ new Set();
|
|
35977
|
+
for (const l of p.leaves) if (l.basic && l.identity !== null) pIds.add(l.identity);
|
|
35978
|
+
const nonParent = /* @__PURE__ */ new Set();
|
|
35979
|
+
for (const l of v.leaves) if (!pIds.has(l.identity)) nonParent.add(l.identity);
|
|
35980
|
+
if (nonParent.size > 1) return refuse("two_non_parent");
|
|
35981
|
+
if (isVRelay === void 0) return nonParent.size === 0 ? BOUND : refuse("relay_unknown");
|
|
35982
|
+
const relays = vRelayIdentities(v, isVRelay);
|
|
35983
|
+
if (relays.size > 1) return refuse("two_relays");
|
|
35984
|
+
const [relay] = relays;
|
|
35985
|
+
for (const id of nonParent) if (id !== relay) return refuse("foreign_leaf");
|
|
35986
|
+
return BOUND;
|
|
35987
|
+
}
|
|
35988
|
+
function vRelayIdentities(v, isVRelay) {
|
|
35989
|
+
const relays = /* @__PURE__ */ new Set();
|
|
35990
|
+
for (const l of v.leaves) if (l.basic && l.identity !== null && isVRelay(l.identity)) relays.add(l.identity);
|
|
35991
|
+
return relays;
|
|
35992
|
+
}
|
|
35993
|
+
function voiceRelayIdentity(v, isVRelay) {
|
|
35994
|
+
if (isVRelay === void 0) return void 0;
|
|
35995
|
+
const relays = vRelayIdentities(v, isVRelay);
|
|
35996
|
+
return relays.size === 1 ? [...relays][0] : void 0;
|
|
35997
|
+
}
|
|
35998
|
+
function parseVoiceResolution(channelId, status, body) {
|
|
35999
|
+
if (status === 404) return { kind: "absent" };
|
|
36000
|
+
if (status !== 200) return { kind: "error" };
|
|
36001
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return { kind: "error" };
|
|
36002
|
+
const b = body;
|
|
36003
|
+
if (b.channel_id !== channelId || typeof b.channel_kind !== "string") return { kind: "error" };
|
|
36004
|
+
if (b.channel_kind !== "voice") return { kind: "not_voice" };
|
|
36005
|
+
const parent3 = b.parent_channel_id;
|
|
36006
|
+
if (typeof parent3 !== "string" || parent3.length === 0 || parent3 === channelId) return { kind: "error" };
|
|
36007
|
+
return { kind: "voice", parentChannelId: parent3 };
|
|
36008
|
+
}
|
|
36009
|
+
var VOICE_RESOLVE_RETRY_BASE_MS = 3e4;
|
|
36010
|
+
var VOICE_RESOLVE_RETRY_MAX_MS = 10 * 6e4;
|
|
36011
|
+
var VOICE_RESOLVE_ABSENT_HELD_MAX_MS = 6e4;
|
|
36012
|
+
var VOICE_RECEIPT_CAP = 1e4;
|
|
36013
|
+
var VOICE_RECEIPT_MAX_AGE_MS = 15 * 6e4;
|
|
36014
|
+
function voiceParentStamp(p) {
|
|
36015
|
+
return JSON.stringify([p.groupId, p.epoch]);
|
|
36016
|
+
}
|
|
36017
|
+
var VoiceChannelRegistry = class {
|
|
36018
|
+
parents = /* @__PURE__ */ new Map();
|
|
36019
|
+
notVoice = /* @__PURE__ */ new Set();
|
|
36020
|
+
failures = /* @__PURE__ */ new Map();
|
|
36021
|
+
bindings = /* @__PURE__ */ new Map();
|
|
36022
|
+
generations = /* @__PURE__ */ new Map();
|
|
36023
|
+
/** Fix I-1 / Task 20 R1: message id -> P's held position before the batch that first received it; null = not knowable. */
|
|
36024
|
+
receipts = /* @__PURE__ */ new Map();
|
|
36025
|
+
/** Final review M-d: arrivals not recorded because the map was full of young records. */
|
|
36026
|
+
capReached = 0;
|
|
36027
|
+
/** True when a lookup should run now: never once resolved; after a failure, only past its back-off. */
|
|
36028
|
+
resolveDue(channelId, now, opts = {}) {
|
|
36029
|
+
if (this.parents.has(channelId) || this.notVoice.has(channelId)) return false;
|
|
36030
|
+
const f = this.failures.get(channelId);
|
|
36031
|
+
if (f === void 0) return true;
|
|
36032
|
+
let wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 1, 20));
|
|
36033
|
+
if (f.kind === "absent" && opts.heldGroup === true) wait = Math.min(wait, VOICE_RESOLVE_ABSENT_HELD_MAX_MS);
|
|
36034
|
+
return now - f.at >= wait;
|
|
36035
|
+
}
|
|
36036
|
+
record(channelId, r, now) {
|
|
36037
|
+
if (r.kind === "voice") {
|
|
36038
|
+
this.parents.set(channelId, r.parentChannelId);
|
|
36039
|
+
this.failures.delete(channelId);
|
|
36040
|
+
} else if (r.kind === "not_voice") {
|
|
36041
|
+
this.notVoice.add(channelId);
|
|
36042
|
+
this.failures.delete(channelId);
|
|
36043
|
+
} else {
|
|
36044
|
+
const prev = this.failures.get(channelId);
|
|
36045
|
+
this.failures.set(channelId, { at: now, attempts: (prev?.attempts ?? 0) + 1, kind: r.kind });
|
|
36046
|
+
}
|
|
36047
|
+
}
|
|
36048
|
+
/** True once get_channel has answered for this channel (voice or not). */
|
|
36049
|
+
isResolved(channelId) {
|
|
36050
|
+
return this.parents.has(channelId) || this.notVoice.has(channelId);
|
|
36051
|
+
}
|
|
36052
|
+
/** Final review M-a: true once get_channel answered this channel's own row with a kind other than voice. */
|
|
36053
|
+
isNotVoice(channelId) {
|
|
36054
|
+
return this.notVoice.has(channelId);
|
|
36055
|
+
}
|
|
36056
|
+
/** Final review (R∈P): bound Vs whose admitted R is also a P leaf (the pre-stamp shape; a sunset signal). */
|
|
36057
|
+
relayInParentCount() {
|
|
36058
|
+
let n = 0;
|
|
36059
|
+
for (const [v, e] of this.bindings) {
|
|
36060
|
+
if (this.parents.has(v) && e.verdict.bound && e.relay !== void 0 && e.parentMembers?.has(e.relay) === true) n++;
|
|
36061
|
+
}
|
|
36062
|
+
return n;
|
|
36063
|
+
}
|
|
36064
|
+
/** True for a resolved V, bound or not. */
|
|
36065
|
+
isVoice(channelId) {
|
|
36066
|
+
return this.parents.has(channelId);
|
|
36067
|
+
}
|
|
36068
|
+
/** P of a resolved V, bound or not; undefined otherwise. Never use this to route: see {@link boundParentOf}. */
|
|
36069
|
+
parentOf(channelId) {
|
|
36070
|
+
return this.parents.get(channelId);
|
|
36071
|
+
}
|
|
36072
|
+
/** Every resolved V with its P. */
|
|
36073
|
+
voiceChannels() {
|
|
36074
|
+
return [...this.parents.entries()];
|
|
36075
|
+
}
|
|
36076
|
+
/** The gate for a channel the bridge's listing has never classified (`unlisted`), or for a resolved V. */
|
|
36077
|
+
gate(channelId, unlisted) {
|
|
36078
|
+
if (this.parents.has(channelId)) return "drop";
|
|
36079
|
+
if (!unlisted || this.notVoice.has(channelId)) return "normal";
|
|
36080
|
+
return this.failures.get(channelId)?.kind === "absent" ? "drop" : "hold";
|
|
36081
|
+
}
|
|
36082
|
+
bindingKey(channelId) {
|
|
36083
|
+
return this.bindings.get(channelId)?.key;
|
|
36084
|
+
}
|
|
36085
|
+
setBinding(channelId, key, verdict, positions = null, admitted = {}) {
|
|
36086
|
+
this.bindings.set(channelId, { key, positions, verdict, ...admitted });
|
|
36087
|
+
}
|
|
36088
|
+
/** Fix M-1: each evaluation of a V takes a generation; only the newest started may store its verdict. */
|
|
36089
|
+
beginEvaluation(channelId) {
|
|
36090
|
+
const g = (this.generations.get(channelId) ?? 0) + 1;
|
|
36091
|
+
this.generations.set(channelId, g);
|
|
36092
|
+
return g;
|
|
36093
|
+
}
|
|
36094
|
+
isLatestEvaluation(channelId, generation) {
|
|
36095
|
+
return this.generations.get(channelId) === generation;
|
|
36096
|
+
}
|
|
36097
|
+
/**
|
|
36098
|
+
* Fix I-1: records a frame's receipt stamp the FIRST time it is seen; a re-served frame keeps its first record.
|
|
36099
|
+
* Task 20 R2: at the cap nothing is evicted and the new arrival is not recorded (refused: no record, no parent).
|
|
36100
|
+
*/
|
|
36101
|
+
noteReceipt(messageId, pStamp, now = Date.now()) {
|
|
36102
|
+
if (this.receipts.has(messageId)) return;
|
|
36103
|
+
if (this.receipts.size >= VOICE_RECEIPT_CAP) {
|
|
36104
|
+
for (const [id, r] of this.receipts) if (now - r.at > VOICE_RECEIPT_MAX_AGE_MS) this.receipts.delete(id);
|
|
36105
|
+
if (this.receipts.size >= VOICE_RECEIPT_CAP) {
|
|
36106
|
+
this.capReached++;
|
|
36107
|
+
return;
|
|
36108
|
+
}
|
|
36109
|
+
}
|
|
36110
|
+
this.receipts.set(messageId, { stamp: pStamp, at: now });
|
|
36111
|
+
}
|
|
36112
|
+
/** Final review M-d: arrivals refused because the receipt map was full (a number only). */
|
|
36113
|
+
receiptCapReached() {
|
|
36114
|
+
return this.capReached;
|
|
36115
|
+
}
|
|
36116
|
+
/** Test visibility: receipt records held. */
|
|
36117
|
+
receiptCount() {
|
|
36118
|
+
return this.receipts.size;
|
|
36119
|
+
}
|
|
36120
|
+
forgetReceipt(messageId) {
|
|
36121
|
+
this.receipts.delete(messageId);
|
|
36122
|
+
}
|
|
36123
|
+
/**
|
|
36124
|
+
* Fix I-1: P for one frame on V only when V is bound now AND both groups' held positions are exactly those at the
|
|
36125
|
+
* frame's receipt. A frame received at other positions (or with none recorded) gets undefined: dropped, never held.
|
|
36126
|
+
*/
|
|
36127
|
+
boundParentForFrame(channelId, messageId, openedAt) {
|
|
36128
|
+
return this.frameContext(channelId, messageId, openedAt)?.parent;
|
|
36129
|
+
}
|
|
36130
|
+
/**
|
|
36131
|
+
* Task 20: the per-frame verdict with what the accept filter needs. Bound now AND the frame's pre-batch P stamp equals
|
|
36132
|
+
* the verdict's P position AND (R1) the frame's own opening position equals the verdict's V position. A frame with
|
|
36133
|
+
* no record, a null record, or no `openedAt` is refused.
|
|
36134
|
+
*/
|
|
36135
|
+
frameContext(channelId, messageId, openedAt) {
|
|
36136
|
+
const parent3 = this.boundParentOf(channelId);
|
|
36137
|
+
if (parent3 === void 0) return void 0;
|
|
36138
|
+
const entry = this.bindings.get(channelId);
|
|
36139
|
+
const at = this.receipts.get(messageId)?.stamp;
|
|
36140
|
+
if (entry?.positions == null || typeof at !== "string" || at !== entry.positions.p) return void 0;
|
|
36141
|
+
const v = entry.positions.v;
|
|
36142
|
+
if (v === null || openedAt == null || openedAt.groupId !== v.groupId || openedAt.epoch !== v.epoch) return void 0;
|
|
36143
|
+
return { parent: parent3, relay: entry.relay, parentMembers: entry.parentMembers ?? /* @__PURE__ */ new Set() };
|
|
36144
|
+
}
|
|
36145
|
+
/** The current verdict, or undefined when never evaluated. */
|
|
36146
|
+
binding(channelId) {
|
|
36147
|
+
return this.bindings.get(channelId)?.verdict;
|
|
36148
|
+
}
|
|
36149
|
+
/** P only while V is a resolved V whose last verdict is bound; undefined otherwise. */
|
|
36150
|
+
boundParentOf(channelId) {
|
|
36151
|
+
const parent3 = this.parents.get(channelId);
|
|
36152
|
+
return parent3 !== void 0 && this.bindings.get(channelId)?.verdict.bound === true ? parent3 : void 0;
|
|
36153
|
+
}
|
|
36154
|
+
};
|
|
36155
|
+
|
|
36156
|
+
// src/voice-channel-frame.ts
|
|
36157
|
+
function classifyVoiceFrame(f) {
|
|
36158
|
+
if (f.senderId === f.ownIdentityId) return "drop";
|
|
36159
|
+
const fromRelay = f.relay !== void 0 && f.senderId === f.relay;
|
|
36160
|
+
const fromMember = f.parentMembers.has(f.senderId);
|
|
36161
|
+
if (!fromRelay && !fromMember) return "drop";
|
|
36162
|
+
let body;
|
|
36163
|
+
try {
|
|
36164
|
+
body = JSON.parse(new TextDecoder().decode(f.plaintext));
|
|
36165
|
+
} catch {
|
|
36166
|
+
return "drop";
|
|
36167
|
+
}
|
|
36168
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return "drop";
|
|
36169
|
+
const b = body;
|
|
36170
|
+
if (b.type === "call_transcript") return "call_transcript";
|
|
36171
|
+
if (b.type !== void 0 && b.type !== "text" || typeof b.text !== "string") return "drop";
|
|
36172
|
+
if (fromRelay) return "relay_turn";
|
|
36173
|
+
return b.text.trim() === VOICE_CALL_START_MARKER ? "greeting" : "drop";
|
|
36174
|
+
}
|
|
36175
|
+
|
|
35462
36176
|
// src/grants/owner-turn.ts
|
|
35463
36177
|
import { ed25519 as ed255196 } from "@noble/curves/ed25519";
|
|
35464
36178
|
var HEX = /^(?:[0-9a-fA-F]{2})+$/;
|
|
@@ -35554,6 +36268,7 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
|
35554
36268
|
var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
|
|
35555
36269
|
Object.freeze({ tool: "current_time", mode: "read" })
|
|
35556
36270
|
]);
|
|
36271
|
+
var BRIDGE_CAPABILITIES = Object.freeze(["voice_channel_v1"]);
|
|
35557
36272
|
var RELAYED_VOICE_GRANTS = Object.freeze([
|
|
35558
36273
|
Object.freeze({ tool: "current_time", mode: "read" }),
|
|
35559
36274
|
Object.freeze({ tool: "web_search", mode: "read" })
|
|
@@ -35828,12 +36543,19 @@ var RoomClassChangedSendError = class extends SendForbiddenError {
|
|
|
35828
36543
|
this.message = `reply dropped for channel ${channelId}: the room changed after its tools were granted; no retry`;
|
|
35829
36544
|
}
|
|
35830
36545
|
};
|
|
36546
|
+
function voiceVerdictPositions(v, p) {
|
|
36547
|
+
return {
|
|
36548
|
+
v: v.groupId !== null && v.epoch !== null ? { groupId: v.groupId, epoch: v.epoch } : null,
|
|
36549
|
+
p: voiceParentStamp(p)
|
|
36550
|
+
};
|
|
36551
|
+
}
|
|
35831
36552
|
function rosterFreshAt(entry, heldEpoch, heldGroupId) {
|
|
35832
36553
|
return entry !== void 0 && typeof heldEpoch === "number" && entry.epoch === heldEpoch && typeof heldGroupId === "string" && entry.groupId === heldGroupId;
|
|
35833
36554
|
}
|
|
35834
36555
|
var RoomGrantEpochMovedError = class extends Error {
|
|
35835
36556
|
};
|
|
35836
36557
|
var ROOM_GRANT_TRACK_MAX = 4096;
|
|
36558
|
+
var VOICE_FRAME_PARENT_MAX = 4096;
|
|
35837
36559
|
var SessionSupersededError = class extends Error {
|
|
35838
36560
|
constructor() {
|
|
35839
36561
|
super("session_superseded: another copy of this agent took its session during start()");
|
|
@@ -36265,6 +36987,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36265
36987
|
supersedeRefundable = /* @__PURE__ */ new Set();
|
|
36266
36988
|
/** M-1: channels this process has used as a FileSafe vault channel. */
|
|
36267
36989
|
vaultChannelIds = /* @__PURE__ */ new Set();
|
|
36990
|
+
/**
|
|
36991
|
+
* ADR-0024 step 2a Task 19 (spec §9): hidden voice channels V resolved to their conversation P, lookup back-off, and
|
|
36992
|
+
* each V's binding verdict. In memory only: a restart re-resolves (one get_channel per V) and re-evaluates.
|
|
36993
|
+
*/
|
|
36994
|
+
voiceChannels = new VoiceChannelRegistry();
|
|
36995
|
+
/** Fix M-3: channels a listing row marked channel_kind "voice"; never recorded as listed. */
|
|
36996
|
+
listedAsVoice = /* @__PURE__ */ new Set();
|
|
36268
36997
|
/** Phase 3 F3: consecutive agent turns per channel. */
|
|
36269
36998
|
agentHops = new AgentHopTracker();
|
|
36270
36999
|
maxAgentHops;
|
|
@@ -36295,6 +37024,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36295
37024
|
* counter is incremented. Surfaced on health()/metrics().
|
|
36296
37025
|
*/
|
|
36297
37026
|
failedAttachments = 0;
|
|
37027
|
+
/** Task 20 R4: V frames dropped by the per-frame verdict (not bound, or not at the verdict's positions). A number only. */
|
|
37028
|
+
voiceFramesRefused = 0;
|
|
37029
|
+
/** Task 20: frames on a bound V the accept filter dropped (not R's voice frame, a greeting marker or a transcript). */
|
|
37030
|
+
voiceFramesFiltered = 0;
|
|
37031
|
+
/** Task 20: message id of an admitted V frame -> its P (B.8 keys). Bounded; an entry lives one ingest cycle in practice. */
|
|
37032
|
+
voiceFrameParent = /* @__PURE__ */ new Map();
|
|
36298
37033
|
/** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
|
|
36299
37034
|
refusedVaultRefs = 0;
|
|
36300
37035
|
/**
|
|
@@ -36708,7 +37443,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
36708
37443
|
// Fix round MEDIUM 2: a channel this process used as a FileSafe vault
|
|
36709
37444
|
// channel is never swept (the sweeper also refuses any channel no listing
|
|
36710
37445
|
// has shown, which covers a vault channel after a restart).
|
|
36711
|
-
|
|
37446
|
+
// Task 20 final review I-1: a voice channel V is never listed, so it is swept as its parent P: V is present exactly
|
|
37447
|
+
// when P is, and purgeConversation(P) purges every V of P. P is swept even when only its V holds memory.
|
|
37448
|
+
conversations: () => [...new Set(this.store.memoryConversationIds().filter((c) => !this.vaultChannelIds.has(c)).map((c) => this.store.voiceChannelParent(c) ?? c))],
|
|
36712
37449
|
enclaveOf: (c) => this.store.getChannelMeta(c)?.enclaveId,
|
|
36713
37450
|
// Audit r1 M6: the persisted flag, never the channelOwner cache. Set only
|
|
36714
37451
|
// from a listing, so undefined means no listing has ever shown the channel.
|
|
@@ -37128,6 +37865,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37128
37865
|
// the actual protected-memory adapter. It is an enum, never a path or
|
|
37129
37866
|
// provider credential.
|
|
37130
37867
|
memory_provider: this.cfg.memoryProvider ?? "securecoms",
|
|
37868
|
+
// Spec §2 (Task 18): the per-device capability declaration; a fixed enum list, one body for every report path.
|
|
37869
|
+
capabilities: [...BRIDGE_CAPABILITIES],
|
|
37131
37870
|
// Spec §11.1-11.2 (OQ-E): flat keys; the string `memory` above is
|
|
37132
37871
|
// unchanged. memory_notes is true only when notes are actually on (the
|
|
37133
37872
|
// provider is securecoms and the parsed payload.memory keeps them on);
|
|
@@ -38375,6 +39114,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38375
39114
|
* across a supersede), and the group must not move around the reads.
|
|
38376
39115
|
*/
|
|
38377
39116
|
async reclassifyForReply(channelId, grant) {
|
|
39117
|
+
if (this.voiceChannels.isVoice(channelId)) return false;
|
|
38378
39118
|
const startedAt = Date.now();
|
|
38379
39119
|
await this.refreshChannelRoster(channelId);
|
|
38380
39120
|
const held = await this.heldPosition(channelId);
|
|
@@ -38391,8 +39131,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38391
39131
|
this.enforceRateFloor("a2a", channelId);
|
|
38392
39132
|
return;
|
|
38393
39133
|
}
|
|
38394
|
-
const
|
|
38395
|
-
|
|
39134
|
+
const capChannel = this.capChannelFor(channelId);
|
|
39135
|
+
const meta = this.freshMeta(capChannel);
|
|
39136
|
+
if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
|
|
38396
39137
|
}
|
|
38397
39138
|
/** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
|
|
38398
39139
|
afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
|
|
@@ -38874,8 +39615,289 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38874
39615
|
return probeResultFor("threw");
|
|
38875
39616
|
}
|
|
38876
39617
|
}
|
|
39618
|
+
// ── ADR-0024 step 2a Task 19: the hidden voice channel V (spec §9, B.8) ──────
|
|
39619
|
+
//
|
|
39620
|
+
// The rule and the cache live in voice-channel-binding.ts; this is the wiring: the get_channel lookup, the bridge's
|
|
39621
|
+
// own held ratchet trees and positions, V's member list (is_relay only), and the receive-cycle gate.
|
|
39622
|
+
/**
|
|
39623
|
+
* Task 20's accessor: P's channel id ONLY while V is bound under the spec §9 rule at both groups' held positions as
|
|
39624
|
+
* of the last evaluation (every receive cycle, before any frame is processed; or {@link voiceParentNow}); undefined
|
|
39625
|
+
* for an unbound or refused V, and for any channel that is not a resolved V.
|
|
39626
|
+
*/
|
|
39627
|
+
voiceParentOf(channelId) {
|
|
39628
|
+
return this.voiceChannels.boundParentOf(channelId);
|
|
39629
|
+
}
|
|
39630
|
+
/** True for a channel resolved as a V, bound or not. Such a channel is never processed as a normal channel. */
|
|
39631
|
+
isKnownVoiceChannel(channelId) {
|
|
39632
|
+
return this.voiceChannels.isVoice(channelId);
|
|
39633
|
+
}
|
|
39634
|
+
/** Re-evaluates one V at the held positions now, then answers as {@link voiceParentOf}. */
|
|
39635
|
+
async voiceParentNow(channelId) {
|
|
39636
|
+
const parent3 = this.voiceChannels.parentOf(channelId);
|
|
39637
|
+
if (parent3 === void 0) return void 0;
|
|
39638
|
+
await this.reevaluateVoiceBinding(channelId, parent3);
|
|
39639
|
+
return this.voiceChannels.boundParentOf(channelId);
|
|
39640
|
+
}
|
|
39641
|
+
/** A channel no listing has ever classified (in memory or persisted). V is hidden from the listing, so it is one. */
|
|
39642
|
+
isUnlistedChannel(channelId) {
|
|
39643
|
+
return this.channelMeta.get(channelId) === void 0 && this.store.getChannelMeta(channelId)?.isDm === void 0;
|
|
39644
|
+
}
|
|
39645
|
+
voiceChannelGate(channelId) {
|
|
39646
|
+
if (this.vaultChannelIds.has(channelId)) return "normal";
|
|
39647
|
+
return this.voiceChannels.gate(channelId, this.isUnlistedChannel(channelId));
|
|
39648
|
+
}
|
|
39649
|
+
/**
|
|
39650
|
+
* Looks up one channel with get_channel (the voice arm, OQ-5 (a)). Only the classification is kept. Never throws; a
|
|
39651
|
+
* failure is recorded and retried (30 s doubling to 10 min). Called only from {@link resolveUnlistedChannels}, which
|
|
39652
|
+
* selects unlisted channels whose back-off allows.
|
|
39653
|
+
*/
|
|
39654
|
+
async resolveVoiceChannel(channelId) {
|
|
39655
|
+
let status = "threw";
|
|
39656
|
+
let body;
|
|
39657
|
+
try {
|
|
39658
|
+
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(channelId)}?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
39659
|
+
const res = await this.fetchBounded("get_channel", "read", url, { headers: { authorization: `Bearer ${this.cfg.apiKey}` } });
|
|
39660
|
+
if (res.ok) {
|
|
39661
|
+
body = await res.json();
|
|
39662
|
+
} else {
|
|
39663
|
+
try {
|
|
39664
|
+
await res.body?.cancel();
|
|
39665
|
+
} catch {
|
|
39666
|
+
}
|
|
39667
|
+
}
|
|
39668
|
+
status = res.status;
|
|
39669
|
+
} catch {
|
|
39670
|
+
status = "threw";
|
|
39671
|
+
}
|
|
39672
|
+
const r = parseVoiceResolution(channelId, status, body);
|
|
39673
|
+
this.voiceChannels.record(channelId, r, Date.now());
|
|
39674
|
+
if (r.kind === "voice") {
|
|
39675
|
+
try {
|
|
39676
|
+
this.store.recordVoiceChannelParent(channelId, r.parentChannelId);
|
|
39677
|
+
} catch {
|
|
39678
|
+
}
|
|
39679
|
+
}
|
|
39680
|
+
if (r.kind === "voice") writeServiceLog("info", "voice.channel_lookup", { result: ServiceLogEnum.of("voice", ["voice", "absent", "error"]) });
|
|
39681
|
+
else if (r.kind !== "not_voice") writeServiceLog("warn", "voice.channel_lookup", { result: ServiceLogEnum.of(r.kind, ["voice", "absent", "error"]) });
|
|
39682
|
+
}
|
|
39683
|
+
/**
|
|
39684
|
+
* Looks up every unlisted channel among `channelIds` whose back-off allows. A channel is "unlisted" only after this
|
|
39685
|
+
* cycle's listing: if any candidate remains and the cycle has not listed yet, one listing runs first, so a channel
|
|
39686
|
+
* that is merely new (listed, not yet cached) is never looked up. Returns true when it ran that listing.
|
|
39687
|
+
*/
|
|
39688
|
+
async resolveUnlistedChannels(channelIds, listed) {
|
|
39689
|
+
const ids = [...new Set(channelIds)].filter((c) => !this.vaultChannelIds.has(c));
|
|
39690
|
+
const held = /* @__PURE__ */ new Set();
|
|
39691
|
+
for (const c of ids) {
|
|
39692
|
+
if (!this.isUnlistedChannel(c) || this.voiceChannels.isResolved(c)) continue;
|
|
39693
|
+
try {
|
|
39694
|
+
if ((await this.heldPosition(c)).groupId !== null) held.add(c);
|
|
39695
|
+
} catch {
|
|
39696
|
+
}
|
|
39697
|
+
}
|
|
39698
|
+
const due = () => ids.filter((c) => this.isUnlistedChannel(c) && this.voiceChannels.resolveDue(c, Date.now(), { heldGroup: held.has(c) }));
|
|
39699
|
+
let candidates = due();
|
|
39700
|
+
if (candidates.length === 0) return false;
|
|
39701
|
+
let ran = false;
|
|
39702
|
+
if (!listed) {
|
|
39703
|
+
await this.refreshChannelMeta();
|
|
39704
|
+
ran = true;
|
|
39705
|
+
candidates = due();
|
|
39706
|
+
}
|
|
39707
|
+
for (const c of candidates) await this.resolveVoiceChannel(c);
|
|
39708
|
+
return ran;
|
|
39709
|
+
}
|
|
39710
|
+
/** Final review M-c: V -> consecutive failed member-list reads for the binding, and when the last one ran. */
|
|
39711
|
+
voiceRosterRetry = /* @__PURE__ */ new Map();
|
|
39712
|
+
/** The first retry after a failure is immediate (next cycle); then 30 s, doubling, capped at 10 min. */
|
|
39713
|
+
voiceRosterRetryDue(v) {
|
|
39714
|
+
const f = this.voiceRosterRetry.get(v);
|
|
39715
|
+
if (f === void 0 || f.attempts <= 1) return true;
|
|
39716
|
+
const wait = Math.min(VOICE_RESOLVE_RETRY_MAX_MS, VOICE_RESOLVE_RETRY_BASE_MS * 2 ** Math.min(f.attempts - 2, 20));
|
|
39717
|
+
return Date.now() - f.at >= wait;
|
|
39718
|
+
}
|
|
39719
|
+
noteVoiceRosterRead(v, ok) {
|
|
39720
|
+
if (ok) {
|
|
39721
|
+
this.voiceRosterRetry.delete(v);
|
|
39722
|
+
return;
|
|
39723
|
+
}
|
|
39724
|
+
this.voiceRosterRetry.set(v, { at: Date.now(), attempts: (this.voiceRosterRetry.get(v)?.attempts ?? 0) + 1 });
|
|
39725
|
+
}
|
|
39726
|
+
/** Every resolved V, re-evaluated where a position or a roster moved. */
|
|
39727
|
+
async reevaluateVoiceBindings() {
|
|
39728
|
+
for (const [v, p] of this.voiceChannels.voiceChannels()) await this.reevaluateVoiceBinding(v, p);
|
|
39729
|
+
}
|
|
39730
|
+
/**
|
|
39731
|
+
* The spec §9 binding rule over the bridge's OWN ratchet trees (never a server list of leaves), keyed by both
|
|
39732
|
+
* groups' held (group, epoch) and both rosters' fetch times: a change in any of them re-evaluates. V's member list
|
|
39733
|
+
* is read only for `is_relay`, and only when it is fresh at V's held position. Fail closed: a tree that moved while
|
|
39734
|
+
* it was read, or any exception, leaves V unbound and is re-evaluated next time.
|
|
39735
|
+
*/
|
|
39736
|
+
async reevaluateVoiceBinding(v, p) {
|
|
39737
|
+
const generation = this.voiceChannels.beginEvaluation(v);
|
|
39738
|
+
try {
|
|
39739
|
+
const vPos = await this.heldPosition(v);
|
|
39740
|
+
const pPos = await this.heldPosition(p);
|
|
39741
|
+
const positions = voiceVerdictPositions(vPos, pPos);
|
|
39742
|
+
const parentNotConversation = this.voiceChannels.isVoice(p) || this.isUnlistedChannel(p);
|
|
39743
|
+
const keyOf = () => JSON.stringify([positions, this.freshRosterEntry(v)?.at ?? null, this.freshRosterEntry(p)?.at ?? null, parentNotConversation]);
|
|
39744
|
+
if (this.voiceChannels.bindingKey(v) === keyOf()) return;
|
|
39745
|
+
if (parentNotConversation) {
|
|
39746
|
+
if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, keyOf(), { bound: false, reason: "parent_not_conversation" }, positions);
|
|
39747
|
+
return;
|
|
39748
|
+
}
|
|
39749
|
+
const vTree = await this.provider.leafTreeSummary(v);
|
|
39750
|
+
const pTree = await this.provider.leafTreeSummary(p);
|
|
39751
|
+
const relayCheck = () => {
|
|
39752
|
+
const entry = this.freshRosterEntry(v);
|
|
39753
|
+
return entry !== void 0 && rosterFreshAt(entry, vPos.epoch, vPos.groupId) ? (id) => entry.members.get(id)?.is_relay === true : void 0;
|
|
39754
|
+
};
|
|
39755
|
+
if (relayCheck() === void 0 && this.voiceRosterRetryDue(v)) {
|
|
39756
|
+
await this.refreshChannelRoster(v);
|
|
39757
|
+
this.noteVoiceRosterRead(v, relayCheck() !== void 0);
|
|
39758
|
+
}
|
|
39759
|
+
const isVRelay = relayCheck();
|
|
39760
|
+
let verdict = evaluateVoiceBinding(vTree, pTree, isVRelay);
|
|
39761
|
+
const vAfter = await this.heldPosition(v);
|
|
39762
|
+
const pAfter = await this.heldPosition(p);
|
|
39763
|
+
const moved = vAfter.groupId !== vPos.groupId || vAfter.epoch !== vPos.epoch || pAfter.groupId !== pPos.groupId || pAfter.epoch !== pPos.epoch;
|
|
39764
|
+
if (moved) verdict = { bound: false, reason: "moved" };
|
|
39765
|
+
const retry = !verdict.bound && (verdict.reason === "relay_unknown" || verdict.reason === "moved") || verdict.bound && isVRelay === void 0;
|
|
39766
|
+
if (!this.voiceChannels.isLatestEvaluation(v, generation)) return;
|
|
39767
|
+
const parentMembers = /* @__PURE__ */ new Set();
|
|
39768
|
+
for (const l of pTree.leaves) if (l.basic && l.identity !== null) parentMembers.add(l.identity);
|
|
39769
|
+
this.noteVoiceBinding(v, retry ? "" : keyOf(), verdict, positions, void 0, {
|
|
39770
|
+
relay: verdict.bound ? voiceRelayIdentity(vTree, isVRelay) : void 0,
|
|
39771
|
+
parentMembers
|
|
39772
|
+
});
|
|
39773
|
+
} catch (e) {
|
|
39774
|
+
if (this.voiceChannels.isLatestEvaluation(v, generation)) this.noteVoiceBinding(v, "", { bound: false, reason: "moved" }, null, e);
|
|
39775
|
+
}
|
|
39776
|
+
}
|
|
39777
|
+
/**
|
|
39778
|
+
* Fix I-1 / Task 20 R1: P's held position for every resolved V, read before a receive so frames can be stamped. V's
|
|
39779
|
+
* half is not stamped here: each frame carries the epoch that opened it (`openedAt`).
|
|
39780
|
+
*/
|
|
39781
|
+
async voicePositionSnapshot() {
|
|
39782
|
+
const out = /* @__PURE__ */ new Map();
|
|
39783
|
+
for (const [v, p] of this.voiceChannels.voiceChannels()) {
|
|
39784
|
+
try {
|
|
39785
|
+
out.set(v, voiceParentStamp(await this.heldPosition(p)));
|
|
39786
|
+
} catch {
|
|
39787
|
+
}
|
|
39788
|
+
}
|
|
39789
|
+
return out;
|
|
39790
|
+
}
|
|
39791
|
+
/**
|
|
39792
|
+
* Fix I-1, for Task 20: P for ONE frame on V, only when V is bound now AND neither P's nor V's held (group, epoch)
|
|
39793
|
+
* moved between that frame's first receipt and the verdict. Otherwise undefined: the frame is dropped (acked, never
|
|
39794
|
+
* held, never processed). A frame first received before V was resolved has no receipt positions and is refused.
|
|
39795
|
+
*/
|
|
39796
|
+
voiceParentForFrame(channelId, messageId, openedAt) {
|
|
39797
|
+
return this.voiceChannels.boundParentForFrame(channelId, messageId, openedAt);
|
|
39798
|
+
}
|
|
39799
|
+
/** Stores a verdict; a change of verdict logs one content-free line. */
|
|
39800
|
+
noteVoiceBinding(v, key, verdict, positions, error, admitted = {}) {
|
|
39801
|
+
const prev = this.voiceChannels.binding(v);
|
|
39802
|
+
this.voiceChannels.setBinding(v, key, verdict, positions, admitted);
|
|
39803
|
+
const state = verdict.bound ? "bound" : verdict.reason;
|
|
39804
|
+
const prevState = prev === void 0 ? void 0 : prev.bound ? "bound" : prev.reason;
|
|
39805
|
+
if (state === prevState && error === void 0) return;
|
|
39806
|
+
writeServiceLog(verdict.bound ? "info" : "warn", "voice.channel_binding", {
|
|
39807
|
+
state: ServiceLogEnum.of(state, ["bound", "v_not_held", "p_not_held", "non_basic_leaf", "foreign_leaf", "two_non_parent", "two_relays", "relay_unknown", "moved", "parent_not_conversation"])
|
|
39808
|
+
}, error);
|
|
39809
|
+
}
|
|
39810
|
+
/**
|
|
39811
|
+
* The receive-cycle gate, before any per-frame work. A resolved V is never a normal channel: a frame passes only when
|
|
39812
|
+
* the per-frame verdict names P (bound now, its P stamp and its own opening epoch at the verdict's positions) AND the
|
|
39813
|
+
* accept filter takes it (Task 20: R's voice frame or a P member's greeting marker; a call_transcript is acked, not a
|
|
39814
|
+
* turn). Every other V frame is acked unprocessed and never recorded. A channel get_channel answered 404 is acked
|
|
39815
|
+
* unprocessed (fail closed). An unlisted channel whose lookup errored or has not run is held: neither processed nor
|
|
39816
|
+
* acked, so it is re-served once the lookup answers. Everything else passes unchanged.
|
|
39817
|
+
*/
|
|
39818
|
+
async gateVoiceEntries(entries) {
|
|
39819
|
+
const keep = [];
|
|
39820
|
+
const drop = [];
|
|
39821
|
+
for (const m of entries) {
|
|
39822
|
+
const gate = this.voiceChannelGate(m.channelId);
|
|
39823
|
+
if (gate === "hold") continue;
|
|
39824
|
+
if (gate === "normal") {
|
|
39825
|
+
this.voiceChannels.forgetReceipt(m.messageId);
|
|
39826
|
+
keep.push(m);
|
|
39827
|
+
continue;
|
|
39828
|
+
}
|
|
39829
|
+
if (this.voiceChannels.isVoice(m.channelId)) {
|
|
39830
|
+
const admitted = this.admitVoiceFrame(m);
|
|
39831
|
+
this.voiceChannels.forgetReceipt(m.messageId);
|
|
39832
|
+
if (admitted) {
|
|
39833
|
+
keep.push(m);
|
|
39834
|
+
continue;
|
|
39835
|
+
}
|
|
39836
|
+
} else {
|
|
39837
|
+
this.voiceChannels.forgetReceipt(m.messageId);
|
|
39838
|
+
}
|
|
39839
|
+
drop.push(m.queueId);
|
|
39840
|
+
}
|
|
39841
|
+
if (drop.length) {
|
|
39842
|
+
try {
|
|
39843
|
+
await this.provider.ackDelivery(this.cfg.deviceId, drop);
|
|
39844
|
+
} catch {
|
|
39845
|
+
}
|
|
39846
|
+
}
|
|
39847
|
+
return keep;
|
|
39848
|
+
}
|
|
39849
|
+
/**
|
|
39850
|
+
* Task 20: the per-frame verdict and the accept filter for one frame on a resolved V. True only for a model turn
|
|
39851
|
+
* (R's voice frame, a P member's greeting marker), which is then keyed per B.8 through {@link voiceFrameParent}. A
|
|
39852
|
+
* call_transcript is accepted but is not a turn (acked, nothing more, as step 1 on P). Counters are numbers only.
|
|
39853
|
+
*/
|
|
39854
|
+
admitVoiceFrame(m) {
|
|
39855
|
+
const parent3 = this.voiceParentForFrame(m.channelId, m.messageId, m.openedAt ?? null);
|
|
39856
|
+
const ctx = parent3 === void 0 ? void 0 : this.voiceChannels.frameContext(m.channelId, m.messageId, m.openedAt ?? null);
|
|
39857
|
+
if (ctx === void 0) {
|
|
39858
|
+
this.voiceFramesRefused++;
|
|
39859
|
+
return false;
|
|
39860
|
+
}
|
|
39861
|
+
const kind2 = classifyVoiceFrame({
|
|
39862
|
+
senderId: m.envelope.sender_id,
|
|
39863
|
+
ownIdentityId: this.cfg.identityId,
|
|
39864
|
+
relay: ctx.relay,
|
|
39865
|
+
parentMembers: ctx.parentMembers,
|
|
39866
|
+
plaintext: m.plaintext
|
|
39867
|
+
});
|
|
39868
|
+
if (kind2 === "call_transcript") return false;
|
|
39869
|
+
if (kind2 === "drop") {
|
|
39870
|
+
this.voiceFramesFiltered++;
|
|
39871
|
+
return false;
|
|
39872
|
+
}
|
|
39873
|
+
this.voiceFrameParent.set(m.messageId, ctx.parent);
|
|
39874
|
+
if (this.voiceFrameParent.size > VOICE_FRAME_PARENT_MAX) this.voiceFrameParent.delete(this.voiceFrameParent.keys().next().value);
|
|
39875
|
+
return true;
|
|
39876
|
+
}
|
|
39877
|
+
/**
|
|
39878
|
+
* Task 20 (B.8): the key channel for metadata, audience, owner, manifest and caps. For an admitted V frame, its P;
|
|
39879
|
+
* for any other frame, its own channel.
|
|
39880
|
+
*/
|
|
39881
|
+
keyChannelForFrame(m) {
|
|
39882
|
+
return this.voiceFrameParent.get(m.messageId) ?? m.channelId;
|
|
39883
|
+
}
|
|
39884
|
+
/**
|
|
39885
|
+
* Task 20 (B.8 "per-room daily caps → P", the room rate floor): the channel a cap or floor counts against. A known V
|
|
39886
|
+
* counts against its bound P; an unbound V against itself (no grant can be issued there, so nothing is widened).
|
|
39887
|
+
*/
|
|
39888
|
+
capChannelFor(channelId) {
|
|
39889
|
+
return this.voiceChannels.parentOf(channelId) ?? channelId;
|
|
39890
|
+
}
|
|
39891
|
+
/** Phase 3 F1 pause, for a voice channel V also read on its P (a paused agent runs no inference on the call either). */
|
|
39892
|
+
pausedFor(channelId) {
|
|
39893
|
+
return this.pausedChannels.has(channelId) || this.pausedChannels.has(this.capChannelFor(channelId));
|
|
39894
|
+
}
|
|
38877
39895
|
/** §5.4: one conversation's turns, facts, notes and recall, inside the memory mutex, with every in-memory copy. */
|
|
38878
39896
|
async purgeConversation(channelId) {
|
|
39897
|
+
await this.purgeOneConversation(channelId);
|
|
39898
|
+
for (const v of this.store.voiceChannelsOf(channelId)) await this.purgeOneConversation(v);
|
|
39899
|
+
}
|
|
39900
|
+
async purgeOneConversation(channelId) {
|
|
38879
39901
|
const scopes = this.unifiedOn ? this.store.ownerDmScopeIds().filter((s) => parseOwnerDmScopeId(s)?.channelId === channelId) : [];
|
|
38880
39902
|
await this.notes.purgeChannel(channelId);
|
|
38881
39903
|
for (const s of scopes) this.store.clearConversationAbsence(s);
|
|
@@ -39732,6 +40754,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
39732
40754
|
* frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
|
|
39733
40755
|
* window flush, and the ingest tap block when the window did not take the turn (off, 0 by default). Like the typed tap, the write-time owner scope's pinned identity must be the speaking identity;
|
|
39734
40756
|
* otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
|
|
40757
|
+
*
|
|
40758
|
+
* Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
|
|
40759
|
+
* per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
|
|
40760
|
+
* channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
|
|
40761
|
+
* inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
|
|
40762
|
+
* persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
|
|
39735
40763
|
*/
|
|
39736
40764
|
async tapUnifiedVoice(m, c) {
|
|
39737
40765
|
const skip = (reason) => this.logUnifiedTapSkip(reason);
|
|
@@ -39740,9 +40768,22 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
39740
40768
|
if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
|
|
39741
40769
|
if (c.stamp === void 0) return skip("stamp_missing");
|
|
39742
40770
|
if (c.capture.purgeEpoch < 0) return skip("purged");
|
|
39743
|
-
const
|
|
40771
|
+
const parent3 = c.parent;
|
|
40772
|
+
const parentCapture = parent3?.capture;
|
|
40773
|
+
if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
|
|
40774
|
+
if (parent3 !== void 0) {
|
|
40775
|
+
if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
|
|
40776
|
+
if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
|
|
40777
|
+
}
|
|
40778
|
+
const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
|
|
39744
40779
|
if (scopeId === null) return skip("scope");
|
|
39745
40780
|
if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
|
|
40781
|
+
const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
|
|
40782
|
+
const now = this.store.itemCapture(parent3.channelId);
|
|
40783
|
+
if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
|
|
40784
|
+
if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
|
|
40785
|
+
return null;
|
|
40786
|
+
};
|
|
39746
40787
|
const r = await this.items.commit({
|
|
39747
40788
|
scopeKind: "owner_dm",
|
|
39748
40789
|
scopeId,
|
|
@@ -39753,7 +40794,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
39753
40794
|
source: "owner_voice",
|
|
39754
40795
|
trust: OWNER_VOICE_TRUST,
|
|
39755
40796
|
text: typeof m.text === "string" ? m.text : "",
|
|
39756
|
-
expect: { ...c.capture, clearGeneration: c.stamp }
|
|
40797
|
+
expect: { ...c.capture, clearGeneration: c.stamp },
|
|
40798
|
+
...precommit ? { precommit } : {}
|
|
39757
40799
|
});
|
|
39758
40800
|
return r.ok ? { ok: true } : { ok: false, reason: r.reason };
|
|
39759
40801
|
} catch {
|
|
@@ -40115,17 +41157,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40115
41157
|
async applyMemoryNote(raw, ctx) {
|
|
40116
41158
|
if (this.unifiedOn) return this.applyUnifiedNote(raw, ctx);
|
|
40117
41159
|
const ids = echoIds(raw);
|
|
40118
|
-
const
|
|
41160
|
+
const refuse3 = (reason, echo = ids) => {
|
|
40119
41161
|
writeServiceLog("info", "memory.note_refused", {
|
|
40120
41162
|
reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
|
|
40121
41163
|
});
|
|
40122
41164
|
return { type: "memory_note_result", ...echo, ok: false, reason };
|
|
40123
41165
|
};
|
|
40124
|
-
if (!this.notesProviderOn()) return
|
|
41166
|
+
if (!this.notesProviderOn()) return refuse3("mode");
|
|
40125
41167
|
const f = parseMemoryNoteFrame(raw);
|
|
40126
|
-
if (!f) return
|
|
40127
|
-
if (ctx.pendingRequests.has(f.request_id)) return
|
|
40128
|
-
if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return
|
|
41168
|
+
if (!f) return refuse3("invalid");
|
|
41169
|
+
if (ctx.pendingRequests.has(f.request_id)) return refuse3("invalid", { message_id: f.message_id });
|
|
41170
|
+
if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse3("invalid", {});
|
|
40129
41171
|
ctx.pendingRequests.add(f.request_id);
|
|
40130
41172
|
try {
|
|
40131
41173
|
const w2 = () => {
|
|
@@ -40137,13 +41179,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40137
41179
|
return cur.record;
|
|
40138
41180
|
};
|
|
40139
41181
|
const rec = w2();
|
|
40140
|
-
if (rec === void 0) return
|
|
41182
|
+
if (rec === void 0) return refuse3("message");
|
|
40141
41183
|
const channelId = rec.channel_id;
|
|
40142
|
-
if (rec.authorization?.memory_write !== true) return
|
|
40143
|
-
if (!this.noteTurnClean(rec.message_id)) return
|
|
41184
|
+
if (rec.authorization?.memory_write !== true) return refuse3("audience");
|
|
41185
|
+
if (!this.noteTurnClean(rec.message_id)) return refuse3("audience");
|
|
40144
41186
|
const held = await this.heldPosition(channelId);
|
|
40145
41187
|
const now = this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false });
|
|
40146
|
-
if (now.memory_write !== true) return
|
|
41188
|
+
if (now.memory_write !== true) return refuse3("audience");
|
|
40147
41189
|
const spend = async () => {
|
|
40148
41190
|
const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
|
|
40149
41191
|
if (rw === "declined") return "budget";
|
|
@@ -40159,44 +41201,44 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40159
41201
|
};
|
|
40160
41202
|
if (f.op === "forget" && typeof f.id === "object") {
|
|
40161
41203
|
const shown = ctx.shownRecall(f.message_id, ctx.token);
|
|
40162
|
-
if (!shown || !shown.has(f.id.recall)) return
|
|
41204
|
+
if (!shown || !shown.has(f.id.recall)) return refuse3("invalid");
|
|
40163
41205
|
const rw = await spend();
|
|
40164
|
-
if (typeof rw === "string") return
|
|
40165
|
-
if (w2() !== rec) return
|
|
40166
|
-
if (!this.notesProviderOn()) return
|
|
41206
|
+
if (typeof rw === "string") return refuse3(rw);
|
|
41207
|
+
if (w2() !== rec) return refuse3("message");
|
|
41208
|
+
if (!this.notesProviderOn()) return refuse3("mode");
|
|
40167
41209
|
const r2 = await this.recall.forget(channelId, f.id.recall, rw, precommit(rw));
|
|
40168
|
-
if (r2 === "unknown_id") return
|
|
40169
|
-
if (r2 !== "ok") return
|
|
41210
|
+
if (r2 === "unknown_id") return refuse3("invalid");
|
|
41211
|
+
if (r2 !== "ok") return refuse3(r2);
|
|
40170
41212
|
writeServiceLog("info", "memory.recall_forgotten");
|
|
40171
41213
|
return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true };
|
|
40172
41214
|
}
|
|
40173
41215
|
let mutation;
|
|
40174
41216
|
let rewrite;
|
|
40175
41217
|
if (f.op === "forget") {
|
|
40176
|
-
if (typeof f.id !== "number") return
|
|
41218
|
+
if (typeof f.id !== "number") return refuse3("invalid");
|
|
40177
41219
|
const rw = await spend();
|
|
40178
|
-
if (typeof rw === "string") return
|
|
41220
|
+
if (typeof rw === "string") return refuse3(rw);
|
|
40179
41221
|
rewrite = rw;
|
|
40180
41222
|
mutation = { op: "forget", id: f.id, rewrite: rw };
|
|
40181
41223
|
} else {
|
|
40182
41224
|
const p = prepareStoredNote(f.text, { text: rec.text, ...rec.transcribed === true ? { transcribed: true } : {} });
|
|
40183
|
-
if (!p.ok) return
|
|
41225
|
+
if (!p.ok) return refuse3(p.reason);
|
|
40184
41226
|
if (f.op === "replace") {
|
|
40185
|
-
if (typeof f.id !== "number") return
|
|
41227
|
+
if (typeof f.id !== "number") return refuse3("invalid");
|
|
40186
41228
|
const rw = await spend();
|
|
40187
|
-
if (typeof rw === "string") return
|
|
41229
|
+
if (typeof rw === "string") return refuse3(rw);
|
|
40188
41230
|
rewrite = rw;
|
|
40189
41231
|
mutation = { op: "replace", id: f.id, text: p.text, rewrite: rw };
|
|
40190
41232
|
} else {
|
|
40191
41233
|
mutation = { op: "add", text: p.text };
|
|
40192
41234
|
}
|
|
40193
41235
|
}
|
|
40194
|
-
if (w2() !== rec) return
|
|
40195
|
-
if (!this.notesProviderOn()) return
|
|
41236
|
+
if (w2() !== rec) return refuse3("message");
|
|
41237
|
+
if (!this.notesProviderOn()) return refuse3("mode");
|
|
40196
41238
|
const stamp = this.ingestGeneration.get(rec.message_id);
|
|
40197
|
-
if (stamp === void 0) return
|
|
41239
|
+
if (stamp === void 0) return refuse3("cleared");
|
|
40198
41240
|
const r = await this.notes.write(channelId, mutation, stamp, precommit(rewrite));
|
|
40199
|
-
if (!r.ok) return
|
|
41241
|
+
if (!r.ok) return refuse3(r.reason);
|
|
40200
41242
|
writeServiceLog("info", "memory.note_written", {
|
|
40201
41243
|
op: ServiceLogEnum.of(f.op, ["add", "replace", "forget"]),
|
|
40202
41244
|
notes_count: r.notesCount,
|
|
@@ -40205,7 +41247,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40205
41247
|
return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.id };
|
|
40206
41248
|
} catch (e) {
|
|
40207
41249
|
writeServiceLog("warn", "memory.note_write_failed", {}, e);
|
|
40208
|
-
return
|
|
41250
|
+
return refuse3("store");
|
|
40209
41251
|
} finally {
|
|
40210
41252
|
ctx.pendingRequests.delete(f.request_id);
|
|
40211
41253
|
}
|
|
@@ -40219,17 +41261,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40219
41261
|
*/
|
|
40220
41262
|
async applyUnifiedNote(raw, ctx) {
|
|
40221
41263
|
const ids = echoIds(raw);
|
|
40222
|
-
const
|
|
41264
|
+
const refuse3 = (reason, echo = ids) => {
|
|
40223
41265
|
writeServiceLog("info", "memory.note_refused", {
|
|
40224
41266
|
reason_class: ServiceLogEnum.of(reason, ["mode", "invalid", "message", "audience", "provenance", "secret", "cap", "cleared", "store", "budget", "purged", "not_owner_words"])
|
|
40225
41267
|
});
|
|
40226
41268
|
return { type: "memory_note_result", ...echo, ok: false, reason };
|
|
40227
41269
|
};
|
|
40228
|
-
if (!this.unifiedProviderOn()) return
|
|
41270
|
+
if (!this.unifiedProviderOn()) return refuse3("mode");
|
|
40229
41271
|
const f = parseMemoryNoteFrame(raw);
|
|
40230
|
-
if (!f) return
|
|
40231
|
-
if (ctx.pendingRequests.has(f.request_id)) return
|
|
40232
|
-
if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return
|
|
41272
|
+
if (!f) return refuse3("invalid");
|
|
41273
|
+
if (ctx.pendingRequests.has(f.request_id)) return refuse3("invalid", { message_id: f.message_id });
|
|
41274
|
+
if (ctx.pendingRequests.size >= MAX_PENDING_NOTE_REQUESTS) return refuse3("invalid", {});
|
|
40233
41275
|
ctx.pendingRequests.add(f.request_id);
|
|
40234
41276
|
try {
|
|
40235
41277
|
const w2 = () => {
|
|
@@ -40241,25 +41283,25 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40241
41283
|
return cur.record;
|
|
40242
41284
|
};
|
|
40243
41285
|
const rec = w2();
|
|
40244
|
-
if (rec === void 0) return
|
|
41286
|
+
if (rec === void 0) return refuse3("message");
|
|
40245
41287
|
const channelId = rec.channel_id;
|
|
40246
|
-
if (rec.authorization?.memory_write !== true) return
|
|
40247
|
-
if (!this.noteTurnClean(rec.message_id)) return
|
|
41288
|
+
if (rec.authorization?.memory_write !== true) return refuse3("audience");
|
|
41289
|
+
if (!this.noteTurnClean(rec.message_id)) return refuse3("audience");
|
|
40248
41290
|
const held = await this.heldPosition(channelId);
|
|
40249
|
-
if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return
|
|
41291
|
+
if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse3("audience");
|
|
40250
41292
|
const scopeId = this.ownerScopeNow(channelId);
|
|
40251
|
-
if (scopeId === null) return
|
|
41293
|
+
if (scopeId === null) return refuse3("audience");
|
|
40252
41294
|
const precommit = () => !this.unifiedProviderOn() ? "mode" : w2() !== rec ? "message" : null;
|
|
40253
|
-
if (f.op === "replace") return
|
|
40254
|
-
if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx,
|
|
41295
|
+
if (f.op === "replace") return refuse3("invalid");
|
|
41296
|
+
if (f.op === "forget") return await this.forgetUnified(f, rec, channelId, ctx, refuse3, precommit);
|
|
40255
41297
|
const stripped = allowlistPin(f.text);
|
|
40256
|
-
if (stripped.trim().length === 0) return
|
|
41298
|
+
if (stripped.trim().length === 0) return refuse3("invalid");
|
|
40257
41299
|
const shown = ctx.shownUnified(f.message_id, ctx.token) ?? /* @__PURE__ */ new Set();
|
|
40258
|
-
if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return
|
|
40259
|
-
if (scanNote(stripped) !== null) return
|
|
40260
|
-
if (stripped.length > PIN_MAX_CHARS) return
|
|
41300
|
+
if (!pinCoverage(stripped, rec.text, this.unifiedReads.coverageTexts(f.message_id, shown)).ok) return refuse3("not_owner_words");
|
|
41301
|
+
if (scanNote(stripped) !== null) return refuse3("secret");
|
|
41302
|
+
if (stripped.length > PIN_MAX_CHARS) return refuse3("cap");
|
|
40261
41303
|
const stamp = this.ingestGeneration.get(rec.message_id);
|
|
40262
|
-
if (stamp === void 0) return
|
|
41304
|
+
if (stamp === void 0) return refuse3("cleared");
|
|
40263
41305
|
const capture = this.store.itemCapture(channelId);
|
|
40264
41306
|
const r = await this.items.commit({
|
|
40265
41307
|
scopeKind: "owner_dm",
|
|
@@ -40277,7 +41319,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40277
41319
|
});
|
|
40278
41320
|
if (!r.ok) {
|
|
40279
41321
|
const map = { pin_cap: "cap", secret: "secret", cleared: "cleared", room_cleared: "cleared", disabled: "mode", mode: "mode", purged: "purged", message: "message", duplicate: "invalid", invalid: "invalid", empty: "invalid" };
|
|
40280
|
-
return
|
|
41322
|
+
return refuse3(map[r.reason] ?? "store");
|
|
40281
41323
|
}
|
|
40282
41324
|
await this.notes.exclusive(async () => this.unifiedReads.bumpGeneration());
|
|
40283
41325
|
void this.send(channelId, pinnedLine(stripped), void 0, { agentTurn: false }).catch((e) => writeServiceLog("warn", "memory.pinned_line_failed", {}, e));
|
|
@@ -40285,7 +41327,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40285
41327
|
return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true, id: r.itemIds[0] };
|
|
40286
41328
|
} catch (e) {
|
|
40287
41329
|
writeServiceLog("warn", "memory.note_write_failed", {}, e);
|
|
40288
|
-
return
|
|
41330
|
+
return refuse3("store");
|
|
40289
41331
|
} finally {
|
|
40290
41332
|
ctx.pendingRequests.delete(f.request_id);
|
|
40291
41333
|
}
|
|
@@ -40299,16 +41341,16 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40299
41341
|
* is invalidated in the same mutex hold, the item leaves the ranking hot set, then the bridge publishes `Forgot:`
|
|
40300
41342
|
* (OQ-P2: afterPublish keeps the line out of history and the items). Every reason is a key of UNIFIED_REFUSAL.
|
|
40301
41343
|
*/
|
|
40302
|
-
async forgetUnified(f, rec, channelId, ctx,
|
|
40303
|
-
if (typeof f.id !== "object") return
|
|
41344
|
+
async forgetUnified(f, rec, channelId, ctx, refuse3, precommit) {
|
|
41345
|
+
if (typeof f.id !== "object") return refuse3("invalid");
|
|
40304
41346
|
const itemId = f.id.recall;
|
|
40305
41347
|
const shown = ctx.shownUnified(f.message_id, ctx.token);
|
|
40306
|
-
if (!shown || !shown.has(itemId)) return
|
|
41348
|
+
if (!shown || !shown.has(itemId)) return refuse3("invalid");
|
|
40307
41349
|
const text = this.unifiedReads.shownText(f.message_id, itemId);
|
|
40308
|
-
if (text === void 0) return
|
|
41350
|
+
if (text === void 0) return refuse3("invalid");
|
|
40309
41351
|
const rw = await this.store.prepareRuntimeInboxRewrite(rec.message_id, bumpW9);
|
|
40310
|
-
if (rw === "declined") return
|
|
40311
|
-
if (rw === "no_row") return
|
|
41352
|
+
if (rw === "declined") return refuse3("budget");
|
|
41353
|
+
if (rw === "no_row") return refuse3("message");
|
|
40312
41354
|
const r = await this.notes.exclusive(async () => {
|
|
40313
41355
|
const veto = precommit();
|
|
40314
41356
|
if (veto !== null) return { ok: false, reason: veto };
|
|
@@ -40321,7 +41363,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40321
41363
|
}
|
|
40322
41364
|
return out;
|
|
40323
41365
|
});
|
|
40324
|
-
if (!r.ok) return
|
|
41366
|
+
if (!r.ok) return refuse3(r.reason === "unknown_id" ? "invalid" : r.reason);
|
|
40325
41367
|
this.rankingNote(() => this.ranking.removed(r.itemIds));
|
|
40326
41368
|
void this.send(channelId, forgotLine(text), void 0, { agentTurn: false }).catch((e) => writeServiceLog("warn", "memory.forgot_line_failed", {}, e));
|
|
40327
41369
|
writeServiceLog("info", "memory.item_forgotten");
|
|
@@ -40395,8 +41437,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40395
41437
|
this.fastReplyChains.set(channelId, next);
|
|
40396
41438
|
}
|
|
40397
41439
|
/** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
|
|
40398
|
-
reserveFastReply(
|
|
41440
|
+
reserveFastReply(replyChannel) {
|
|
40399
41441
|
this.fastReplyJobs++;
|
|
41442
|
+
const channelId = this.capChannelFor(replyChannel);
|
|
40400
41443
|
this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
|
|
40401
41444
|
return () => {
|
|
40402
41445
|
this.fastReplyJobs--;
|
|
@@ -40620,6 +41663,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40620
41663
|
});
|
|
40621
41664
|
if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
|
|
40622
41665
|
const rows = await res.json();
|
|
41666
|
+
for (const r of rows) if (r.channel_kind === "voice") this.listedAsVoice.add(r.channel_id);
|
|
40623
41667
|
for (const r of rows) this.store.recordChannelEnclave(r.channel_id, r.enclave_id);
|
|
40624
41668
|
return rows.map((r) => ({
|
|
40625
41669
|
channel_id: r.channel_id,
|
|
@@ -40703,6 +41747,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40703
41747
|
*/
|
|
40704
41748
|
recordListedChannelMeta(channels, at) {
|
|
40705
41749
|
for (const c of channels) {
|
|
41750
|
+
if (this.listedAsVoice.has(c.channel_id)) continue;
|
|
40706
41751
|
this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at });
|
|
40707
41752
|
this.store.recordChannelIsDm(c.channel_id, c.is_dm);
|
|
40708
41753
|
}
|
|
@@ -40886,7 +41931,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40886
41931
|
}
|
|
40887
41932
|
const self = members.get(this.cfg.identityId);
|
|
40888
41933
|
if (self?.name) this.lastOwnDisplayName = self.name;
|
|
40889
|
-
|
|
41934
|
+
const unclassified = this.isUnlistedChannel(channelId) && !this.voiceChannels.isNotVoice(channelId);
|
|
41935
|
+
if (!this.voiceChannels.isVoice(channelId) && !unclassified) await this.resolveAndPinOwner(channelId, liveRows);
|
|
40890
41936
|
} catch {
|
|
40891
41937
|
}
|
|
40892
41938
|
}
|
|
@@ -40922,7 +41968,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40922
41968
|
* held group (never the backend's row label, M-2). NEVER throws (M-4): any
|
|
40923
41969
|
* exception is `failed verify_error`, and the row is acked like any other turn.
|
|
40924
41970
|
*/
|
|
40925
|
-
async verifyOwnerTurn(m) {
|
|
41971
|
+
async verifyOwnerTurn(m, keyChannel = m.channelId) {
|
|
40926
41972
|
const pin = this.cfg.ownerGrantPin?.pin ?? null;
|
|
40927
41973
|
if (!pin || m.envelope.sender_id !== pin.ownerIdentityId) return OWNER_TURN_UNCHECKED;
|
|
40928
41974
|
try {
|
|
@@ -40934,9 +41980,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40934
41980
|
}
|
|
40935
41981
|
if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
|
|
40936
41982
|
let leafClass = "ok";
|
|
40937
|
-
if (this.freshMeta(
|
|
41983
|
+
if (this.freshMeta(keyChannel)?.is_dm === true) {
|
|
40938
41984
|
const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
|
|
40939
|
-
if (signedDm !== void 0 && signedDm !==
|
|
41985
|
+
if (signedDm !== void 0 && signedDm !== keyChannel) return this.ownerTurnFailed(m, "wrong_dm_channel");
|
|
40940
41986
|
leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
|
|
40941
41987
|
ownerIdentityId: pin.ownerIdentityId,
|
|
40942
41988
|
agentIdentityId: this.cfg.identityId,
|
|
@@ -41100,9 +42146,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41100
42146
|
});
|
|
41101
42147
|
}
|
|
41102
42148
|
/** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
|
|
41103
|
-
async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
|
|
42149
|
+
async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped, voiceParent) {
|
|
41104
42150
|
const held = await this.heldPosition(channelId);
|
|
41105
|
-
const
|
|
42151
|
+
const keyHeld = voiceParent === void 0 ? held : await this.heldPosition(voiceParent);
|
|
42152
|
+
const auth = this.authorizationFor(voiceParent ?? channelId, senderId, relaySpeakerId, keyHeld.epoch, keyHeld.groupId, {
|
|
42153
|
+
ownerVerdict,
|
|
42154
|
+
voiceScoped,
|
|
42155
|
+
...voiceParent === void 0 ? {} : { voice: { channelId, heldEpoch: held.epoch, heldGroupId: held.groupId } }
|
|
42156
|
+
});
|
|
41106
42157
|
if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
|
|
41107
42158
|
if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
|
|
41108
42159
|
writeServiceLog("warn", "room_grant.untracked");
|
|
@@ -41155,7 +42206,10 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41155
42206
|
const meta = this.freshMeta(channelId);
|
|
41156
42207
|
const entry = this.freshRosterEntry(channelId);
|
|
41157
42208
|
const roster = entry?.members;
|
|
41158
|
-
const
|
|
42209
|
+
const vEntry = opts.voice === void 0 ? void 0 : this.freshRosterEntry(opts.voice.channelId);
|
|
42210
|
+
const vSender = vEntry?.members.get(senderId);
|
|
42211
|
+
const sender = opts.voice === void 0 ? roster?.get(senderId) : vSender === void 0 ? void 0 : roster?.get(senderId) ?? vSender;
|
|
42212
|
+
const senderIsRelay = opts.voice === void 0 ? sender?.is_relay === true : vSender?.is_relay === true;
|
|
41159
42213
|
let audience = "unknown";
|
|
41160
42214
|
if (meta && sender) {
|
|
41161
42215
|
if (sender.is_agent) audience = "agent_to_agent";
|
|
@@ -41167,15 +42221,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41167
42221
|
let tool_grants = NO_TOOL_GRANTS;
|
|
41168
42222
|
let relaySpeaker;
|
|
41169
42223
|
let memoryWrite = false;
|
|
41170
|
-
const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId);
|
|
41171
|
-
const ownerDmFresh = epochFresh || this.ownerDmCycleFresh.has(channelId);
|
|
42224
|
+
const epochFresh = rosterFreshAt(entry, heldEpoch, heldGroupId) && (opts.voice === void 0 || rosterFreshAt(vEntry, opts.voice.heldEpoch, opts.voice.heldGroupId));
|
|
42225
|
+
const ownerDmFresh = epochFresh || opts.voice === void 0 && this.ownerDmCycleFresh.has(channelId);
|
|
41172
42226
|
if (ownerDmFresh && this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
|
|
41173
42227
|
const pinned = this.channelOwner.get(channelId);
|
|
41174
42228
|
const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
|
|
41175
42229
|
if (ownerId !== void 0) {
|
|
41176
42230
|
const ownerDirect = audience === "owner_dm" && senderId === ownerId;
|
|
41177
42231
|
const owner = roster?.get(ownerId);
|
|
41178
|
-
const ownerRelayed = relaySpeakerId !== void 0 &&
|
|
42232
|
+
const ownerRelayed = relaySpeakerId !== void 0 && senderIsRelay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
|
|
41179
42233
|
if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
|
|
41180
42234
|
if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
|
|
41181
42235
|
if (ownerRelayed) relaySpeaker = "owner";
|
|
@@ -41854,6 +42908,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41854
42908
|
async ingestCycleBody(receiptIds) {
|
|
41855
42909
|
if (this.closingDown) return;
|
|
41856
42910
|
const cycleStart = performance.now();
|
|
42911
|
+
let voiceListed = false;
|
|
42912
|
+
const cycleStartedAt = Date.now();
|
|
42913
|
+
const joinedInPhase0 = /* @__PURE__ */ new Set();
|
|
41857
42914
|
try {
|
|
41858
42915
|
const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
|
|
41859
42916
|
this.recordRecvLag(rows);
|
|
@@ -41863,9 +42920,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41863
42920
|
}
|
|
41864
42921
|
for (const cid of channelIds) {
|
|
41865
42922
|
if (this.joinState.get(cid) !== "JOINED") {
|
|
42923
|
+
joinedInPhase0.add(cid);
|
|
41866
42924
|
await this.reconcileJoin(cid);
|
|
41867
42925
|
}
|
|
41868
42926
|
}
|
|
42927
|
+
if (await this.resolveUnlistedChannels(channelIds, false)) voiceListed = true;
|
|
41869
42928
|
for (const cid of channelIds) {
|
|
41870
42929
|
if (this.freshRoster(cid) === void 0) {
|
|
41871
42930
|
await this.refreshChannelRoster(cid);
|
|
@@ -41890,6 +42949,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41890
42949
|
}
|
|
41891
42950
|
} catch {
|
|
41892
42951
|
}
|
|
42952
|
+
const voiceStamp = await this.voicePositionSnapshot();
|
|
42953
|
+
const receiveStartedAt = Date.now();
|
|
42954
|
+
const stampsFor = (m) => {
|
|
42955
|
+
if (m.receivedAt >= receiveStartedAt) return true;
|
|
42956
|
+
const p = this.voiceChannels.parentOf(m.channelId);
|
|
42957
|
+
return m.receivedAt >= cycleStartedAt && p !== void 0 && !joinedInPhase0.has(p);
|
|
42958
|
+
};
|
|
41893
42959
|
let entries;
|
|
41894
42960
|
try {
|
|
41895
42961
|
entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
|
|
@@ -41899,29 +42965,56 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
41899
42965
|
for (const cid of entries.supersededChannelIds ?? []) {
|
|
41900
42966
|
this.joinState.set(cid, "JOINED");
|
|
41901
42967
|
}
|
|
41902
|
-
if (!entries.length)
|
|
42968
|
+
if (!entries.length) {
|
|
42969
|
+
await this.reevaluateVoiceBindings();
|
|
42970
|
+
return;
|
|
42971
|
+
}
|
|
41903
42972
|
await this.maybePruneOwnerReplay();
|
|
41904
42973
|
const receivedAt = Date.now();
|
|
41905
42974
|
const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
|
|
41906
42975
|
const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
|
|
41907
42976
|
const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
|
|
42977
|
+
const unifiedVoiceParentAt = (p) => {
|
|
42978
|
+
const cap = itemCaptureAtReceipt.get(p);
|
|
42979
|
+
return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
|
|
42980
|
+
};
|
|
41908
42981
|
for (const m of entries) {
|
|
41909
42982
|
this.lastInboundAt.set(m.channelId, receivedAt);
|
|
41910
42983
|
if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
|
|
41911
42984
|
if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
|
|
41912
42985
|
if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
|
|
42986
|
+
const receiptParent = this.voiceChannels.parentOf(m.channelId);
|
|
42987
|
+
if (receiptParent !== void 0) {
|
|
42988
|
+
if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
|
|
42989
|
+
if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
|
|
42990
|
+
}
|
|
41913
42991
|
this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
|
|
41914
42992
|
receiptIds.push(m.messageId);
|
|
42993
|
+
const stamp = voiceStamp.get(m.channelId);
|
|
42994
|
+
if (stamp !== void 0) this.voiceChannels.noteReceipt(m.messageId, stampsFor(m) ? stamp : null);
|
|
42995
|
+
else if (this.voiceChannels.isVoice(m.channelId) || this.isUnlistedChannel(m.channelId) && !this.voiceChannels.isResolved(m.channelId)) {
|
|
42996
|
+
this.voiceChannels.noteReceipt(m.messageId, null);
|
|
42997
|
+
}
|
|
41915
42998
|
}
|
|
41916
|
-
|
|
41917
|
-
|
|
42999
|
+
if (await this.resolveUnlistedChannels(entries.map((m) => m.channelId), voiceListed)) voiceListed = true;
|
|
43000
|
+
await this.reevaluateVoiceBindings();
|
|
43001
|
+
entries = await this.gateVoiceEntries(entries);
|
|
43002
|
+
let metaRefreshed = voiceListed;
|
|
43003
|
+
if (!voiceListed && entries.some((m) => this.freshMeta(this.keyChannelForFrame(m)) === void 0)) {
|
|
41918
43004
|
await this.refreshChannelMeta();
|
|
41919
43005
|
metaRefreshed = true;
|
|
41920
43006
|
}
|
|
41921
|
-
await this.refreshRostersForCycle(entries.
|
|
43007
|
+
await this.refreshRostersForCycle(entries.flatMap((m) => {
|
|
43008
|
+
const key = this.keyChannelForFrame(m);
|
|
43009
|
+
return key === m.channelId ? [m.channelId] : [m.channelId, key];
|
|
43010
|
+
}));
|
|
41922
43011
|
const relayMissRefreshed = /* @__PURE__ */ new Set();
|
|
41923
43012
|
const acked = [];
|
|
41924
43013
|
for (const m of entries) {
|
|
43014
|
+
if (this.voiceChannels.isVoice(m.channelId) && !this.voiceFrameParent.has(m.messageId)) {
|
|
43015
|
+
acked.push(m.queueId);
|
|
43016
|
+
continue;
|
|
43017
|
+
}
|
|
41925
43018
|
try {
|
|
41926
43019
|
if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
|
|
41927
43020
|
const encId = m.envelope.enclave_id;
|
|
@@ -42082,7 +43175,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42082
43175
|
}
|
|
42083
43176
|
if (typeof parsed.text === "string") {
|
|
42084
43177
|
if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
|
|
42085
|
-
const
|
|
43178
|
+
const keyChannel = this.keyChannelForFrame(m);
|
|
43179
|
+
const voiceParent = keyChannel !== m.channelId ? keyChannel : void 0;
|
|
43180
|
+
const meta = this.freshMeta(keyChannel);
|
|
42086
43181
|
const senderType = m.envelope.sender_type;
|
|
42087
43182
|
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
42088
43183
|
const claimsRelaySpeaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string";
|
|
@@ -42129,14 +43224,15 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42129
43224
|
senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
|
|
42130
43225
|
text: parsed.text
|
|
42131
43226
|
});
|
|
42132
|
-
const ownerVerdict = await this.verifyOwnerTurn(m);
|
|
43227
|
+
const ownerVerdict = await this.verifyOwnerTurn(m, keyChannel);
|
|
42133
43228
|
const turnAuthorization = await this.authorizeTurn(
|
|
42134
43229
|
m.channelId,
|
|
42135
43230
|
m.messageId,
|
|
42136
43231
|
m.envelope.sender_id,
|
|
42137
43232
|
ownerVerdict,
|
|
42138
43233
|
speaker?.identityId,
|
|
42139
|
-
voiceScoped
|
|
43234
|
+
voiceScoped,
|
|
43235
|
+
voiceParent
|
|
42140
43236
|
);
|
|
42141
43237
|
await this.prepareNotesForTurn(m.channelId, turnAuthorization);
|
|
42142
43238
|
const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
|
|
@@ -42163,11 +43259,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42163
43259
|
}
|
|
42164
43260
|
let fastPathRoomBlocked = false;
|
|
42165
43261
|
if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
|
|
42166
|
-
const
|
|
42167
|
-
|
|
43262
|
+
const floorKey = this.capChannelFor(m.channelId);
|
|
43263
|
+
const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
|
|
43264
|
+
if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) fastPathRoomBlocked = true;
|
|
42168
43265
|
}
|
|
42169
43266
|
if (fastPathRoomBlocked) this.fastPathSkipped++;
|
|
42170
|
-
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.
|
|
43267
|
+
const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedFor(m.channelId);
|
|
42171
43268
|
const fastGreeting = isCallStart && fastPathFiring;
|
|
42172
43269
|
const runtimeVoice = (ackSent) => m.voiceMode ? {
|
|
42173
43270
|
voice: true,
|
|
@@ -42236,6 +43333,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42236
43333
|
pending: Promise.resolve(),
|
|
42237
43334
|
purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
|
|
42238
43335
|
itemCapture: itemCaptureAtReceipt.get(m.channelId),
|
|
43336
|
+
...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
|
|
42239
43337
|
bufferGeneration
|
|
42240
43338
|
};
|
|
42241
43339
|
this.voiceCoalescing.set(windowKey, win);
|
|
@@ -42266,18 +43364,19 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42266
43364
|
const merged = { ...held, text, memory: windowMemory };
|
|
42267
43365
|
if (this.unifiedOn) {
|
|
42268
43366
|
const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
|
|
42269
|
-
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
|
|
43367
|
+
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
|
|
42270
43368
|
this.unifiedWork.add(work);
|
|
42271
43369
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42272
43370
|
}
|
|
42273
43371
|
void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice", win.purgeEpoch);
|
|
42274
43372
|
let roomBlocked = false;
|
|
42275
43373
|
if (fastPathEligible && isRoom) {
|
|
42276
|
-
const
|
|
42277
|
-
|
|
43374
|
+
const floorKey = this.capChannelFor(channelId);
|
|
43375
|
+
const reserved = this.fastReplyReserved.get(floorKey) ?? 0;
|
|
43376
|
+
if (this.rateFloor.available(`room:${floorKey}`) - reserved < 2) roomBlocked = true;
|
|
42278
43377
|
}
|
|
42279
43378
|
if (roomBlocked) this.fastPathSkipped++;
|
|
42280
|
-
const plan = fastPathEligible && !roomBlocked && !this.
|
|
43379
|
+
const plan = fastPathEligible && !roomBlocked && !this.pausedFor(channelId) ? this.planFastPath(firstId, text, false) : void 0;
|
|
42281
43380
|
if (!plan || plan.kind === "dup" || plan.kind === "skip") {
|
|
42282
43381
|
if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
|
|
42283
43382
|
await this.settleHeldRuntimeMessage(merged, true);
|
|
@@ -42355,7 +43454,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42355
43454
|
);
|
|
42356
43455
|
if (windowEligible && turnAuthorization.relay_speaker === "owner") {
|
|
42357
43456
|
const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
|
|
42358
|
-
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43457
|
+
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43458
|
+
stamp: this.ingestGeneration.get(m.messageId),
|
|
43459
|
+
capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
|
|
43460
|
+
...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
|
|
43461
|
+
});
|
|
42359
43462
|
this.unifiedWork.add(work);
|
|
42360
43463
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42361
43464
|
}
|
|
@@ -42381,6 +43484,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42381
43484
|
}
|
|
42382
43485
|
}
|
|
42383
43486
|
for (const m of entries) this.recallReceipt.delete(m.messageId);
|
|
43487
|
+
for (const m of entries) this.voiceFrameParent.delete(m.messageId);
|
|
42384
43488
|
if (acked.length) {
|
|
42385
43489
|
try {
|
|
42386
43490
|
await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
|
|
@@ -42661,8 +43765,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42661
43765
|
}
|
|
42662
43766
|
}
|
|
42663
43767
|
/** Spec §7.5 (T-14c, M-4): the runtime reports an executed-call cap for a room. Never throws. */
|
|
42664
|
-
noteRoomCapReached(
|
|
42665
|
-
if (typeof
|
|
43768
|
+
noteRoomCapReached(reported) {
|
|
43769
|
+
if (typeof reported !== "string" || this.closingDown) return;
|
|
43770
|
+
const channelId = this.capChannelFor(reported);
|
|
42666
43771
|
if (this.roomSearchDailyCap === 0) return;
|
|
42667
43772
|
if (this.freshMeta(channelId)?.is_dm !== false || this.channelRoster.get(channelId)?.crossOrg !== false) return;
|
|
42668
43773
|
const day = utcDayOf(Date.now());
|
|
@@ -42719,8 +43824,9 @@ ${channelId}`;
|
|
|
42719
43824
|
const a = entry.authorization;
|
|
42720
43825
|
if (!a || a.audience !== "team_room" || !a.tool_grants.some((g) => g.tool === "web_search")) return entry;
|
|
42721
43826
|
const day = utcDayOf(Date.now());
|
|
43827
|
+
const room = this.capChannelFor(entry.channel_id);
|
|
42722
43828
|
if (this.roomSearchDailyCap === 0) {
|
|
42723
|
-
this.logRoomCap(day,
|
|
43829
|
+
this.logRoomCap(day, room, "operator");
|
|
42724
43830
|
return this.withoutWebSearch(entry);
|
|
42725
43831
|
}
|
|
42726
43832
|
if (this.agentRoomSearchDailyCap === 0) {
|
|
@@ -42728,9 +43834,9 @@ ${channelId}`;
|
|
|
42728
43834
|
return this.withoutWebSearch(entry);
|
|
42729
43835
|
}
|
|
42730
43836
|
try {
|
|
42731
|
-
const n = this.store.countRoomSearchGrant(
|
|
42732
|
-
if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(
|
|
42733
|
-
this.logRoomCap(day,
|
|
43837
|
+
const n = this.store.countRoomSearchGrant(room, day);
|
|
43838
|
+
if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(room, day, "bridge")) {
|
|
43839
|
+
this.logRoomCap(day, room, "bridge");
|
|
42734
43840
|
}
|
|
42735
43841
|
if (n > this.roomSearchDailyCap) return this.withoutWebSearch(entry);
|
|
42736
43842
|
const total = this.store.countRoomSearchGrant(AGENT_TOTAL_KEY, day);
|
|
@@ -42827,9 +43933,15 @@ ${channelId}`;
|
|
|
42827
43933
|
* PAUSE_RECHECK_MS window, so release does not depend on new traffic.
|
|
42828
43934
|
*/
|
|
42829
43935
|
heldForPause(channelId) {
|
|
42830
|
-
|
|
43936
|
+
let key = channelId;
|
|
43937
|
+
let at = this.pausedChannels.get(channelId);
|
|
43938
|
+
const parent3 = at === void 0 ? this.voiceChannels.parentOf(channelId) : void 0;
|
|
43939
|
+
if (parent3 !== void 0) {
|
|
43940
|
+
key = parent3;
|
|
43941
|
+
at = this.pausedChannels.get(parent3);
|
|
43942
|
+
}
|
|
42831
43943
|
if (at === void 0) return false;
|
|
42832
|
-
this.schedulePauseRecheck(
|
|
43944
|
+
this.schedulePauseRecheck(key, at);
|
|
42833
43945
|
return true;
|
|
42834
43946
|
}
|
|
42835
43947
|
schedulePauseRecheck(channelId, confirmedAt) {
|
|
@@ -42858,7 +43970,7 @@ ${channelId}`;
|
|
|
42858
43970
|
if (await this.probeOwnPause(channelId) === true) {
|
|
42859
43971
|
const now = Date.now();
|
|
42860
43972
|
this.pausedChannels.set(channelId, now);
|
|
42861
|
-
if (this.buffer.some((m) => m.channel_id === channelId)) this.schedulePauseRecheck(channelId, now);
|
|
43973
|
+
if (this.buffer.some((m) => m.channel_id === channelId || this.voiceChannels.parentOf(m.channel_id) === channelId)) this.schedulePauseRecheck(channelId, now);
|
|
42862
43974
|
return;
|
|
42863
43975
|
}
|
|
42864
43976
|
this.clearPause(channelId);
|
|
@@ -43337,6 +44449,10 @@ ${channelId}`;
|
|
|
43337
44449
|
custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
|
|
43338
44450
|
failed_attachments: this.failedAttachments,
|
|
43339
44451
|
refused_vault_refs: this.refusedVaultRefs,
|
|
44452
|
+
voice_frames_refused: this.voiceFramesRefused,
|
|
44453
|
+
voice_frames_filtered: this.voiceFramesFiltered,
|
|
44454
|
+
voice_receipt_cap_reached: this.voiceChannels.receiptCapReached(),
|
|
44455
|
+
voice_relay_in_parent: this.voiceChannels.relayInParentCount(),
|
|
43340
44456
|
fast_path_sent: this.fastPathSent,
|
|
43341
44457
|
fast_path_failed: this.fastPathFailed,
|
|
43342
44458
|
fast_path_skipped: this.fastPathSkipped,
|
|
@@ -46000,7 +47116,7 @@ var PIN_COPY = Object.freeze({
|
|
|
46000
47116
|
usage: "usage: agent-bridge pin --credential <path> --file <owner-signing-key.json> --agent <agent_identity_id>"
|
|
46001
47117
|
// [COPY, approved by Eric 2026-10-12] (P-4)
|
|
46002
47118
|
});
|
|
46003
|
-
var
|
|
47119
|
+
var refuse2 = (reason) => ({ ok: false, reason });
|
|
46004
47120
|
function isSymlink(path) {
|
|
46005
47121
|
try {
|
|
46006
47122
|
return lstatSync8(path).isSymbolicLink();
|
|
@@ -46030,33 +47146,33 @@ function runPinCommand(opts) {
|
|
|
46030
47146
|
const credDir = dirname12(resolve2(opts.credentialPath));
|
|
46031
47147
|
const target = join23(credDir, PIN_FILE_NAME);
|
|
46032
47148
|
try {
|
|
46033
|
-
if (realpathSync2(dirname12(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return
|
|
47149
|
+
if (realpathSync2(dirname12(resolve2(opts.pinSourcePath))) !== realpathSync2(credDir)) return refuse2("directory_mismatch");
|
|
46034
47150
|
} catch {
|
|
46035
|
-
return
|
|
47151
|
+
return refuse2("io_error");
|
|
46036
47152
|
}
|
|
46037
47153
|
try {
|
|
46038
|
-
if (isSymlink(opts.credentialPath) || isSymlink(opts.pinSourcePath) || isSymlink(target)) return
|
|
47154
|
+
if (isSymlink(opts.credentialPath) || isSymlink(opts.pinSourcePath) || isSymlink(target)) return refuse2("symlink_refused");
|
|
46039
47155
|
} catch {
|
|
46040
|
-
return
|
|
47156
|
+
return refuse2("io_error");
|
|
46041
47157
|
}
|
|
46042
47158
|
let source;
|
|
46043
47159
|
try {
|
|
46044
47160
|
source = readJson(opts.pinSourcePath);
|
|
46045
47161
|
} catch {
|
|
46046
|
-
return
|
|
47162
|
+
return refuse2("pin_invalid");
|
|
46047
47163
|
}
|
|
46048
47164
|
const parsed = parsePinObject(source);
|
|
46049
|
-
if (!parsed.ok) return
|
|
47165
|
+
if (!parsed.ok) return refuse2("pin_invalid");
|
|
46050
47166
|
let cred;
|
|
46051
47167
|
try {
|
|
46052
47168
|
const c = readJson(opts.credentialPath);
|
|
46053
|
-
if (typeof c !== "object" || c === null || Array.isArray(c)) return
|
|
47169
|
+
if (typeof c !== "object" || c === null || Array.isArray(c)) return refuse2("credential_invalid");
|
|
46054
47170
|
cred = c;
|
|
46055
47171
|
} catch {
|
|
46056
|
-
return
|
|
47172
|
+
return refuse2("credential_invalid");
|
|
46057
47173
|
}
|
|
46058
|
-
for (const k of REQUIRED) if (typeof cred[k] !== "string" || !cred[k]) return
|
|
46059
|
-
if (cred.identity_id !== opts.agentIdentityId) return
|
|
47174
|
+
for (const k of REQUIRED) if (typeof cred[k] !== "string" || !cred[k]) return refuse2("credential_invalid");
|
|
47175
|
+
if (cred.identity_id !== opts.agentIdentityId) return refuse2("agent_mismatch");
|
|
46060
47176
|
opts.announce(cred.identity_id);
|
|
46061
47177
|
const newKey = parsed.pin.publicKeyB64;
|
|
46062
47178
|
let priorFileKey;
|
|
@@ -46081,7 +47197,7 @@ function runPinCommand(opts) {
|
|
|
46081
47197
|
} catch {
|
|
46082
47198
|
}
|
|
46083
47199
|
}
|
|
46084
|
-
return
|
|
47200
|
+
return refuse2("io_error");
|
|
46085
47201
|
};
|
|
46086
47202
|
const step = (s) => opts.afterStep?.(s);
|
|
46087
47203
|
try {
|
|
@@ -46589,7 +47705,7 @@ function realRunModeDeps(credentialPath, storePath) {
|
|
|
46589
47705
|
}
|
|
46590
47706
|
async function runMode(deps) {
|
|
46591
47707
|
const { credentialPath, storePath, log } = deps;
|
|
46592
|
-
const
|
|
47708
|
+
const refuse3 = (reason, extra, e) => {
|
|
46593
47709
|
const fields = {
|
|
46594
47710
|
reason: ServiceLogEnum.of(reason, [
|
|
46595
47711
|
"credential_not_found",
|
|
@@ -46621,18 +47737,18 @@ async function runMode(deps) {
|
|
|
46621
47737
|
cfg = loadConfig({ credentialPath, storePath });
|
|
46622
47738
|
daemon = parseDaemonArgs(deps.argv, deps.env);
|
|
46623
47739
|
} catch (e) {
|
|
46624
|
-
if (e instanceof ConfigError)
|
|
46625
|
-
else
|
|
47740
|
+
if (e instanceof ConfigError) refuse3(e.reason, { field: e.field, source: e.source }, e);
|
|
47741
|
+
else refuse3("unknown", {}, e);
|
|
46626
47742
|
return deps.exit(1);
|
|
46627
47743
|
}
|
|
46628
47744
|
try {
|
|
46629
47745
|
validateBind(daemon.bind, daemon.tls);
|
|
46630
47746
|
} catch (e) {
|
|
46631
|
-
|
|
47747
|
+
refuse3("bind_refused", {}, e);
|
|
46632
47748
|
return deps.exit(1);
|
|
46633
47749
|
}
|
|
46634
47750
|
if (!CANONICAL_UUID2.test(cfg.identityId)) {
|
|
46635
|
-
|
|
47751
|
+
refuse3("identity_id_invalid", { field: "identity_id" });
|
|
46636
47752
|
return deps.exit(1);
|
|
46637
47753
|
}
|
|
46638
47754
|
const lockDir = join25(deps.home, ".securecoms", "locks");
|