@securecoms/agent-bridge 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/bin.js +814 -94
- package/dist/bridge-agent.d.ts +43 -6
- package/dist/consumer-grants.d.ts +9 -0
- package/dist/errors.d.ts +1 -1
- package/dist/grants/binding.d.ts +18 -5
- package/dist/http-server.d.ts +10 -36
- package/dist/index.js +814 -94
- package/dist/memory/unified/item-book.d.ts +5 -2
- package/dist/reply-credit.d.ts +22 -0
- package/dist/reply-intent.d.ts +6 -1
- package/dist/service/runtime-config-dir.d.ts +3 -0
- package/dist/version.d.ts +1 -1
- package/dist/ws-server.d.ts +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -17477,7 +17477,17 @@ var CODES = [
|
|
|
17477
17477
|
// missed and before any write, so nothing landed. Definite: the committer's
|
|
17478
17478
|
// work cannot name an agent in a humans-only enclave (admission refuses
|
|
17479
17479
|
// one), so dropping the commit starts no resend loop.
|
|
17480
|
-
{ status: 403, code: "humans_only_enclave" }
|
|
17480
|
+
{ status: 403, code: "humans_only_enclave" },
|
|
17481
|
+
// ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
|
|
17482
|
+
// leaf-key claim map is missing, does not cover exactly the Welcome
|
|
17483
|
+
// targets, or names a key that is not the target's current vendable
|
|
17484
|
+
// KeyPackage's. Refused before any write; definite, so the committer drops
|
|
17485
|
+
// the commit, refetches the KeyPackages and rebuilds instead of resending
|
|
17486
|
+
// the same stale bytes forever.
|
|
17487
|
+
{ status: 409, code: "voice_stale_key_package" },
|
|
17488
|
+
// PR-E: a Welcome target already holds a bound leaf in this V group (the
|
|
17489
|
+
// DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
|
|
17490
|
+
{ status: 409, code: "voice_leaf_already_bound" }
|
|
17481
17491
|
];
|
|
17482
17492
|
var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
|
|
17483
17493
|
var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
|
|
@@ -18524,6 +18534,31 @@ function ownLeafIndexOf(mgr) {
|
|
|
18524
18534
|
function tagOf(mgr) {
|
|
18525
18535
|
return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
18526
18536
|
}
|
|
18537
|
+
function leafKeyClaims(targets, pkgs) {
|
|
18538
|
+
if (targets.length !== pkgs.length)
|
|
18539
|
+
throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
|
|
18540
|
+
const out = {};
|
|
18541
|
+
targets.forEach((d, i) => {
|
|
18542
|
+
out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
|
|
18543
|
+
});
|
|
18544
|
+
return out;
|
|
18545
|
+
}
|
|
18546
|
+
function leafIndicesForSignatureKey(mgr, keyHex) {
|
|
18547
|
+
const tree = mgr.state?.ratchetTree;
|
|
18548
|
+
if (!tree)
|
|
18549
|
+
return [];
|
|
18550
|
+
const want = keyHex.toLowerCase();
|
|
18551
|
+
const out = [];
|
|
18552
|
+
const numLeaves = Math.floor((tree.length + 1) / 2);
|
|
18553
|
+
for (let i = 0; i < numLeaves; i++) {
|
|
18554
|
+
const node = tree[i * 2];
|
|
18555
|
+
if (!node || node.nodeType !== "leaf")
|
|
18556
|
+
continue;
|
|
18557
|
+
if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
|
|
18558
|
+
out.push(i);
|
|
18559
|
+
}
|
|
18560
|
+
return out;
|
|
18561
|
+
}
|
|
18527
18562
|
function tagOfState(state) {
|
|
18528
18563
|
const m = new MLSGroupManager(state.ciphersuite);
|
|
18529
18564
|
m.importState(state);
|
|
@@ -18550,6 +18585,8 @@ function isEpochConflict(e) {
|
|
|
18550
18585
|
var LABEL = "agentvault-connect-v1";
|
|
18551
18586
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
18552
18587
|
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
18588
|
+
var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
|
|
18589
|
+
var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
|
|
18553
18590
|
var DELIVERY_PULL_PAGE = 100;
|
|
18554
18591
|
var DELIVERY_MAX_RETRIES = 5;
|
|
18555
18592
|
var DrainPageFullError = class extends Error {
|
|
@@ -18638,6 +18675,12 @@ var SelfAddRefusedError = class extends Error {
|
|
|
18638
18675
|
this.name = "SelfAddRefusedError";
|
|
18639
18676
|
}
|
|
18640
18677
|
};
|
|
18678
|
+
var VoiceMirrorAddRefusedError = class extends Error {
|
|
18679
|
+
constructor() {
|
|
18680
|
+
super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
|
|
18681
|
+
this.name = "VoiceMirrorAddRefusedError";
|
|
18682
|
+
}
|
|
18683
|
+
};
|
|
18641
18684
|
var VaultCredentialMismatchError = class extends Error {
|
|
18642
18685
|
expected;
|
|
18643
18686
|
got;
|
|
@@ -18748,6 +18791,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
18748
18791
|
* {@link errorStats}; mutated only through {@link recordDecryptError} /
|
|
18749
18792
|
* {@link recordDecryptSuccess}.
|
|
18750
18793
|
*/
|
|
18794
|
+
/** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
|
|
18795
|
+
* deferred behind the kept Welcome `welcomeId`. Restarted for a different
|
|
18796
|
+
* Welcome; cleared when the channel decrypts, or when a pass that saw the
|
|
18797
|
+
* whole queue kept no Welcome for it (fix round 1, L2: the Welcome
|
|
18798
|
+
* resolved, so a later re-Welcome gets a fresh, bounded window). An entry
|
|
18799
|
+
* past its bound stays (expired) until then, so the bound cannot restart
|
|
18800
|
+
* itself pass after pass. In memory: a reload restarts it, and the
|
|
18801
|
+
* per-pass row cap is what bounds the page cost across reloads. */
|
|
18802
|
+
keptWelcomeDeferSince = /* @__PURE__ */ new Map();
|
|
18751
18803
|
errors = /* @__PURE__ */ new Map();
|
|
18752
18804
|
stuckThreshold;
|
|
18753
18805
|
/** Consecutive-error threshold at which a wedged channel's row is quarantined
|
|
@@ -18966,8 +19018,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
18966
19018
|
* channel resumes normal processing and can quarantine again later if it
|
|
18967
19019
|
* re-wedges (the onWedged guard is reset so the hook can fire again).
|
|
18968
19020
|
*/
|
|
19021
|
+
/** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
|
|
19022
|
+
* caller has already established that this pass KEPT a no-state Welcome
|
|
19023
|
+
* for `channelId`. True: defer the commit row. Never throws. */
|
|
19024
|
+
deferKeptWelcomeCommit(channelId, welcomeId) {
|
|
19025
|
+
const pred = this.o.deferCommitsWhileWelcomePending;
|
|
19026
|
+
if (!pred)
|
|
19027
|
+
return false;
|
|
19028
|
+
let boundMs = 0;
|
|
19029
|
+
try {
|
|
19030
|
+
const v = pred(channelId);
|
|
19031
|
+
boundMs = v === true ? KEPT_WELCOME_COMMIT_DEFER_MS : typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.min(v, KEPT_WELCOME_COMMIT_DEFER_MS) : 0;
|
|
19032
|
+
} catch {
|
|
19033
|
+
boundMs = 0;
|
|
19034
|
+
}
|
|
19035
|
+
if (boundMs <= 0)
|
|
19036
|
+
return false;
|
|
19037
|
+
const now = this.nowMs();
|
|
19038
|
+
const entry = this.keptWelcomeDeferSince.get(channelId);
|
|
19039
|
+
if (entry === void 0 || entry.welcomeId !== welcomeId) {
|
|
19040
|
+
this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
|
|
19041
|
+
return true;
|
|
19042
|
+
}
|
|
19043
|
+
return now - entry.since < boundMs;
|
|
19044
|
+
}
|
|
18969
19045
|
recordDecryptSuccess(channelId) {
|
|
18970
19046
|
this.lastDecryptAt.set(channelId, Date.now());
|
|
19047
|
+
this.keptWelcomeDeferSince.delete(channelId);
|
|
18971
19048
|
const e = this.errors.get(channelId);
|
|
18972
19049
|
if (!e)
|
|
18973
19050
|
return;
|
|
@@ -19363,6 +19440,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
19363
19440
|
* server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
|
|
19364
19441
|
* not apply (which means the state is genuinely FORKED, not merely behind,
|
|
19365
19442
|
* and replay cannot fix a fork).
|
|
19443
|
+
*
|
|
19444
|
+
* THROWS (retryable, never `false`, #1290): CustodyPersistError when the
|
|
19445
|
+
* channel is under the custody hold (nothing applied), and the C2 classes
|
|
19446
|
+
* (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
|
|
19447
|
+
* the advanced cache, so the persisted state is unchanged. A /sync request
|
|
19448
|
+
* that fails also throws, before anything is applied.
|
|
19366
19449
|
*/
|
|
19367
19450
|
async resyncByReplay(channelId, groupId) {
|
|
19368
19451
|
if (!await this.hasPersistedChannelState(channelId))
|
|
@@ -19382,12 +19465,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
19382
19465
|
let applied = 0;
|
|
19383
19466
|
try {
|
|
19384
19467
|
await this.locks.run(channelId, async () => {
|
|
19468
|
+
if (await this.custodyHeld(channelId))
|
|
19469
|
+
throw new CustodyPersistError(channelId);
|
|
19385
19470
|
const mgr = await this.manager(channelId);
|
|
19386
19471
|
try {
|
|
19387
19472
|
for (const c of commits) {
|
|
19388
19473
|
if (c.epoch <= Number(mgr.epoch))
|
|
19389
19474
|
continue;
|
|
19390
|
-
|
|
19475
|
+
const commitBytes = hexToBytes22(c.payload);
|
|
19476
|
+
try {
|
|
19477
|
+
await mgr.decrypt(commitBytes);
|
|
19478
|
+
} catch (e) {
|
|
19479
|
+
if (await probeOwnRemoval(mgr, commitBytes) !== null) {
|
|
19480
|
+
this.noteOwnLeafRemoved(channelId, {
|
|
19481
|
+
group_id: groupId,
|
|
19482
|
+
epoch: c.epoch,
|
|
19483
|
+
message_id: c.id
|
|
19484
|
+
});
|
|
19485
|
+
}
|
|
19486
|
+
throw e;
|
|
19487
|
+
}
|
|
19391
19488
|
applied++;
|
|
19392
19489
|
}
|
|
19393
19490
|
if (applied > 0)
|
|
@@ -19399,6 +19496,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
19399
19496
|
}
|
|
19400
19497
|
});
|
|
19401
19498
|
} catch (e) {
|
|
19499
|
+
if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
|
|
19500
|
+
writeServiceLog("warn", "resync.replay_held", { applied }, e);
|
|
19501
|
+
throw e;
|
|
19502
|
+
}
|
|
19402
19503
|
writeServiceLog("warn", "resync.replay_commit_failed", { applied }, e);
|
|
19403
19504
|
return false;
|
|
19404
19505
|
}
|
|
@@ -20127,10 +20228,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20127
20228
|
* The row is deliberately NOT acked here — the subsequent ensureSession(join) /
|
|
20128
20229
|
* joinAndReconcile consumes the same Welcome and acks it after joining.
|
|
20129
20230
|
*/
|
|
20130
|
-
async peekWelcomeChannel(deviceId) {
|
|
20231
|
+
async peekWelcomeChannel(deviceId, opts = {}) {
|
|
20131
20232
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
20132
|
-
const
|
|
20133
|
-
|
|
20233
|
+
const asked = /* @__PURE__ */ new Set();
|
|
20234
|
+
for (const r of rows) {
|
|
20235
|
+
if (r.message_type !== "welcome" || r.connect_channel_id == null)
|
|
20236
|
+
continue;
|
|
20237
|
+
const ch = r.connect_channel_id;
|
|
20238
|
+
if (!opts.accept)
|
|
20239
|
+
return ch;
|
|
20240
|
+
if (asked.has(ch))
|
|
20241
|
+
continue;
|
|
20242
|
+
asked.add(ch);
|
|
20243
|
+
let ok = false;
|
|
20244
|
+
try {
|
|
20245
|
+
ok = await opts.accept(ch) === true;
|
|
20246
|
+
} catch {
|
|
20247
|
+
ok = false;
|
|
20248
|
+
}
|
|
20249
|
+
if (ok)
|
|
20250
|
+
return ch;
|
|
20251
|
+
}
|
|
20252
|
+
return null;
|
|
20134
20253
|
}
|
|
20135
20254
|
/**
|
|
20136
20255
|
* Publish this device's KeyPackage if it holds none; otherwise a no-op that
|
|
@@ -20236,10 +20355,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20236
20355
|
}
|
|
20237
20356
|
const attempted = [];
|
|
20238
20357
|
const failed = [];
|
|
20358
|
+
const discarded = [];
|
|
20239
20359
|
for (const channelId of channelIds) {
|
|
20240
20360
|
attempted.push(channelId);
|
|
20241
20361
|
try {
|
|
20242
|
-
await this.ensureSession(channelId, {
|
|
20362
|
+
await this.ensureSession(channelId, {
|
|
20363
|
+
enclaveId: "welcome-drain",
|
|
20364
|
+
bootstrap: false,
|
|
20365
|
+
onUnopenableWelcome: (groupId) => {
|
|
20366
|
+
discarded.push({ channelId, groupId });
|
|
20367
|
+
}
|
|
20368
|
+
});
|
|
20243
20369
|
} catch (error) {
|
|
20244
20370
|
failed.push({ channelId, error });
|
|
20245
20371
|
}
|
|
@@ -20249,7 +20375,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20249
20375
|
receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
|
|
20250
20376
|
this.drainReceipts.set(receipt, this.nowMs());
|
|
20251
20377
|
}
|
|
20252
|
-
return { attempted, failed, skipped, unknown, pageFull, receipt };
|
|
20378
|
+
return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
|
|
20253
20379
|
}
|
|
20254
20380
|
/**
|
|
20255
20381
|
* Resolve the channel's pending commit record, under the channel lock, and
|
|
@@ -20722,7 +20848,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20722
20848
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
20723
20849
|
return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
20724
20850
|
});
|
|
20725
|
-
const
|
|
20851
|
+
const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
|
|
20852
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
|
|
20726
20853
|
if (init.created === false) {
|
|
20727
20854
|
if (!this.isAdoptableOrphan(init, others)) {
|
|
20728
20855
|
if (this.isOrphanShape(init, others))
|
|
@@ -20742,7 +20869,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20742
20869
|
commit_epoch: Number(mgr.epoch),
|
|
20743
20870
|
expected_epoch: init.current_epoch,
|
|
20744
20871
|
member_device_ids: [this.o.deviceId, ...others],
|
|
20745
|
-
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
|
|
20872
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
|
|
20873
|
+
// Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
|
|
20874
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
|
|
20746
20875
|
}, mgr, null);
|
|
20747
20876
|
const res = await this.commitWithPendingRecord(channelId, rec, mgr);
|
|
20748
20877
|
if (!res.landed) {
|
|
@@ -20756,15 +20885,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20756
20885
|
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
|
|
20757
20886
|
if (!w)
|
|
20758
20887
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
20888
|
+
const aborted = () => opts.shouldAbort?.() === true;
|
|
20889
|
+
if (aborted())
|
|
20890
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
20759
20891
|
let mgr;
|
|
20760
20892
|
try {
|
|
20761
20893
|
mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
|
|
20762
20894
|
} catch (err) {
|
|
20895
|
+
if (aborted())
|
|
20896
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
20763
20897
|
this.recordDecryptError(w, err);
|
|
20764
20898
|
writeServiceLog("warn", "session.unopenable_welcome_discarded", {}, err);
|
|
20765
20899
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
20900
|
+
try {
|
|
20901
|
+
opts.onUnopenableWelcome?.(w.group_id);
|
|
20902
|
+
} catch (e) {
|
|
20903
|
+
writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
20904
|
+
}
|
|
20905
|
+
try {
|
|
20906
|
+
this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
|
|
20907
|
+
} catch (e) {
|
|
20908
|
+
writeServiceLog("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
20909
|
+
}
|
|
20766
20910
|
return;
|
|
20767
20911
|
}
|
|
20912
|
+
if (aborted())
|
|
20913
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
20768
20914
|
this.managers.set(channelId, mgr);
|
|
20769
20915
|
await this.saveMain(channelId, mgr);
|
|
20770
20916
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
@@ -20975,6 +21121,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20975
21121
|
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
|
|
20976
21122
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
20977
21123
|
groupId = init.group_id;
|
|
21124
|
+
if (this.o.isVoiceChannel?.(channelId) === true) {
|
|
21125
|
+
const mirror = init.member_device_ids;
|
|
21126
|
+
if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
|
|
21127
|
+
writeServiceLog("warn", "admission.voice_device_already_in_mirror");
|
|
21128
|
+
throw new VoiceMirrorAddRefusedError();
|
|
21129
|
+
}
|
|
21130
|
+
}
|
|
20978
21131
|
members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
20979
21132
|
}
|
|
20980
21133
|
if (new TextDecoder().decode(mgr.groupId) !== groupId) {
|
|
@@ -20993,7 +21146,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20993
21146
|
expected_epoch: priorEpoch,
|
|
20994
21147
|
member_device_ids: members,
|
|
20995
21148
|
op: "add",
|
|
20996
|
-
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
|
|
21149
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
|
|
21150
|
+
// Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
|
|
21151
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
|
|
20997
21152
|
}, mgr, prior), mgr);
|
|
20998
21153
|
if (!res.landed) {
|
|
20999
21154
|
const e = res.error;
|
|
@@ -21314,6 +21469,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21314
21469
|
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
21315
21470
|
}
|
|
21316
21471
|
}
|
|
21472
|
+
/**
|
|
21473
|
+
* ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
|
|
21474
|
+
* Remove of one leaf the server's committer-work lists in `remove_devices`
|
|
21475
|
+
* (on the hidden voice channel V: a stale device of an agent or relay
|
|
21476
|
+
* identity that is still on V's roster).
|
|
21477
|
+
*
|
|
21478
|
+
* ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
|
|
21479
|
+
* identity's leaf by add-order position, which RFC 9420's leftmost-blank
|
|
21480
|
+
* refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
|
|
21481
|
+
* evicts the current device's leaf while the distribute's `removed_device_id`
|
|
21482
|
+
* makes the server drop the STALE device from its mirror. The current device
|
|
21483
|
+
* is then in the roster with no leaf, outside the adds delta forever: an
|
|
21484
|
+
* unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
|
|
21485
|
+
*
|
|
21486
|
+
* - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
|
|
21487
|
+
* is matched against THIS committer's live tree (the device-level leaf
|
|
21488
|
+
* predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
|
|
21489
|
+
* mints a fresh signature keypair per call, so a match names exactly the
|
|
21490
|
+
* leaf that KeyPackage was Added as;
|
|
21491
|
+
* - exactly ONE matching leaf, or nothing is committed;
|
|
21492
|
+
* - that leaf's credential identity is read from the tree, never from the
|
|
21493
|
+
* server, and is what the distribute names as `removed_identity_id`.
|
|
21494
|
+
*
|
|
21495
|
+
* Outcomes. `"removed"`: one leaf evicted and the distribute landed.
|
|
21496
|
+
* `"unresolved"`: no KeyPackage is published for the device, or it matches no
|
|
21497
|
+
* leaf (the device rotated it after its Add, or the leaf is already gone).
|
|
21498
|
+
* Nothing is committed: the leaf, if any, stays, and is reported, never
|
|
21499
|
+
* guessed. The server's next delta lists it again (the caller's throttle
|
|
21500
|
+
* bounds the retries).
|
|
21501
|
+
*
|
|
21502
|
+
* Refusals (thrown before any commit is built, so nothing is distributed):
|
|
21503
|
+
* the committer's own device (`own_device`), any leaf of the committer's own
|
|
21504
|
+
* identity, the committer's own leaf, or an unknown own leaf
|
|
21505
|
+
* (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
|
|
21506
|
+
* or more matching leaves; a held group that is not the channel's active
|
|
21507
|
+
* group (#757 binding, checked BEFORE init).
|
|
21508
|
+
*/
|
|
21509
|
+
async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
|
|
21510
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
21511
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
21512
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
|
|
21513
|
+
}));
|
|
21514
|
+
}
|
|
21515
|
+
async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
|
|
21516
|
+
if (await this.custodyHeld(channelId))
|
|
21517
|
+
throw new CustodyPersistError(channelId);
|
|
21518
|
+
if (deviceId === this.o.deviceId)
|
|
21519
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
|
|
21520
|
+
const mgr = await this.manager(channelId);
|
|
21521
|
+
const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
|
|
21522
|
+
if (resolved === "unresolved")
|
|
21523
|
+
return "unresolved";
|
|
21524
|
+
const { leafIndex, leafIdentity } = resolved;
|
|
21525
|
+
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
|
|
21526
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
21527
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
21528
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
|
|
21529
|
+
}
|
|
21530
|
+
const priorEpoch = Number(mgr.epoch);
|
|
21531
|
+
const prior = tagOf(mgr);
|
|
21532
|
+
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
21533
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
21534
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
|
|
21535
|
+
}
|
|
21536
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
21537
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
21538
|
+
device_id: this.o.deviceId,
|
|
21539
|
+
commit: bytesToHex22(commit),
|
|
21540
|
+
commit_epoch: Number(mgr.epoch),
|
|
21541
|
+
expected_epoch: priorEpoch,
|
|
21542
|
+
member_device_ids: members,
|
|
21543
|
+
op: "remove",
|
|
21544
|
+
removed_identity_id: leafIdentity,
|
|
21545
|
+
removed_device_id: deviceId,
|
|
21546
|
+
welcomes: []
|
|
21547
|
+
}, mgr, prior), mgr);
|
|
21548
|
+
if (!res.landed) {
|
|
21549
|
+
const e = res.error;
|
|
21550
|
+
if (isSupersededRefusal(e))
|
|
21551
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
21552
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
21553
|
+
throw e;
|
|
21554
|
+
try {
|
|
21555
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
21556
|
+
} catch (e2) {
|
|
21557
|
+
if (e2 instanceof CustodyPersistError)
|
|
21558
|
+
throw e2;
|
|
21559
|
+
}
|
|
21560
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
|
|
21561
|
+
}
|
|
21562
|
+
return "removed";
|
|
21563
|
+
}
|
|
21564
|
+
/** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
|
|
21565
|
+
* the committer's tree. Unchanged from Task 24 and its fix round 1. */
|
|
21566
|
+
async resolveServedKeyPackageLeaf(mgr, deviceId) {
|
|
21567
|
+
const kps = await this.o.backend.fetchKeyPackages([deviceId]);
|
|
21568
|
+
const hex = kps[deviceId];
|
|
21569
|
+
if (!hex) {
|
|
21570
|
+
writeServiceLog("info", "committer.remove_device_unresolved", { no_key_package: true });
|
|
21571
|
+
return "unresolved";
|
|
21572
|
+
}
|
|
21573
|
+
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
21574
|
+
const leaves = leafIndicesForKeyPackage(mgr, pkg);
|
|
21575
|
+
if (leaves.length === 0) {
|
|
21576
|
+
writeServiceLog("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
|
|
21577
|
+
return "unresolved";
|
|
21578
|
+
}
|
|
21579
|
+
if (leaves.length > 1) {
|
|
21580
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
|
|
21581
|
+
}
|
|
21582
|
+
const leafIndex = leaves[0];
|
|
21583
|
+
const tree = mgr.state?.ratchetTree;
|
|
21584
|
+
if (!tree)
|
|
21585
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
21586
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
21587
|
+
if (cred.credentialType !== "basic") {
|
|
21588
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
|
|
21589
|
+
}
|
|
21590
|
+
const leafIdentity = new TextDecoder().decode(cred.identity);
|
|
21591
|
+
const ownIdentity = new TextDecoder().decode(this.o.identity);
|
|
21592
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
21593
|
+
if (!leafIdentity || leafIdentity === ownIdentity) {
|
|
21594
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
|
|
21595
|
+
}
|
|
21596
|
+
if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
|
|
21597
|
+
writeServiceLog("warn", "committer.remove_device_refused_last_leaf", {});
|
|
21598
|
+
return "unresolved";
|
|
21599
|
+
}
|
|
21600
|
+
return { leafIndex, leafIdentity };
|
|
21601
|
+
}
|
|
21602
|
+
/**
|
|
21603
|
+
* ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
|
|
21604
|
+
* resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
|
|
21605
|
+
* `remove_device_leaves`). The server recorded the key when the leaf was
|
|
21606
|
+
* born, from the KeyPackage it vended, so it survives the target's
|
|
21607
|
+
* KeyPackage rotation, revocation and deletion, which the served-KeyPackage
|
|
21608
|
+
* path cannot.
|
|
21609
|
+
*
|
|
21610
|
+
* - Leaves are matched by the bound key ONLY: exactly one match, else
|
|
21611
|
+
* `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
|
|
21612
|
+
* the served KeyPackage: two evidence sources that disagree fail closed.
|
|
21613
|
+
* - The matched leaf's basic-credential identity must equal the server's
|
|
21614
|
+
* `identityId` for the device (R4), else a throw.
|
|
21615
|
+
* - The committer's own leaf index (or an unknown one) is refused
|
|
21616
|
+
* ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
|
|
21617
|
+
* - R5: a leaf of the committer's OWN identity is admitted (a sibling
|
|
21618
|
+
* device: a revoked laptop). The brick class the own-identity refusal
|
|
21619
|
+
* guards came from positional or identity-level resolution, which this
|
|
21620
|
+
* exact-key path cannot reach, and the own leaf index is still refused.
|
|
21621
|
+
* - No last-leaf refusal: the binding is exact evidence, and an identity's
|
|
21622
|
+
* last leaf is exactly the revoked single-device case (and carry A's
|
|
21623
|
+
* Remove before its re-Add).
|
|
21624
|
+
* Synchronous and before any commit is built: a refusal distributes nothing.
|
|
21625
|
+
*/
|
|
21626
|
+
resolveBoundDeviceLeaf(mgr, bound) {
|
|
21627
|
+
const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
|
|
21628
|
+
if (leaves.length === 0) {
|
|
21629
|
+
writeServiceLog("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
|
|
21630
|
+
return "unresolved";
|
|
21631
|
+
}
|
|
21632
|
+
if (leaves.length > 1) {
|
|
21633
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
|
|
21634
|
+
}
|
|
21635
|
+
const leafIndex = leaves[0];
|
|
21636
|
+
const tree = mgr.state?.ratchetTree;
|
|
21637
|
+
if (!tree)
|
|
21638
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
21639
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
21640
|
+
const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
21641
|
+
if (!leafIdentity || leafIdentity !== bound.identityId) {
|
|
21642
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
|
|
21643
|
+
}
|
|
21644
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
21645
|
+
return { leafIndex, leafIdentity };
|
|
21646
|
+
}
|
|
21317
21647
|
/**
|
|
21318
21648
|
* Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
|
|
21319
21649
|
* targeted by its owner-observed leaf `signaturePublicKey` against the live
|
|
@@ -21870,6 +22200,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21870
22200
|
out.push({
|
|
21871
22201
|
queueId: `replay-${m.id}`,
|
|
21872
22202
|
messageId: envelope.message_id,
|
|
22203
|
+
// #1246 C-1: the group-messages row id is the same mls_messages key.
|
|
22204
|
+
...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
|
|
21873
22205
|
channelId,
|
|
21874
22206
|
plaintext,
|
|
21875
22207
|
envelope,
|
|
@@ -22770,6 +23102,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22770
23102
|
const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
|
|
22771
23103
|
const w2Acked = /* @__PURE__ */ new Map();
|
|
22772
23104
|
const w2Kept = /* @__PURE__ */ new Set();
|
|
23105
|
+
const keptWelcomeId = /* @__PURE__ */ new Map();
|
|
23106
|
+
const deferredThisPass = /* @__PURE__ */ new Map();
|
|
22773
23107
|
const unpinnedRefusals = /* @__PURE__ */ new Map();
|
|
22774
23108
|
const welcomes = rows.filter((r) => r.message_type === "welcome");
|
|
22775
23109
|
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
@@ -22928,6 +23262,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22928
23262
|
writeServiceLog("warn", "welcome.unopenable");
|
|
22929
23263
|
} else {
|
|
22930
23264
|
w2Kept.add(wcid);
|
|
23265
|
+
if (!keptWelcomeId.has(wcid))
|
|
23266
|
+
keptWelcomeId.set(wcid, row.queue_id);
|
|
22931
23267
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
22932
23268
|
writeServiceLog("debug", "delivery.welcome_no_local_state");
|
|
22933
23269
|
}
|
|
@@ -22938,6 +23274,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22938
23274
|
const cid = row.connect_channel_id;
|
|
22939
23275
|
if (!cid)
|
|
22940
23276
|
continue;
|
|
23277
|
+
if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
|
|
23278
|
+
deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
|
|
23279
|
+
continue;
|
|
23280
|
+
}
|
|
22941
23281
|
let advancedEpoch;
|
|
22942
23282
|
let custodyHeld = false;
|
|
22943
23283
|
await this.locks.run(cid, async () => {
|
|
@@ -23094,6 +23434,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23094
23434
|
this.leftStreak.set(cid, { pulls: 1, since: pullNow });
|
|
23095
23435
|
this.scheduleAutoResolve(cid);
|
|
23096
23436
|
}
|
|
23437
|
+
if (rows.length < DELIVERY_PULL_PAGE) {
|
|
23438
|
+
for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
|
|
23439
|
+
if (!w2Kept.has(cid))
|
|
23440
|
+
this.keptWelcomeDeferSince.delete(cid);
|
|
23441
|
+
}
|
|
23442
|
+
}
|
|
23097
23443
|
for (const [cid, gid] of w2Acked) {
|
|
23098
23444
|
if (w2Kept.has(cid))
|
|
23099
23445
|
continue;
|
|
@@ -23134,6 +23480,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23134
23480
|
if (supersededChannelIds.length) {
|
|
23135
23481
|
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
23136
23482
|
}
|
|
23483
|
+
if (w2Kept.size) {
|
|
23484
|
+
Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
|
|
23485
|
+
}
|
|
23137
23486
|
return appEntries;
|
|
23138
23487
|
}
|
|
23139
23488
|
/**
|
|
@@ -23370,7 +23719,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23370
23719
|
voiceDefaultResponder: row.voice_default_responder ?? void 0,
|
|
23371
23720
|
...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
|
|
23372
23721
|
payloadHex: row.payload,
|
|
23373
|
-
openedAt
|
|
23722
|
+
openedAt,
|
|
23723
|
+
// #1246 C-1: the server row id, beside the sender-chosen envelope id.
|
|
23724
|
+
...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
|
|
23374
23725
|
};
|
|
23375
23726
|
const custodiedAt = this.nowMs();
|
|
23376
23727
|
this.custody.set(row.queue_id, entry);
|
|
@@ -24925,9 +25276,9 @@ var RestMlsBackend = class {
|
|
|
24925
25276
|
current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
|
|
24926
25277
|
};
|
|
24927
25278
|
}
|
|
24928
|
-
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
25279
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
24929
25280
|
const path = `/api/v1/mls/connect-channels/${channelId}/init`;
|
|
24930
|
-
const body = { member_device_ids: memberDeviceIds };
|
|
25281
|
+
const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
|
|
24931
25282
|
let j;
|
|
24932
25283
|
for (let attempt = 0; ; attempt++) {
|
|
24933
25284
|
try {
|
|
@@ -25169,6 +25520,22 @@ var RestMlsBackend = class {
|
|
|
25169
25520
|
throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
|
|
25170
25521
|
return rows;
|
|
25171
25522
|
}
|
|
25523
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
25524
|
+
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 }) });
|
|
25525
|
+
const text = await res.text().catch(() => "");
|
|
25526
|
+
if (res.ok)
|
|
25527
|
+
return { status: res.status };
|
|
25528
|
+
let parsed = null;
|
|
25529
|
+
if (text) {
|
|
25530
|
+
try {
|
|
25531
|
+
parsed = JSON.parse(text);
|
|
25532
|
+
} catch {
|
|
25533
|
+
parsed = null;
|
|
25534
|
+
}
|
|
25535
|
+
}
|
|
25536
|
+
const code = parseErrorCode(parsed);
|
|
25537
|
+
return code === void 0 ? { status: res.status } : { status: res.status, code };
|
|
25538
|
+
}
|
|
25172
25539
|
async getCommitterWork(channelId) {
|
|
25173
25540
|
const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
25174
25541
|
const requireList = (field) => {
|
|
@@ -25178,7 +25545,29 @@ var RestMlsBackend = class {
|
|
|
25178
25545
|
}
|
|
25179
25546
|
return v.map(String);
|
|
25180
25547
|
};
|
|
25181
|
-
|
|
25548
|
+
const work = {
|
|
25549
|
+
adds: requireList("adds"),
|
|
25550
|
+
removes: requireList("removes")
|
|
25551
|
+
};
|
|
25552
|
+
if (j?.remove_devices !== void 0) {
|
|
25553
|
+
work.removeDevices = requireList("remove_devices");
|
|
25554
|
+
}
|
|
25555
|
+
const rawLeaves = j?.remove_device_leaves;
|
|
25556
|
+
if (rawLeaves !== void 0) {
|
|
25557
|
+
if (!Array.isArray(rawLeaves))
|
|
25558
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
|
|
25559
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25560
|
+
work.removeDeviceLeaves = rawLeaves.map((e) => {
|
|
25561
|
+
const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
|
|
25562
|
+
const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
|
|
25563
|
+
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)) {
|
|
25564
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
|
|
25565
|
+
}
|
|
25566
|
+
seen.add(o.device_id);
|
|
25567
|
+
return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
|
|
25568
|
+
});
|
|
25569
|
+
}
|
|
25570
|
+
return work;
|
|
25182
25571
|
}
|
|
25183
25572
|
async acquireCommitLease(channelId) {
|
|
25184
25573
|
try {
|
|
@@ -25228,6 +25617,48 @@ var RestMlsBackend = class {
|
|
|
25228
25617
|
|
|
25229
25618
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
25230
25619
|
var DELIVERY_PULL_LIMIT = 100;
|
|
25620
|
+
function keyPackageSignatureKeyHex(kpHex) {
|
|
25621
|
+
const b = hexToBytes22(kpHex);
|
|
25622
|
+
let at = 0;
|
|
25623
|
+
const u16 = () => {
|
|
25624
|
+
if (at + 2 > b.length)
|
|
25625
|
+
throw new Error("t");
|
|
25626
|
+
const v = b[at] << 8 | b[at + 1];
|
|
25627
|
+
at += 2;
|
|
25628
|
+
return v;
|
|
25629
|
+
};
|
|
25630
|
+
const opaque = () => {
|
|
25631
|
+
if (at >= b.length)
|
|
25632
|
+
throw new Error("t");
|
|
25633
|
+
const prefix = b[at] >> 6;
|
|
25634
|
+
if (prefix === 3)
|
|
25635
|
+
throw new Error("t");
|
|
25636
|
+
const size = 1 << prefix;
|
|
25637
|
+
if (at + size > b.length)
|
|
25638
|
+
throw new Error("t");
|
|
25639
|
+
let n = b[at] & 63;
|
|
25640
|
+
for (let i = 1; i < size; i++)
|
|
25641
|
+
n = n << 8 | b[at + i];
|
|
25642
|
+
at += size;
|
|
25643
|
+
if (at + n > b.length)
|
|
25644
|
+
throw new Error("t");
|
|
25645
|
+
const out = b.slice(at, at + n);
|
|
25646
|
+
at += n;
|
|
25647
|
+
return out;
|
|
25648
|
+
};
|
|
25649
|
+
try {
|
|
25650
|
+
if (u16() !== 1 || u16() !== 5)
|
|
25651
|
+
return null;
|
|
25652
|
+
u16();
|
|
25653
|
+
u16();
|
|
25654
|
+
opaque();
|
|
25655
|
+
opaque();
|
|
25656
|
+
const sig = opaque();
|
|
25657
|
+
return sig.length > 0 ? bytesToHex22(sig) : null;
|
|
25658
|
+
} catch {
|
|
25659
|
+
return null;
|
|
25660
|
+
}
|
|
25661
|
+
}
|
|
25231
25662
|
var FakeConnectBackend = class _FakeConnectBackend {
|
|
25232
25663
|
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
25233
25664
|
kps = /* @__PURE__ */ new Map();
|
|
@@ -25312,6 +25743,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25312
25743
|
* server's purge_device_queue. */
|
|
25313
25744
|
linkIdentityDevice(identityId, deviceId) {
|
|
25314
25745
|
this.deviceByIdentity.set(identityId, deviceId);
|
|
25746
|
+
this.identityByDevice.set(deviceId, identityId);
|
|
25315
25747
|
}
|
|
25316
25748
|
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
25317
25749
|
this.kps.set(deviceId, keyPackageHex);
|
|
@@ -25361,8 +25793,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25361
25793
|
current_epoch: this.epochByChannel.get(channelId) ?? 0
|
|
25362
25794
|
};
|
|
25363
25795
|
}
|
|
25364
|
-
|
|
25796
|
+
/** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
|
|
25797
|
+
* voice channel V. Init on an EXISTING V returns it unchanged and merges
|
|
25798
|
+
* nothing, as the real backend does. */
|
|
25799
|
+
voiceChannelIds = /* @__PURE__ */ new Set();
|
|
25800
|
+
/** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
|
|
25801
|
+
* {@link voiceChannelIds} only, as the PR-E server does for V:
|
|
25802
|
+
* `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
|
|
25803
|
+
* when a V leaf is born (the creator's init, an Add's Welcome) from the
|
|
25804
|
+
* verified claim, and deleted when a Remove of that device (or its
|
|
25805
|
+
* identity) lands. */
|
|
25806
|
+
voiceLeafBindings = /* @__PURE__ */ new Map();
|
|
25807
|
+
/** The device -> identity the server knows (the devices table). Written by
|
|
25808
|
+
* {@link linkIdentityDevice}; read for `remove_device_leaves`. */
|
|
25809
|
+
identityByDevice = /* @__PURE__ */ new Map();
|
|
25810
|
+
static voiceRefusal(path, code) {
|
|
25811
|
+
return new MlsBackendHttpError(path, 409, code);
|
|
25812
|
+
}
|
|
25813
|
+
/** The current vendable KeyPackage key of `deviceId`, or null. */
|
|
25814
|
+
currentLeafKey(deviceId) {
|
|
25815
|
+
const hex = this.kps.get(deviceId);
|
|
25816
|
+
return hex ? keyPackageSignatureKeyHex(hex) : null;
|
|
25817
|
+
}
|
|
25818
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
25365
25819
|
const alreadyExists = this.groupIdByChannel.has(channelId);
|
|
25820
|
+
if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
|
|
25821
|
+
const creator = memberDeviceIds[0];
|
|
25822
|
+
const current = creator ? this.currentLeafKey(creator) : null;
|
|
25823
|
+
if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
|
|
25824
|
+
throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
|
|
25825
|
+
}
|
|
25826
|
+
this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
|
|
25827
|
+
}
|
|
25366
25828
|
let groupId = this.groupIdByChannel.get(channelId);
|
|
25367
25829
|
if (!groupId) {
|
|
25368
25830
|
groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
|
|
@@ -25370,7 +25832,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25370
25832
|
this.channelByGroup.set(groupId, channelId);
|
|
25371
25833
|
this.epochByChannel.set(channelId, 0);
|
|
25372
25834
|
}
|
|
25373
|
-
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
25835
|
+
const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
25374
25836
|
this.membersByChannel.set(channelId, members);
|
|
25375
25837
|
return {
|
|
25376
25838
|
group_id: groupId,
|
|
@@ -25507,6 +25969,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25507
25969
|
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
|
|
25508
25970
|
}
|
|
25509
25971
|
}
|
|
25972
|
+
const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
|
|
25973
|
+
let bindNow = [];
|
|
25974
|
+
if (vBindings && req.commit && req.op !== "remove") {
|
|
25975
|
+
const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
|
|
25976
|
+
const claims = req.added_leaf_signature_keys;
|
|
25977
|
+
const path = _FakeConnectBackend.distributePath(groupId);
|
|
25978
|
+
if (!claims)
|
|
25979
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
25980
|
+
const claimed = Object.keys(claims);
|
|
25981
|
+
if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
|
|
25982
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
25983
|
+
}
|
|
25984
|
+
for (const t of targets) {
|
|
25985
|
+
const current = this.currentLeafKey(t);
|
|
25986
|
+
if (current === null || claims[t].toLowerCase() !== current)
|
|
25987
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
25988
|
+
if (vBindings.has(t))
|
|
25989
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
|
|
25990
|
+
}
|
|
25991
|
+
bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
|
|
25992
|
+
}
|
|
25510
25993
|
if (req.commit) {
|
|
25511
25994
|
const log = this.commitLog.get(groupId) ?? [];
|
|
25512
25995
|
log.push({
|
|
@@ -25529,6 +26012,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25529
26012
|
this.membersByChannel.set(channelId, remaining);
|
|
25530
26013
|
if (evictedDevice)
|
|
25531
26014
|
this.queues.delete(evictedDevice);
|
|
26015
|
+
if (vBindings) {
|
|
26016
|
+
for (const d of [...vBindings.keys()]) {
|
|
26017
|
+
if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
|
|
26018
|
+
vBindings.delete(d);
|
|
26019
|
+
}
|
|
26020
|
+
}
|
|
25532
26021
|
} else {
|
|
25533
26022
|
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
|
|
25534
26023
|
this.membersByChannel.set(channelId, members);
|
|
@@ -25552,6 +26041,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25552
26041
|
});
|
|
25553
26042
|
}
|
|
25554
26043
|
}
|
|
26044
|
+
if (vBindings && bindNow.length > 0) {
|
|
26045
|
+
for (const [d, k] of bindNow)
|
|
26046
|
+
vBindings.set(d, { key: k, unusableSignaledAt: null });
|
|
26047
|
+
this.voiceLeafBindings.set(channelId, vBindings);
|
|
26048
|
+
}
|
|
25555
26049
|
for (const w of req.welcomes ?? []) {
|
|
25556
26050
|
this.q(w.target_device_id).push({
|
|
25557
26051
|
queue_id: "q-" + this._seq++,
|
|
@@ -25612,6 +26106,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25612
26106
|
async getCommitterWork(_channelId) {
|
|
25613
26107
|
return { adds: [], removes: [] };
|
|
25614
26108
|
}
|
|
26109
|
+
/** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
|
|
26110
|
+
* modelled: the group is a V (else 404), a binding exists (else 409), the
|
|
26111
|
+
* device's current vendable key differs from the bound one (409
|
|
26112
|
+
* `voice_leaf_key_current` when equal, `voice_no_current_key_package`
|
|
26113
|
+
* when it has none). An already-signalled row is an idempotent 202. The
|
|
26114
|
+
* bind and roster gates are the server's (backend tests). */
|
|
26115
|
+
signalVoiceLeafUnusableCalls = [];
|
|
26116
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
26117
|
+
this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
|
|
26118
|
+
const channelId = this.channelByGroup.get(groupId);
|
|
26119
|
+
if (!channelId || !this.voiceChannelIds.has(channelId))
|
|
26120
|
+
return { status: 404, code: "Not Found" };
|
|
26121
|
+
const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
|
|
26122
|
+
if (!row)
|
|
26123
|
+
return { status: 409, code: "voice_leaf_binding_missing" };
|
|
26124
|
+
const current = this.currentLeafKey(deviceId);
|
|
26125
|
+
if (current === null)
|
|
26126
|
+
return { status: 409, code: "voice_no_current_key_package" };
|
|
26127
|
+
if (current === row.key)
|
|
26128
|
+
return { status: 409, code: "voice_leaf_key_current" };
|
|
26129
|
+
if (row.unusableSignaledAt === null)
|
|
26130
|
+
row.unusableSignaledAt = this.now();
|
|
26131
|
+
return { status: 202 };
|
|
26132
|
+
}
|
|
26133
|
+
/** Task 24b test seam: V's committer work as the PR-E server derives it,
|
|
26134
|
+
* for a test's `roster` (the server's roster devices): `adds` = roster
|
|
26135
|
+
* devices not in the mirror; `remove_devices` / `remove_device_leaves` =
|
|
26136
|
+
* roster devices whose binding is signalled unusable (NOT in `adds`: they
|
|
26137
|
+
* are still in the mirror). The re-Add follows only from a later call,
|
|
26138
|
+
* after the Remove landed and the mirror dropped the device. */
|
|
26139
|
+
voiceCommitterWork(channelId, roster) {
|
|
26140
|
+
const mirror = this.membersByChannel.get(channelId) ?? [];
|
|
26141
|
+
const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
|
|
26142
|
+
const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
|
|
26143
|
+
return {
|
|
26144
|
+
adds: roster.filter((d) => !mirror.includes(d)),
|
|
26145
|
+
removes: [],
|
|
26146
|
+
removeDevices: signalled,
|
|
26147
|
+
removeDeviceLeaves: signalled.map((d) => ({
|
|
26148
|
+
deviceId: d,
|
|
26149
|
+
identityId: this.identityByDevice.get(d) ?? "",
|
|
26150
|
+
leafSignatureKeyHex: bindings.get(d).key
|
|
26151
|
+
}))
|
|
26152
|
+
};
|
|
26153
|
+
}
|
|
25615
26154
|
async acquireCommitLease(_channelId) {
|
|
25616
26155
|
return "fake-lease-token";
|
|
25617
26156
|
}
|
|
@@ -25704,6 +26243,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25704
26243
|
// Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
|
|
25705
26244
|
// the envelope's `identity_proof.signature` as `identity_signature` beside the
|
|
25706
26245
|
// unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
|
|
26246
|
+
/** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
|
|
26247
|
+
* the envelope's id. The REST send path keys the row by the envelope id
|
|
26248
|
+
* (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
|
|
26249
|
+
* path stores through `store_mls_message`, whose row id is server-generated,
|
|
26250
|
+
* so there the two differ and the envelope id is the sender's free choice. */
|
|
25707
26251
|
async ingestApplication(envelope, opts) {
|
|
25708
26252
|
const channelId = envelope.channel_id;
|
|
25709
26253
|
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
@@ -25715,7 +26259,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
25715
26259
|
continue;
|
|
25716
26260
|
this.q(dev).push({
|
|
25717
26261
|
queue_id: "q-" + this._seq++,
|
|
25718
|
-
message_id: envelope.message_id,
|
|
26262
|
+
message_id: opts?.rowMessageId ?? envelope.message_id,
|
|
25719
26263
|
group_id: groupId,
|
|
25720
26264
|
message_type: "application",
|
|
25721
26265
|
sender_device_id: senderDeviceId ?? "",
|
|
@@ -26099,6 +26643,7 @@ function openSchema(db) {
|
|
|
26099
26643
|
ensureRuntimeInboxDeliveryAttempts(db);
|
|
26100
26644
|
ensureRuntimeReplyIntent(db);
|
|
26101
26645
|
ensureRuntimeReplyIntentEnvelopeGroup(db);
|
|
26646
|
+
ensureRuntimeReplyIntentCredit(db);
|
|
26102
26647
|
ensureGroupLink(db);
|
|
26103
26648
|
ensureMlsCustody(db);
|
|
26104
26649
|
ensureToolGrantTables(db);
|
|
@@ -26179,6 +26724,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
|
|
|
26179
26724
|
throw e;
|
|
26180
26725
|
}
|
|
26181
26726
|
}
|
|
26727
|
+
function ensureRuntimeReplyIntentCredit(db) {
|
|
26728
|
+
const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
|
|
26729
|
+
const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
|
|
26730
|
+
if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
|
|
26731
|
+
return;
|
|
26732
|
+
try {
|
|
26733
|
+
db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
|
|
26734
|
+
} catch (e) {
|
|
26735
|
+
if (hasColumn())
|
|
26736
|
+
return;
|
|
26737
|
+
throw e;
|
|
26738
|
+
}
|
|
26739
|
+
}
|
|
26182
26740
|
var RECALL_STATE_COLUMNS = [
|
|
26183
26741
|
["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
|
|
26184
26742
|
["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
|
|
@@ -27230,8 +27788,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27230
27788
|
this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
|
|
27231
27789
|
const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
|
|
27232
27790
|
(identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27233
|
-
sealed_body, sealed_envelope, state, created_at, updated_at)
|
|
27234
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
|
|
27791
|
+
sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
|
|
27792
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
|
|
27235
27793
|
const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
|
|
27236
27794
|
if (count.n > RUNTIME_REPLY_INTENT_MAX) {
|
|
27237
27795
|
this.db.prepare(`DELETE FROM runtime_reply_intent
|
|
@@ -27241,7 +27799,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27241
27799
|
)`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
|
|
27242
27800
|
}
|
|
27243
27801
|
const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27244
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
27802
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
27245
27803
|
FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
|
|
27246
27804
|
if (!row)
|
|
27247
27805
|
throw new Error("reply intent vanished after claim");
|
|
@@ -27250,7 +27808,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27250
27808
|
/** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
|
|
27251
27809
|
async listReplyIntents(inReplyTo) {
|
|
27252
27810
|
const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27253
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
27811
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
27254
27812
|
FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
|
|
27255
27813
|
ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
|
|
27256
27814
|
const out = [];
|
|
@@ -27261,7 +27819,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27261
27819
|
/** SEC-06 #18: re-read one intent by its key (null when absent). */
|
|
27262
27820
|
async getReplyIntent(scope, part) {
|
|
27263
27821
|
const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27264
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
27822
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
27265
27823
|
FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
|
|
27266
27824
|
return row ? this.openReplyIntent(row) : null;
|
|
27267
27825
|
}
|
|
@@ -27335,7 +27893,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27335
27893
|
envelope,
|
|
27336
27894
|
envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
|
|
27337
27895
|
state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
|
|
27338
|
-
createdAt: Number(row.created_at)
|
|
27896
|
+
createdAt: Number(row.created_at),
|
|
27897
|
+
replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
|
|
27339
27898
|
};
|
|
27340
27899
|
}
|
|
27341
27900
|
async recordMemory(input) {
|
|
@@ -29105,8 +29664,8 @@ function decodeReplyBody(bytes) {
|
|
|
29105
29664
|
if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
|
|
29106
29665
|
throw new Error("reply intent body is malformed");
|
|
29107
29666
|
}
|
|
29108
|
-
function replyBodyMac(key, replyId, channelId, body) {
|
|
29109
|
-
return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
|
|
29667
|
+
function replyBodyMac(key, replyId, channelId, body, credit) {
|
|
29668
|
+
return createHmac("sha256", key).update(JSON.stringify(credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit])).update("\0").update(body).digest("hex");
|
|
29110
29669
|
}
|
|
29111
29670
|
function partOrdinal(part) {
|
|
29112
29671
|
const m = /^p:(\d+)$/.exec(part);
|
|
@@ -34328,6 +34887,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
|
|
|
34328
34887
|
}
|
|
34329
34888
|
const search = skillsJson.search;
|
|
34330
34889
|
if (search?.keyFile) paths.push(search.keyFile);
|
|
34890
|
+
const weather = skillsJson.weather;
|
|
34891
|
+
if (weather?.keyFile) paths.push(weather.keyFile);
|
|
34331
34892
|
return paths.map((path) => {
|
|
34332
34893
|
let st;
|
|
34333
34894
|
try {
|
|
@@ -34353,7 +34914,7 @@ function parseSecretPathsReport(raw) {
|
|
|
34353
34914
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
34354
34915
|
const r = raw;
|
|
34355
34916
|
const f = r.outside_by_field;
|
|
34356
|
-
if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
|
|
34917
|
+
if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite) || f.weather_key_file !== void 0 && !isCount(f.weather_key_file)) return void 0;
|
|
34357
34918
|
return {
|
|
34358
34919
|
mode: r.mode,
|
|
34359
34920
|
inside: r.inside,
|
|
@@ -34363,7 +34924,8 @@ function parseSecretPathsReport(raw) {
|
|
|
34363
34924
|
headers_file: f.headers_file,
|
|
34364
34925
|
postgres_url_file: f.postgres_url_file,
|
|
34365
34926
|
search_key_file: f.search_key_file,
|
|
34366
|
-
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
|
|
34927
|
+
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
|
|
34928
|
+
...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
|
|
34367
34929
|
}
|
|
34368
34930
|
};
|
|
34369
34931
|
}
|
|
@@ -34443,6 +35005,7 @@ function readLocalReport(home, identityId) {
|
|
|
34443
35005
|
has_search_key: hasSearchKey,
|
|
34444
35006
|
has_llm_key: hasLlmKey,
|
|
34445
35007
|
has_extract_provider: doc.has_extract_provider === true,
|
|
35008
|
+
has_weather_key: doc.has_weather_key === true,
|
|
34446
35009
|
...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
|
|
34447
35010
|
legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
|
|
34448
35011
|
secret_paths: parseSecretPathsReport(doc.secret_paths)
|
|
@@ -34544,6 +35107,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
|
|
|
34544
35107
|
return removed;
|
|
34545
35108
|
}
|
|
34546
35109
|
|
|
35110
|
+
// src/reply-credit.ts
|
|
35111
|
+
var REPLY_CREDIT_FEATURE = "reply_credit_v1";
|
|
35112
|
+
var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
|
|
35113
|
+
var WEATHER_CREDIT_SUFFIX = `
|
|
35114
|
+
|
|
35115
|
+
${WEATHER_CREDIT_LINE}`;
|
|
35116
|
+
function isReplyCredit(v) {
|
|
35117
|
+
return v === "weatherapi";
|
|
35118
|
+
}
|
|
35119
|
+
function stripCreditSuffix(text) {
|
|
35120
|
+
return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
|
|
35121
|
+
}
|
|
35122
|
+
|
|
34547
35123
|
// src/grants/binding.ts
|
|
34548
35124
|
import { sha256 as sha25611 } from "@noble/hashes/sha256";
|
|
34549
35125
|
|
|
@@ -34632,9 +35208,10 @@ function loadOwnerGrantPin(opts) {
|
|
|
34632
35208
|
// src/grants/manifest.ts
|
|
34633
35209
|
var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
|
|
34634
35210
|
var MANIFEST_KNOWN_TOOLS = Object.freeze({
|
|
34635
|
-
owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
|
|
35211
|
+
owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
|
|
34636
35212
|
// web_extract: owner DM only, never team_room (spec §5.4, D5)
|
|
34637
|
-
|
|
35213
|
+
// Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
|
|
35214
|
+
team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
|
|
34638
35215
|
});
|
|
34639
35216
|
function manifestSignedBytes(manifest) {
|
|
34640
35217
|
const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
|
|
@@ -34958,20 +35535,30 @@ function bindTargets(raw, signed, equals) {
|
|
|
34958
35535
|
}
|
|
34959
35536
|
return { written, mismatches };
|
|
34960
35537
|
}
|
|
34961
|
-
|
|
34962
|
-
|
|
35538
|
+
var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
|
|
35539
|
+
var TOOL_FEATURE = Object.freeze({
|
|
35540
|
+
web_extract: "search_providers_v2",
|
|
35541
|
+
weather_forecast: "utility_tools_v1",
|
|
35542
|
+
currency_convert: "utility_tools_v1"
|
|
35543
|
+
});
|
|
35544
|
+
function toolAllowedByFeatures(tool, features) {
|
|
35545
|
+
if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
|
|
35546
|
+
return features.has(TOOL_FEATURE[tool]);
|
|
35547
|
+
}
|
|
35548
|
+
function signedManifestTools(manifest, audience, features) {
|
|
35549
|
+
return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
|
|
34963
35550
|
}
|
|
34964
|
-
function manifestPolicyFor(manifest, switches,
|
|
35551
|
+
function manifestPolicyFor(manifest, switches, features) {
|
|
34965
35552
|
return {
|
|
34966
35553
|
v: 1,
|
|
34967
35554
|
manifest_tools: [.../* @__PURE__ */ new Set([
|
|
34968
|
-
...switches.ownerDm ? signedManifestTools(manifest, "owner_dm",
|
|
34969
|
-
...switches.room ? signedManifestTools(manifest, "team_room",
|
|
35555
|
+
...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
|
|
35556
|
+
...switches.room ? signedManifestTools(manifest, "team_room", features) : []
|
|
34970
35557
|
])].sort(),
|
|
34971
35558
|
secret_path_mode: "enforce"
|
|
34972
35559
|
};
|
|
34973
35560
|
}
|
|
34974
|
-
function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true },
|
|
35561
|
+
function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
|
|
34975
35562
|
const mismatches = /* @__PURE__ */ new Set();
|
|
34976
35563
|
const cfg = manifest.config;
|
|
34977
35564
|
const out = {};
|
|
@@ -35000,7 +35587,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35000
35587
|
for (const name of Object.keys(files)) {
|
|
35001
35588
|
if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
|
|
35002
35589
|
}
|
|
35003
|
-
const ownerDmTools = signedManifestTools(manifest, "owner_dm",
|
|
35590
|
+
const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
|
|
35591
|
+
const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
|
|
35004
35592
|
const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
|
|
35005
35593
|
const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
|
|
35006
35594
|
const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
|
|
@@ -35021,17 +35609,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35021
35609
|
const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
|
|
35022
35610
|
searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
|
|
35023
35611
|
}
|
|
35612
|
+
const pw = isObj2(sj?.weather) ? sj.weather : void 0;
|
|
35613
|
+
const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
|
|
35024
35614
|
const skillsDoc = {
|
|
35025
35615
|
endpoints: eps.written,
|
|
35026
35616
|
databases: dbs.written,
|
|
35027
35617
|
flags: [...cfg.flags],
|
|
35028
35618
|
...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
|
|
35029
35619
|
...searchBlock ? { search: searchBlock } : {},
|
|
35620
|
+
...weatherBlock ? { weather: weatherBlock } : {},
|
|
35030
35621
|
// D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
|
|
35031
35622
|
search_signed: cfg.search !== void 0,
|
|
35032
35623
|
// D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
|
|
35033
35624
|
// otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
|
|
35034
|
-
...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm &&
|
|
35625
|
+
...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
|
|
35035
35626
|
};
|
|
35036
35627
|
out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
|
|
35037
35628
|
const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
|
|
@@ -35062,7 +35653,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35062
35653
|
mismatches.add("legacy_assign");
|
|
35063
35654
|
legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
|
|
35064
35655
|
}
|
|
35065
|
-
const policy = manifestPolicyFor(manifest, switches,
|
|
35656
|
+
const policy = manifestPolicyFor(manifest, switches, features);
|
|
35066
35657
|
return {
|
|
35067
35658
|
mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
|
|
35068
35659
|
targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
|
|
@@ -35205,7 +35796,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
|
|
|
35205
35796
|
// D5: spends the owner's key and sends URLs out
|
|
35206
35797
|
fetch_url: Object.freeze(["egress"]),
|
|
35207
35798
|
sql_query: Object.freeze(["owner_credential"]),
|
|
35208
|
-
http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
|
|
35799
|
+
http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
|
|
35800
|
+
// Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
|
|
35801
|
+
// spends the owner's key.
|
|
35802
|
+
weather_forecast: Object.freeze(["owner_credential", "egress"]),
|
|
35803
|
+
currency_convert: Object.freeze(["egress"])
|
|
35209
35804
|
});
|
|
35210
35805
|
var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
|
|
35211
35806
|
function isLiveRow(r) {
|
|
@@ -37019,7 +37614,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37019
37614
|
}
|
|
37020
37615
|
let bound;
|
|
37021
37616
|
try {
|
|
37022
|
-
bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.
|
|
37617
|
+
bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
|
|
37023
37618
|
} catch (e) {
|
|
37024
37619
|
bound = null;
|
|
37025
37620
|
return this.bindFailed(manifest, payload.memory, e);
|
|
@@ -37040,7 +37635,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37040
37635
|
try {
|
|
37041
37636
|
this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
|
|
37042
37637
|
if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
|
|
37043
|
-
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.
|
|
37638
|
+
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
|
|
37044
37639
|
} catch (e) {
|
|
37045
37640
|
this.manifestBinding = null;
|
|
37046
37641
|
throw e;
|
|
@@ -37094,6 +37689,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37094
37689
|
async postRuntimeConfigReport(base, version, skillsJson) {
|
|
37095
37690
|
const local = readLocalReport(this.home, this.cfg.identityId);
|
|
37096
37691
|
const reportedSearchProviders = this.searchProvidersCapable();
|
|
37692
|
+
const reportedUtilityTools = this.utilityToolsCapable();
|
|
37097
37693
|
const report = {
|
|
37098
37694
|
applied_version: version,
|
|
37099
37695
|
paths: checkConfiguredPaths(skillsJson),
|
|
@@ -37111,6 +37707,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37111
37707
|
// Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
|
|
37112
37708
|
// the bridge withholds web_extract from the runtime.
|
|
37113
37709
|
has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
|
|
37710
|
+
// Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
|
|
37711
|
+
// while the bridge withholds weather_forecast from the runtime.
|
|
37712
|
+
has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
|
|
37114
37713
|
// D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
|
|
37115
37714
|
...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
|
|
37116
37715
|
// Same idea, for the BYOK LLM key file: only the runtime can see
|
|
@@ -37158,7 +37757,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37158
37757
|
// D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
|
|
37159
37758
|
search_providers_v2: reportedSearchProviders,
|
|
37160
37759
|
// D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
|
|
37161
|
-
search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
|
|
37760
|
+
search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
|
|
37761
|
+
// Weather/currency §8 item 5: a separate key, false with no runtime attached.
|
|
37762
|
+
utility_tools_v1: reportedUtilityTools
|
|
37162
37763
|
}
|
|
37163
37764
|
};
|
|
37164
37765
|
const reportedVerifyFailures = this.ownerVerifyFailures;
|
|
@@ -37180,6 +37781,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37180
37781
|
}
|
|
37181
37782
|
this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
|
|
37182
37783
|
this.lastReportedSearchProviders = reportedSearchProviders;
|
|
37784
|
+
this.lastReportedUtilityTools = reportedUtilityTools;
|
|
37183
37785
|
if (version) writeServiceLog2("info", "runtime_config.applied", { version });
|
|
37184
37786
|
else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
|
|
37185
37787
|
}
|
|
@@ -37249,7 +37851,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37249
37851
|
this.startedAt = Date.now();
|
|
37250
37852
|
await this.restoreRuntimeInbox();
|
|
37251
37853
|
await this.loadToolGrantState();
|
|
37252
|
-
this.recomputeManifestPolicy(
|
|
37854
|
+
this.recomputeManifestPolicy(NO_FEATURES);
|
|
37253
37855
|
try {
|
|
37254
37856
|
this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
|
|
37255
37857
|
} catch (e) {
|
|
@@ -38232,13 +38834,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38232
38834
|
* absent, because the id is derived.
|
|
38233
38835
|
*/
|
|
38234
38836
|
async send(channelId, text, messageId, opts = {}) {
|
|
38235
|
-
const body = { kind: "text", text };
|
|
38837
|
+
const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
|
|
38236
38838
|
if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
|
|
38237
38839
|
const id = resolveMessageId(messageId);
|
|
38238
38840
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
|
|
38239
38841
|
const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
|
|
38240
38842
|
await this.publishBody(channelId, body, id);
|
|
38241
|
-
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
|
|
38843
|
+
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
|
|
38242
38844
|
return { message_id: id, status: "sent" };
|
|
38243
38845
|
}
|
|
38244
38846
|
/** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
|
|
@@ -38257,7 +38859,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38257
38859
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
|
|
38258
38860
|
const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
|
|
38259
38861
|
await this.publishBody(channelId, body, id);
|
|
38260
|
-
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
|
|
38862
|
+
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
|
|
38261
38863
|
return { message_id: id, status: "sent" };
|
|
38262
38864
|
}
|
|
38263
38865
|
/**
|
|
@@ -38390,18 +38992,24 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38390
38992
|
const meta = this.freshMeta(capChannel);
|
|
38391
38993
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
|
|
38392
38994
|
}
|
|
38393
|
-
/**
|
|
38394
|
-
|
|
38995
|
+
/**
|
|
38996
|
+
* Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
|
|
38997
|
+
* reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
|
|
38998
|
+
* `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
|
|
38999
|
+
* undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
|
|
39000
|
+
*/
|
|
39001
|
+
afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
|
|
39002
|
+
const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
|
|
38395
39003
|
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
38396
39004
|
const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
|
|
38397
|
-
const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(
|
|
39005
|
+
const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
|
|
38398
39006
|
if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
|
|
38399
|
-
if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(
|
|
38400
|
-
this.callBuffer.appendAgent(channelId,
|
|
39007
|
+
if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
|
|
39008
|
+
this.callBuffer.appendAgent(channelId, recorded, "reply");
|
|
38401
39009
|
}
|
|
38402
39010
|
const origin = this.spokenReplyIds.has(id) ? "fast_path" : !this.taintScope(channelId) ? "reply" : opts.inReplyTo !== void 0 && this.cleanTurns.has(opts.inReplyTo) && !this.taintedTurns.has(opts.inReplyTo) ? "reply" : "untrusted";
|
|
38403
|
-
if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId,
|
|
38404
|
-
if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId,
|
|
39011
|
+
if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
|
|
39012
|
+
if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
|
|
38405
39013
|
}
|
|
38406
39014
|
/**
|
|
38407
39015
|
* Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
|
|
@@ -38479,14 +39087,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38479
39087
|
const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
|
|
38480
39088
|
if (!k) throw new Error("idempotent send without a key");
|
|
38481
39089
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
|
|
38482
|
-
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
|
|
38483
|
-
const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
|
|
39090
|
+
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
|
|
39091
|
+
const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
|
|
38484
39092
|
if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
|
|
38485
39093
|
throw new IdempotencyConflictError();
|
|
38486
39094
|
}
|
|
38487
39095
|
return k;
|
|
38488
39096
|
}
|
|
38489
|
-
return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
|
|
39097
|
+
return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
|
|
38490
39098
|
});
|
|
38491
39099
|
return this.publishIntent(key, opts);
|
|
38492
39100
|
}
|
|
@@ -38495,22 +39103,23 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38495
39103
|
* the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
|
|
38496
39104
|
* after a slot collision: the body must go to a NEW part.
|
|
38497
39105
|
*/
|
|
38498
|
-
async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
|
|
39106
|
+
async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
|
|
39107
|
+
const credit = replyCredit ?? void 0;
|
|
38499
39108
|
const scope = `reply:${inReplyTo}`;
|
|
38500
39109
|
let latest = null;
|
|
38501
39110
|
for (const intent of await this.store.listReplyIntents(inReplyTo)) {
|
|
38502
39111
|
const n = intent.scope === scope ? partOrdinal(intent.part) : null;
|
|
38503
39112
|
if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
|
|
38504
39113
|
}
|
|
38505
|
-
if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
|
|
39114
|
+
if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
|
|
38506
39115
|
return { scope, part: latest.intent.part, inReplyTo };
|
|
38507
39116
|
}
|
|
38508
39117
|
let ordinal = latest ? latest.ordinal + 1 : 0;
|
|
38509
39118
|
for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
|
|
38510
39119
|
const k = { scope, part: `p:${ordinal}`, inReplyTo };
|
|
38511
39120
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
|
|
38512
|
-
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
|
|
38513
|
-
const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
|
|
39121
|
+
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
|
|
39122
|
+
const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
|
|
38514
39123
|
if (created) return k;
|
|
38515
39124
|
if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
|
|
38516
39125
|
writeServiceLog2("info", "reply.part_slot_taken", { attempt: i + 1 });
|
|
@@ -38555,6 +39164,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38555
39164
|
}
|
|
38556
39165
|
const bytes = intent.body;
|
|
38557
39166
|
const body = decodeReplyBody(bytes);
|
|
39167
|
+
const recordText = this.recordTextOf(intent.replyCredit, body);
|
|
39168
|
+
if (recordText === void 0) {
|
|
39169
|
+
writeServiceLog2("error", "reply.intent_integrity_refused");
|
|
39170
|
+
this.store.deletePendingReplyIntent(key.scope, key.part);
|
|
39171
|
+
throw new ReplyIntentIntegrityError();
|
|
39172
|
+
}
|
|
38558
39173
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
|
|
38559
39174
|
const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
|
|
38560
39175
|
try {
|
|
@@ -38583,14 +39198,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38583
39198
|
const inReplyTo = intent.inReplyTo;
|
|
38584
39199
|
const next = await this.withReplyLock(
|
|
38585
39200
|
JSON.stringify(["scope", key.scope]),
|
|
38586
|
-
() =>
|
|
39201
|
+
() => (
|
|
39202
|
+
// r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
|
|
39203
|
+
this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
|
|
39204
|
+
)
|
|
38587
39205
|
);
|
|
38588
39206
|
return this.publishIntent(next, opts, advances + 1);
|
|
38589
39207
|
}
|
|
38590
39208
|
throw e;
|
|
38591
39209
|
}
|
|
38592
39210
|
this.store.markReplyIntentSent(key.scope, key.part);
|
|
38593
|
-
this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
|
|
39211
|
+
this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
|
|
38594
39212
|
return { message_id: intent.replyMessageId, status: "sent" };
|
|
38595
39213
|
});
|
|
38596
39214
|
}
|
|
@@ -38600,9 +39218,21 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38600
39218
|
*/
|
|
38601
39219
|
intentVerifies(intent) {
|
|
38602
39220
|
if (!intent.body) return false;
|
|
39221
|
+
const credit = intent.replyCredit ?? null;
|
|
39222
|
+
if (credit !== null && !isReplyCredit(credit)) return false;
|
|
38603
39223
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
|
|
38604
39224
|
if (id !== intent.replyMessageId) return false;
|
|
38605
|
-
return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
|
|
39225
|
+
return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
|
|
39226
|
+
}
|
|
39227
|
+
/**
|
|
39228
|
+
* Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
|
|
39229
|
+
* for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
|
|
39230
|
+
* credit, a credited a2a body, or a credited text without the exact suffix).
|
|
39231
|
+
*/
|
|
39232
|
+
recordTextOf(credit, body) {
|
|
39233
|
+
if (credit === null) return body.kind === "text" ? body.text : null;
|
|
39234
|
+
if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
|
|
39235
|
+
return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
|
|
38606
39236
|
}
|
|
38607
39237
|
/**
|
|
38608
39238
|
* SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
|
|
@@ -40009,6 +40639,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40009
40639
|
* frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
|
|
40010
40640
|
* 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;
|
|
40011
40641
|
* otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
|
|
40642
|
+
*
|
|
40643
|
+
* Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
|
|
40644
|
+
* per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
|
|
40645
|
+
* channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
|
|
40646
|
+
* inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
|
|
40647
|
+
* persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
|
|
40012
40648
|
*/
|
|
40013
40649
|
async tapUnifiedVoice(m, c) {
|
|
40014
40650
|
const skip = (reason) => this.logUnifiedTapSkip(reason);
|
|
@@ -40017,9 +40653,22 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40017
40653
|
if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
|
|
40018
40654
|
if (c.stamp === void 0) return skip("stamp_missing");
|
|
40019
40655
|
if (c.capture.purgeEpoch < 0) return skip("purged");
|
|
40020
|
-
const
|
|
40656
|
+
const parent3 = c.parent;
|
|
40657
|
+
const parentCapture = parent3?.capture;
|
|
40658
|
+
if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
|
|
40659
|
+
if (parent3 !== void 0) {
|
|
40660
|
+
if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
|
|
40661
|
+
if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
|
|
40662
|
+
}
|
|
40663
|
+
const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
|
|
40021
40664
|
if (scopeId === null) return skip("scope");
|
|
40022
40665
|
if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
|
|
40666
|
+
const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
|
|
40667
|
+
const now = this.store.itemCapture(parent3.channelId);
|
|
40668
|
+
if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
|
|
40669
|
+
if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
|
|
40670
|
+
return null;
|
|
40671
|
+
};
|
|
40023
40672
|
const r = await this.items.commit({
|
|
40024
40673
|
scopeKind: "owner_dm",
|
|
40025
40674
|
scopeId,
|
|
@@ -40030,7 +40679,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40030
40679
|
source: "owner_voice",
|
|
40031
40680
|
trust: OWNER_VOICE_TRUST,
|
|
40032
40681
|
text: typeof m.text === "string" ? m.text : "",
|
|
40033
|
-
expect: { ...c.capture, clearGeneration: c.stamp }
|
|
40682
|
+
expect: { ...c.capture, clearGeneration: c.stamp },
|
|
40683
|
+
...precommit ? { precommit } : {}
|
|
40034
40684
|
});
|
|
40035
40685
|
return r.ok ? { ok: true } : { ok: false, reason: r.reason };
|
|
40036
40686
|
} catch {
|
|
@@ -42209,11 +42859,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42209
42859
|
const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
|
|
42210
42860
|
const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
|
|
42211
42861
|
const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
|
|
42862
|
+
const unifiedVoiceParentAt = (p) => {
|
|
42863
|
+
const cap = itemCaptureAtReceipt.get(p);
|
|
42864
|
+
return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
|
|
42865
|
+
};
|
|
42212
42866
|
for (const m of entries) {
|
|
42213
42867
|
this.lastInboundAt.set(m.channelId, receivedAt);
|
|
42214
42868
|
if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
|
|
42215
42869
|
if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
|
|
42216
42870
|
if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
|
|
42871
|
+
const receiptParent = this.voiceChannels.parentOf(m.channelId);
|
|
42872
|
+
if (receiptParent !== void 0) {
|
|
42873
|
+
if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
|
|
42874
|
+
if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
|
|
42875
|
+
}
|
|
42217
42876
|
this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
|
|
42218
42877
|
receiptIds.push(m.messageId);
|
|
42219
42878
|
const stamp = voiceStamp.get(m.channelId);
|
|
@@ -42559,6 +43218,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42559
43218
|
pending: Promise.resolve(),
|
|
42560
43219
|
purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
|
|
42561
43220
|
itemCapture: itemCaptureAtReceipt.get(m.channelId),
|
|
43221
|
+
...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
|
|
42562
43222
|
bufferGeneration
|
|
42563
43223
|
};
|
|
42564
43224
|
this.voiceCoalescing.set(windowKey, win);
|
|
@@ -42589,7 +43249,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42589
43249
|
const merged = { ...held, text, memory: windowMemory };
|
|
42590
43250
|
if (this.unifiedOn) {
|
|
42591
43251
|
const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
|
|
42592
|
-
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
|
|
43252
|
+
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
|
|
42593
43253
|
this.unifiedWork.add(work);
|
|
42594
43254
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42595
43255
|
}
|
|
@@ -42679,7 +43339,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42679
43339
|
);
|
|
42680
43340
|
if (windowEligible && turnAuthorization.relay_speaker === "owner") {
|
|
42681
43341
|
const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
|
|
42682
|
-
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43342
|
+
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43343
|
+
stamp: this.ingestGeneration.get(m.messageId),
|
|
43344
|
+
capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
|
|
43345
|
+
...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
|
|
43346
|
+
});
|
|
42683
43347
|
this.unifiedWork.add(work);
|
|
42684
43348
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42685
43349
|
}
|
|
@@ -42692,7 +43356,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42692
43356
|
this.memory,
|
|
42693
43357
|
m.channelId,
|
|
42694
43358
|
m.envelope.sender_id,
|
|
42695
|
-
parsed.text,
|
|
43359
|
+
m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
|
|
42696
43360
|
m.messageId,
|
|
42697
43361
|
m.voiceMode ? "voice" : "text",
|
|
42698
43362
|
purgeEpochAtTap
|
|
@@ -43490,21 +44154,49 @@ ${channelId}`;
|
|
|
43490
44154
|
*/
|
|
43491
44155
|
searchProvidersSockets = /* @__PURE__ */ new Map();
|
|
43492
44156
|
/**
|
|
43493
|
-
*
|
|
43494
|
-
*
|
|
44157
|
+
* Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
|
|
44158
|
+
* runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
|
|
44159
|
+
* sees a utility name.
|
|
44160
|
+
*/
|
|
44161
|
+
utilityToolsSockets = /* @__PURE__ */ new Map();
|
|
44162
|
+
/**
|
|
44163
|
+
* The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
|
|
44164
|
+
* observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
|
|
43495
44165
|
*/
|
|
43496
|
-
|
|
44166
|
+
capabilityState = null;
|
|
43497
44167
|
searchCapabilityRefetchTimer = null;
|
|
43498
44168
|
/** When the current burst's first change happened (valid while the timer is set). */
|
|
43499
44169
|
searchCapabilityBurstStart = 0;
|
|
43500
44170
|
/** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
|
|
43501
44171
|
lastReportedSearchProviders = null;
|
|
44172
|
+
/** U6: the utility_tools_v1 value in the last report posted (null until one is). */
|
|
44173
|
+
lastReportedUtilityTools = null;
|
|
43502
44174
|
noteSearchProvidersSocket(token, declared) {
|
|
43503
44175
|
this.searchProvidersSockets.set(token, declared);
|
|
43504
|
-
this.
|
|
44176
|
+
this.onCapabilityMaybeChanged();
|
|
43505
44177
|
}
|
|
43506
44178
|
clearSearchProvidersSocket(token) {
|
|
43507
|
-
if (this.searchProvidersSockets.delete(token)) this.
|
|
44179
|
+
if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
|
|
44180
|
+
}
|
|
44181
|
+
noteUtilityToolsSocket(token, declared) {
|
|
44182
|
+
this.utilityToolsSockets.set(token, declared);
|
|
44183
|
+
this.onCapabilityMaybeChanged();
|
|
44184
|
+
}
|
|
44185
|
+
clearUtilityToolsSocket(token) {
|
|
44186
|
+
if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
|
|
44187
|
+
}
|
|
44188
|
+
/** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
|
|
44189
|
+
utilityToolsCapable() {
|
|
44190
|
+
if (this.utilityToolsSockets.size === 0) return false;
|
|
44191
|
+
for (const v of this.utilityToolsSockets.values()) if (!v) return false;
|
|
44192
|
+
return true;
|
|
44193
|
+
}
|
|
44194
|
+
/** U6: the features every attached runtime socket declared, for the policy functions. */
|
|
44195
|
+
capabilityFeatures() {
|
|
44196
|
+
const out = /* @__PURE__ */ new Set();
|
|
44197
|
+
if (this.searchProvidersCapable()) out.add("search_providers_v2");
|
|
44198
|
+
if (this.utilityToolsCapable()) out.add("utility_tools_v1");
|
|
44199
|
+
return out;
|
|
43508
44200
|
}
|
|
43509
44201
|
/** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
|
|
43510
44202
|
searchProvidersCapable() {
|
|
@@ -43531,14 +44223,14 @@ ${channelId}`;
|
|
|
43531
44223
|
* D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
|
|
43532
44224
|
* switches and the new capability, then a debounced refetch (one per burst) only when needed.
|
|
43533
44225
|
*/
|
|
43534
|
-
|
|
43535
|
-
const
|
|
43536
|
-
const prev = this.
|
|
43537
|
-
if (
|
|
43538
|
-
this.
|
|
43539
|
-
this.recomputeManifestPolicy(
|
|
44226
|
+
onCapabilityMaybeChanged() {
|
|
44227
|
+
const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
|
|
44228
|
+
const prev = this.capabilityState;
|
|
44229
|
+
if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
|
|
44230
|
+
this.capabilityState = next;
|
|
44231
|
+
this.recomputeManifestPolicy(this.capabilityFeatures());
|
|
43540
44232
|
if (this.closingDown) return;
|
|
43541
|
-
if (prev === null && !
|
|
44233
|
+
if (prev === null && !next.search && !next.utility) return;
|
|
43542
44234
|
const now = Date.now();
|
|
43543
44235
|
if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
|
|
43544
44236
|
else this.searchCapabilityBurstStart = now;
|
|
@@ -43556,7 +44248,7 @@ ${channelId}`;
|
|
|
43556
44248
|
searchCapabilityRefetchNeeded() {
|
|
43557
44249
|
if (!this.bootConfigSettled || this.closingDown) return false;
|
|
43558
44250
|
const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
|
|
43559
|
-
return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
|
|
44251
|
+
return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
|
|
43560
44252
|
}
|
|
43561
44253
|
/**
|
|
43562
44254
|
* D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
|
|
@@ -43564,15 +44256,16 @@ ${channelId}`;
|
|
|
43564
44256
|
* lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
|
|
43565
44257
|
* Content-free; a write failure is logged and the debounced refetch tries again.
|
|
43566
44258
|
*/
|
|
43567
|
-
recomputeManifestPolicy(
|
|
44259
|
+
recomputeManifestPolicy(features) {
|
|
43568
44260
|
const pin = this.cfg.ownerGrantPin?.pin ?? null;
|
|
43569
44261
|
const manifest = pin !== null ? this.toolGrantManifest : null;
|
|
43570
44262
|
try {
|
|
43571
44263
|
if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
|
|
43572
|
-
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants },
|
|
44264
|
+
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
|
|
43573
44265
|
return;
|
|
43574
44266
|
}
|
|
43575
|
-
|
|
44267
|
+
const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
|
|
44268
|
+
if (withheld.size === 0) return;
|
|
43576
44269
|
const path = join17(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
|
|
43577
44270
|
if (!existsSync5(path)) return;
|
|
43578
44271
|
let doc;
|
|
@@ -43582,10 +44275,10 @@ ${channelId}`;
|
|
|
43582
44275
|
return;
|
|
43583
44276
|
}
|
|
43584
44277
|
const tools = doc?.manifest_tools;
|
|
43585
|
-
if (!Array.isArray(tools) || !tools.
|
|
44278
|
+
if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
|
|
43586
44279
|
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
|
|
43587
44280
|
v: 1,
|
|
43588
|
-
manifest_tools: tools.filter((t) => typeof t === "string" && t
|
|
44281
|
+
manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
|
|
43589
44282
|
secret_path_mode: "enforce"
|
|
43590
44283
|
});
|
|
43591
44284
|
} catch (e) {
|
|
@@ -44122,6 +44815,9 @@ var BRIDGE_ERRORS = {
|
|
|
44122
44815
|
// C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
|
|
44123
44816
|
// session (WS 4409), so this copy stopped. Every local route but /healthz.
|
|
44124
44817
|
session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
|
|
44818
|
+
// Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
|
|
44819
|
+
// the inbound is retired, so the model (and the weather call) never runs again for it.
|
|
44820
|
+
reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
|
|
44125
44821
|
internal: { http: 500, message: "internal error" }
|
|
44126
44822
|
};
|
|
44127
44823
|
function errorBody(code, detail) {
|
|
@@ -44151,6 +44847,7 @@ function withConsumerGrants(m, perToolConsumer) {
|
|
|
44151
44847
|
}
|
|
44152
44848
|
var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
|
|
44153
44849
|
var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
|
|
44850
|
+
var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
|
|
44154
44851
|
var CONSTANT_TOOL_NAMES = Object.freeze({
|
|
44155
44852
|
owner_dm: Object.freeze(["current_time", "web_search"]),
|
|
44156
44853
|
team_room: Object.freeze(["current_time"])
|
|
@@ -44182,6 +44879,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
|
|
|
44182
44879
|
if (kept.length === a.tool_grants.length) return m;
|
|
44183
44880
|
return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
|
|
44184
44881
|
}
|
|
44882
|
+
var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
|
|
44883
|
+
function withUtilityCapability(m, consumerHasFeature) {
|
|
44884
|
+
if (consumerHasFeature) return m;
|
|
44885
|
+
const a = m.authorization;
|
|
44886
|
+
if (!a) return m;
|
|
44887
|
+
const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
|
|
44888
|
+
if (kept.length === a.tool_grants.length) return m;
|
|
44889
|
+
return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
|
|
44890
|
+
}
|
|
44185
44891
|
|
|
44186
44892
|
// src/http-server.ts
|
|
44187
44893
|
function writeSse(res, msgs, onWriteError) {
|
|
@@ -44205,7 +44911,7 @@ data: ${JSON.stringify(msgs[i])}
|
|
|
44205
44911
|
function deliverSseBatch(res, batch, agent, onWriteError) {
|
|
44206
44912
|
const withheld = agent.harnessGrantsWithheld();
|
|
44207
44913
|
const provider = agent.signedSearchProvider();
|
|
44208
|
-
const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
|
|
44914
|
+
const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
|
|
44209
44915
|
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
44210
44916
|
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
44211
44917
|
if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
|
|
@@ -44237,7 +44943,7 @@ var OTHER_CAP = 8 * 1024;
|
|
|
44237
44943
|
var a2aSchema = z3.object({
|
|
44238
44944
|
a2a_version: z3.string().min(1),
|
|
44239
44945
|
kind: z3.enum(["message", "task", "artifact"]),
|
|
44240
|
-
object: z3.record(z3.unknown())
|
|
44946
|
+
object: z3.record(z3.string(), z3.unknown())
|
|
44241
44947
|
});
|
|
44242
44948
|
var sendAttachmentSchema = z3.object({
|
|
44243
44949
|
channel_id: z3.string().min(1),
|
|
@@ -44479,7 +45185,7 @@ function createHttpServer(agent, opts) {
|
|
|
44479
45185
|
const drained = await agent.drain(channel, wait);
|
|
44480
45186
|
const withheld = agent.harnessGrantsWithheld();
|
|
44481
45187
|
const provider = agent.signedSearchProvider();
|
|
44482
|
-
return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
|
|
45188
|
+
return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
|
|
44483
45189
|
}
|
|
44484
45190
|
if (method === "GET" && path === "/v1/events") {
|
|
44485
45191
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
@@ -44654,6 +45360,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44654
45360
|
perToolGrants: false,
|
|
44655
45361
|
manifestGrants: false,
|
|
44656
45362
|
searchProviders: false,
|
|
45363
|
+
utilityTools: false,
|
|
44657
45364
|
// helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
|
|
44658
45365
|
deliveryOpen: helloWaitMs <= 0,
|
|
44659
45366
|
deliveryToken: /* @__PURE__ */ new Map(),
|
|
@@ -44707,14 +45414,14 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44707
45414
|
continue;
|
|
44708
45415
|
}
|
|
44709
45416
|
const out = withConsumerNotes(
|
|
44710
|
-
withSearchCapability(
|
|
45417
|
+
withUtilityCapability(withSearchCapability(
|
|
44711
45418
|
withManifestCapability(
|
|
44712
45419
|
withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
|
|
44713
45420
|
session.manifestGrants
|
|
44714
45421
|
),
|
|
44715
45422
|
session.searchProviders,
|
|
44716
45423
|
agent.signedSearchProvider()
|
|
44717
|
-
),
|
|
45424
|
+
), session.utilityTools),
|
|
44718
45425
|
batch[i],
|
|
44719
45426
|
session,
|
|
44720
45427
|
agent
|
|
@@ -44771,6 +45478,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44771
45478
|
if (session.perToolGrants) agent.clearPerToolConsumer(session);
|
|
44772
45479
|
if (session.manifestGrants) agent.clearManifestConsumer(session);
|
|
44773
45480
|
agent.clearSearchProvidersSocket(session);
|
|
45481
|
+
agent.clearUtilityToolsSocket(session);
|
|
44774
45482
|
};
|
|
44775
45483
|
current = { sock, cleanup, sweepStale };
|
|
44776
45484
|
sock.on("close", cleanup);
|
|
@@ -44811,7 +45519,10 @@ function sendJson(sock, body) {
|
|
|
44811
45519
|
} catch {
|
|
44812
45520
|
}
|
|
44813
45521
|
}
|
|
45522
|
+
var ReplyCreditUnknownError = class extends Error {
|
|
45523
|
+
};
|
|
44814
45524
|
function sendErrorCode(e) {
|
|
45525
|
+
if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
|
|
44815
45526
|
if (e instanceof ChannelPendingError) return "channel_pending";
|
|
44816
45527
|
if (e instanceof RateLimitedError) return "rate_limited";
|
|
44817
45528
|
if (e instanceof AgentPausedSendError) return "agent_paused";
|
|
@@ -44872,6 +45583,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
44872
45583
|
agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
|
|
44873
45584
|
session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
|
|
44874
45585
|
agent.noteSearchProvidersSocket(session, session.searchProviders);
|
|
45586
|
+
session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
|
|
45587
|
+
agent.noteUtilityToolsSocket(session, session.utilityTools);
|
|
44875
45588
|
if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
|
|
44876
45589
|
agent.noteNotesConsumer();
|
|
44877
45590
|
}
|
|
@@ -44879,7 +45592,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
44879
45592
|
if (session.recallConsumer) agent.noteRecallConsumer();
|
|
44880
45593
|
session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
|
|
44881
45594
|
if (session.unifiedConsumer) agent.noteUnifiedConsumer();
|
|
44882
|
-
sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
|
|
45595
|
+
sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE] });
|
|
44883
45596
|
if (!session.deliveryOpen) {
|
|
44884
45597
|
session.deliveryOpen = true;
|
|
44885
45598
|
flush();
|
|
@@ -44908,7 +45621,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
44908
45621
|
}
|
|
44909
45622
|
const replyFailed = (e, inReplyTo) => {
|
|
44910
45623
|
const code = sendErrorCode(e);
|
|
44911
|
-
|
|
45624
|
+
const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
|
|
45625
|
+
if (terminal && typeof inReplyTo === "string") {
|
|
44912
45626
|
inflight.delete(inReplyTo);
|
|
44913
45627
|
session.deliveryToken.delete(inReplyTo);
|
|
44914
45628
|
session.shownRecall.delete(inReplyTo);
|
|
@@ -44916,7 +45630,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
44916
45630
|
agent.ackRuntimeMessages([inReplyTo]);
|
|
44917
45631
|
}
|
|
44918
45632
|
sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
|
|
44919
|
-
if (
|
|
45633
|
+
if (terminal) flush();
|
|
44920
45634
|
};
|
|
44921
45635
|
if (m.type === "send") {
|
|
44922
45636
|
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
@@ -44933,8 +45647,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
44933
45647
|
}
|
|
44934
45648
|
const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
|
|
44935
45649
|
const callBufferReply = session.consumer === "runtime" && session.perToolGrants === true && session.harnessClaimed !== true && typeof m.in_reply_to === "string" && record !== void 0 && session.deliveryToken.get(m.in_reply_to) !== void 0 && record.channel_id === m.channel_id && !agent.isRuntimeMessageRetired(m.in_reply_to);
|
|
45650
|
+
if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
|
|
45651
|
+
writeServiceLog2("error", "reply.credit_unknown");
|
|
45652
|
+
replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
|
|
45653
|
+
return;
|
|
45654
|
+
}
|
|
44936
45655
|
const sendOpts = {
|
|
44937
45656
|
agentTurn: isAgentTurn(agent, m),
|
|
45657
|
+
...isReplyCredit(m.credit) ? { credit: m.credit } : {},
|
|
44938
45658
|
...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
|
|
44939
45659
|
...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
|
|
44940
45660
|
...callBufferReply ? { callBufferReply: true } : {}
|