@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/bin.js
CHANGED
|
@@ -18223,7 +18223,17 @@ var CODES = [
|
|
|
18223
18223
|
// missed and before any write, so nothing landed. Definite: the committer's
|
|
18224
18224
|
// work cannot name an agent in a humans-only enclave (admission refuses
|
|
18225
18225
|
// one), so dropping the commit starts no resend loop.
|
|
18226
|
-
{ status: 403, code: "humans_only_enclave" }
|
|
18226
|
+
{ status: 403, code: "humans_only_enclave" },
|
|
18227
|
+
// ADR-0024 step 2a PR-E (carry-A ruling §3 A): a V Add or Welcome whose
|
|
18228
|
+
// leaf-key claim map is missing, does not cover exactly the Welcome
|
|
18229
|
+
// targets, or names a key that is not the target's current vendable
|
|
18230
|
+
// KeyPackage's. Refused before any write; definite, so the committer drops
|
|
18231
|
+
// the commit, refetches the KeyPackages and rebuilds instead of resending
|
|
18232
|
+
// the same stale bytes forever.
|
|
18233
|
+
{ status: 409, code: "voice_stale_key_package" },
|
|
18234
|
+
// PR-E: a Welcome target already holds a bound leaf in this V group (the
|
|
18235
|
+
// DS-level duplicate-leaf guard). Nothing written; a resend cannot land.
|
|
18236
|
+
{ status: 409, code: "voice_leaf_already_bound" }
|
|
18227
18237
|
];
|
|
18228
18238
|
var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
|
|
18229
18239
|
var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
|
|
@@ -19270,6 +19280,31 @@ function ownLeafIndexOf(mgr) {
|
|
|
19270
19280
|
function tagOf(mgr) {
|
|
19271
19281
|
return { groupId: new TextDecoder().decode(mgr.groupId), epoch: Number(mgr.epoch) };
|
|
19272
19282
|
}
|
|
19283
|
+
function leafKeyClaims(targets, pkgs) {
|
|
19284
|
+
if (targets.length !== pkgs.length)
|
|
19285
|
+
throw new Error("leafKeyClaims: targets and KeyPackages differ in length");
|
|
19286
|
+
const out = {};
|
|
19287
|
+
targets.forEach((d, i) => {
|
|
19288
|
+
out[d] = bytesToHex22(pkgs[i].leafNode.signaturePublicKey);
|
|
19289
|
+
});
|
|
19290
|
+
return out;
|
|
19291
|
+
}
|
|
19292
|
+
function leafIndicesForSignatureKey(mgr, keyHex) {
|
|
19293
|
+
const tree = mgr.state?.ratchetTree;
|
|
19294
|
+
if (!tree)
|
|
19295
|
+
return [];
|
|
19296
|
+
const want = keyHex.toLowerCase();
|
|
19297
|
+
const out = [];
|
|
19298
|
+
const numLeaves = Math.floor((tree.length + 1) / 2);
|
|
19299
|
+
for (let i = 0; i < numLeaves; i++) {
|
|
19300
|
+
const node = tree[i * 2];
|
|
19301
|
+
if (!node || node.nodeType !== "leaf")
|
|
19302
|
+
continue;
|
|
19303
|
+
if (bytesToHex22(getSignaturePublicKeyFromLeafIndex2(tree, toLeafIndex2(i))) === want)
|
|
19304
|
+
out.push(i);
|
|
19305
|
+
}
|
|
19306
|
+
return out;
|
|
19307
|
+
}
|
|
19273
19308
|
function tagOfState(state) {
|
|
19274
19309
|
const m = new MLSGroupManager(state.ciphersuite);
|
|
19275
19310
|
m.importState(state);
|
|
@@ -19296,6 +19331,8 @@ function isEpochConflict(e) {
|
|
|
19296
19331
|
var LABEL = "agentvault-connect-v1";
|
|
19297
19332
|
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
19298
19333
|
var DEFAULT_QUARANTINE_THRESHOLD = 8;
|
|
19334
|
+
var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
|
|
19335
|
+
var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
|
|
19299
19336
|
var DELIVERY_PULL_PAGE = 100;
|
|
19300
19337
|
var DELIVERY_MAX_RETRIES = 5;
|
|
19301
19338
|
var DrainPageFullError = class extends Error {
|
|
@@ -19384,6 +19421,12 @@ var SelfAddRefusedError = class extends Error {
|
|
|
19384
19421
|
this.name = "SelfAddRefusedError";
|
|
19385
19422
|
}
|
|
19386
19423
|
};
|
|
19424
|
+
var VoiceMirrorAddRefusedError = class extends Error {
|
|
19425
|
+
constructor() {
|
|
19426
|
+
super("addMemberToLiveGroup: refusing a voice-channel Add of a device already in the roster mirror (it would mint a duplicate leaf)");
|
|
19427
|
+
this.name = "VoiceMirrorAddRefusedError";
|
|
19428
|
+
}
|
|
19429
|
+
};
|
|
19387
19430
|
var VaultCredentialMismatchError = class extends Error {
|
|
19388
19431
|
expected;
|
|
19389
19432
|
got;
|
|
@@ -19494,6 +19537,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
19494
19537
|
* {@link errorStats}; mutated only through {@link recordDecryptError} /
|
|
19495
19538
|
* {@link recordDecryptSuccess}.
|
|
19496
19539
|
*/
|
|
19540
|
+
/** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
|
|
19541
|
+
* deferred behind the kept Welcome `welcomeId`. Restarted for a different
|
|
19542
|
+
* Welcome; cleared when the channel decrypts, or when a pass that saw the
|
|
19543
|
+
* whole queue kept no Welcome for it (fix round 1, L2: the Welcome
|
|
19544
|
+
* resolved, so a later re-Welcome gets a fresh, bounded window). An entry
|
|
19545
|
+
* past its bound stays (expired) until then, so the bound cannot restart
|
|
19546
|
+
* itself pass after pass. In memory: a reload restarts it, and the
|
|
19547
|
+
* per-pass row cap is what bounds the page cost across reloads. */
|
|
19548
|
+
keptWelcomeDeferSince = /* @__PURE__ */ new Map();
|
|
19497
19549
|
errors = /* @__PURE__ */ new Map();
|
|
19498
19550
|
stuckThreshold;
|
|
19499
19551
|
/** Consecutive-error threshold at which a wedged channel's row is quarantined
|
|
@@ -19712,8 +19764,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
19712
19764
|
* channel resumes normal processing and can quarantine again later if it
|
|
19713
19765
|
* re-wedges (the onWedged guard is reset so the hook can fire again).
|
|
19714
19766
|
*/
|
|
19767
|
+
/** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
|
|
19768
|
+
* caller has already established that this pass KEPT a no-state Welcome
|
|
19769
|
+
* for `channelId`. True: defer the commit row. Never throws. */
|
|
19770
|
+
deferKeptWelcomeCommit(channelId, welcomeId) {
|
|
19771
|
+
const pred = this.o.deferCommitsWhileWelcomePending;
|
|
19772
|
+
if (!pred)
|
|
19773
|
+
return false;
|
|
19774
|
+
let boundMs = 0;
|
|
19775
|
+
try {
|
|
19776
|
+
const v = pred(channelId);
|
|
19777
|
+
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;
|
|
19778
|
+
} catch {
|
|
19779
|
+
boundMs = 0;
|
|
19780
|
+
}
|
|
19781
|
+
if (boundMs <= 0)
|
|
19782
|
+
return false;
|
|
19783
|
+
const now = this.nowMs();
|
|
19784
|
+
const entry = this.keptWelcomeDeferSince.get(channelId);
|
|
19785
|
+
if (entry === void 0 || entry.welcomeId !== welcomeId) {
|
|
19786
|
+
this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
|
|
19787
|
+
return true;
|
|
19788
|
+
}
|
|
19789
|
+
return now - entry.since < boundMs;
|
|
19790
|
+
}
|
|
19715
19791
|
recordDecryptSuccess(channelId) {
|
|
19716
19792
|
this.lastDecryptAt.set(channelId, Date.now());
|
|
19793
|
+
this.keptWelcomeDeferSince.delete(channelId);
|
|
19717
19794
|
const e = this.errors.get(channelId);
|
|
19718
19795
|
if (!e)
|
|
19719
19796
|
return;
|
|
@@ -20109,6 +20186,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20109
20186
|
* server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
|
|
20110
20187
|
* not apply (which means the state is genuinely FORKED, not merely behind,
|
|
20111
20188
|
* and replay cannot fix a fork).
|
|
20189
|
+
*
|
|
20190
|
+
* THROWS (retryable, never `false`, #1290): CustodyPersistError when the
|
|
20191
|
+
* channel is under the custody hold (nothing applied), and the C2 classes
|
|
20192
|
+
* (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
|
|
20193
|
+
* the advanced cache, so the persisted state is unchanged. A /sync request
|
|
20194
|
+
* that fails also throws, before anything is applied.
|
|
20112
20195
|
*/
|
|
20113
20196
|
async resyncByReplay(channelId, groupId) {
|
|
20114
20197
|
if (!await this.hasPersistedChannelState(channelId))
|
|
@@ -20128,12 +20211,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20128
20211
|
let applied = 0;
|
|
20129
20212
|
try {
|
|
20130
20213
|
await this.locks.run(channelId, async () => {
|
|
20214
|
+
if (await this.custodyHeld(channelId))
|
|
20215
|
+
throw new CustodyPersistError(channelId);
|
|
20131
20216
|
const mgr = await this.manager(channelId);
|
|
20132
20217
|
try {
|
|
20133
20218
|
for (const c of commits) {
|
|
20134
20219
|
if (c.epoch <= Number(mgr.epoch))
|
|
20135
20220
|
continue;
|
|
20136
|
-
|
|
20221
|
+
const commitBytes = hexToBytes22(c.payload);
|
|
20222
|
+
try {
|
|
20223
|
+
await mgr.decrypt(commitBytes);
|
|
20224
|
+
} catch (e) {
|
|
20225
|
+
if (await probeOwnRemoval(mgr, commitBytes) !== null) {
|
|
20226
|
+
this.noteOwnLeafRemoved(channelId, {
|
|
20227
|
+
group_id: groupId,
|
|
20228
|
+
epoch: c.epoch,
|
|
20229
|
+
message_id: c.id
|
|
20230
|
+
});
|
|
20231
|
+
}
|
|
20232
|
+
throw e;
|
|
20233
|
+
}
|
|
20137
20234
|
applied++;
|
|
20138
20235
|
}
|
|
20139
20236
|
if (applied > 0)
|
|
@@ -20145,6 +20242,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20145
20242
|
}
|
|
20146
20243
|
});
|
|
20147
20244
|
} catch (e) {
|
|
20245
|
+
if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
|
|
20246
|
+
writeServiceLog2("warn", "resync.replay_held", { applied }, e);
|
|
20247
|
+
throw e;
|
|
20248
|
+
}
|
|
20148
20249
|
writeServiceLog2("warn", "resync.replay_commit_failed", { applied }, e);
|
|
20149
20250
|
return false;
|
|
20150
20251
|
}
|
|
@@ -20873,10 +20974,28 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20873
20974
|
* The row is deliberately NOT acked here — the subsequent ensureSession(join) /
|
|
20874
20975
|
* joinAndReconcile consumes the same Welcome and acks it after joining.
|
|
20875
20976
|
*/
|
|
20876
|
-
async peekWelcomeChannel(deviceId) {
|
|
20977
|
+
async peekWelcomeChannel(deviceId, opts = {}) {
|
|
20877
20978
|
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
20878
|
-
const
|
|
20879
|
-
|
|
20979
|
+
const asked = /* @__PURE__ */ new Set();
|
|
20980
|
+
for (const r of rows) {
|
|
20981
|
+
if (r.message_type !== "welcome" || r.connect_channel_id == null)
|
|
20982
|
+
continue;
|
|
20983
|
+
const ch = r.connect_channel_id;
|
|
20984
|
+
if (!opts.accept)
|
|
20985
|
+
return ch;
|
|
20986
|
+
if (asked.has(ch))
|
|
20987
|
+
continue;
|
|
20988
|
+
asked.add(ch);
|
|
20989
|
+
let ok = false;
|
|
20990
|
+
try {
|
|
20991
|
+
ok = await opts.accept(ch) === true;
|
|
20992
|
+
} catch {
|
|
20993
|
+
ok = false;
|
|
20994
|
+
}
|
|
20995
|
+
if (ok)
|
|
20996
|
+
return ch;
|
|
20997
|
+
}
|
|
20998
|
+
return null;
|
|
20880
20999
|
}
|
|
20881
21000
|
/**
|
|
20882
21001
|
* Publish this device's KeyPackage if it holds none; otherwise a no-op that
|
|
@@ -20982,10 +21101,17 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20982
21101
|
}
|
|
20983
21102
|
const attempted = [];
|
|
20984
21103
|
const failed = [];
|
|
21104
|
+
const discarded = [];
|
|
20985
21105
|
for (const channelId of channelIds) {
|
|
20986
21106
|
attempted.push(channelId);
|
|
20987
21107
|
try {
|
|
20988
|
-
await this.ensureSession(channelId, {
|
|
21108
|
+
await this.ensureSession(channelId, {
|
|
21109
|
+
enclaveId: "welcome-drain",
|
|
21110
|
+
bootstrap: false,
|
|
21111
|
+
onUnopenableWelcome: (groupId) => {
|
|
21112
|
+
discarded.push({ channelId, groupId });
|
|
21113
|
+
}
|
|
21114
|
+
});
|
|
20989
21115
|
} catch (error) {
|
|
20990
21116
|
failed.push({ channelId, error });
|
|
20991
21117
|
}
|
|
@@ -20995,7 +21121,7 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
20995
21121
|
receipt = new DrainReceipt(DRAIN_RECEIPT_TOKEN);
|
|
20996
21122
|
this.drainReceipts.set(receipt, this.nowMs());
|
|
20997
21123
|
}
|
|
20998
|
-
return { attempted, failed, skipped, unknown, pageFull, receipt };
|
|
21124
|
+
return { attempted, failed, skipped, unknown, pageFull, receipt, discarded };
|
|
20999
21125
|
}
|
|
21000
21126
|
/**
|
|
21001
21127
|
* Resolve the channel's pending commit record, under the channel lock, and
|
|
@@ -21468,7 +21594,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21468
21594
|
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
21469
21595
|
return MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
21470
21596
|
});
|
|
21471
|
-
const
|
|
21597
|
+
const creatorLeafSignatureKey = bundle ? bytesToHex22(bundle.publicPackage.leafNode.signaturePublicKey) : void 0;
|
|
21598
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId], creatorLeafSignatureKey !== void 0 ? { creatorLeafSignatureKey } : void 0);
|
|
21472
21599
|
if (init.created === false) {
|
|
21473
21600
|
if (!this.isAdoptableOrphan(init, others)) {
|
|
21474
21601
|
if (this.isOrphanShape(init, others))
|
|
@@ -21488,7 +21615,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21488
21615
|
commit_epoch: Number(mgr.epoch),
|
|
21489
21616
|
expected_epoch: init.current_epoch,
|
|
21490
21617
|
member_device_ids: [this.o.deviceId, ...others],
|
|
21491
|
-
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : []
|
|
21618
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex22(welcome) })) : [],
|
|
21619
|
+
// Task 24b (ruling §3 A): exact cover of the Welcome targets ({} when none).
|
|
21620
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? others : [], welcome ? pkgs : [])
|
|
21492
21621
|
}, mgr, null);
|
|
21493
21622
|
const res = await this.commitWithPendingRecord(channelId, rec, mgr);
|
|
21494
21623
|
if (!res.landed) {
|
|
@@ -21502,15 +21631,32 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21502
21631
|
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id === channelId);
|
|
21503
21632
|
if (!w)
|
|
21504
21633
|
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
21634
|
+
const aborted = () => opts.shouldAbort?.() === true;
|
|
21635
|
+
if (aborted())
|
|
21636
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21505
21637
|
let mgr;
|
|
21506
21638
|
try {
|
|
21507
21639
|
mgr = await this.openWelcome(hexToBytes22(w.payload), [bundle, joinSlot]);
|
|
21508
21640
|
} catch (err) {
|
|
21641
|
+
if (aborted())
|
|
21642
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21509
21643
|
this.recordDecryptError(w, err);
|
|
21510
21644
|
writeServiceLog2("warn", "session.unopenable_welcome_discarded", {}, err);
|
|
21511
21645
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
21646
|
+
try {
|
|
21647
|
+
opts.onUnopenableWelcome?.(w.group_id);
|
|
21648
|
+
} catch (e) {
|
|
21649
|
+
writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
21650
|
+
}
|
|
21651
|
+
try {
|
|
21652
|
+
this.o.onUnopenableWelcomeDiscarded?.({ channelId, groupId: w.group_id });
|
|
21653
|
+
} catch (e) {
|
|
21654
|
+
writeServiceLog2("warn", "session.unopenable_welcome_report_failed", {}, e);
|
|
21655
|
+
}
|
|
21512
21656
|
return;
|
|
21513
21657
|
}
|
|
21658
|
+
if (aborted())
|
|
21659
|
+
throw new Error("ensureSession(join): aborted by the caller");
|
|
21514
21660
|
this.managers.set(channelId, mgr);
|
|
21515
21661
|
await this.saveMain(channelId, mgr);
|
|
21516
21662
|
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
@@ -21721,6 +21867,13 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21721
21867
|
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "addMemberToLiveGroup");
|
|
21722
21868
|
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
21723
21869
|
groupId = init.group_id;
|
|
21870
|
+
if (this.o.isVoiceChannel?.(channelId) === true) {
|
|
21871
|
+
const mirror = init.member_device_ids;
|
|
21872
|
+
if (!Array.isArray(mirror) || mirror.includes(newDeviceId)) {
|
|
21873
|
+
writeServiceLog2("warn", "admission.voice_device_already_in_mirror");
|
|
21874
|
+
throw new VoiceMirrorAddRefusedError();
|
|
21875
|
+
}
|
|
21876
|
+
}
|
|
21724
21877
|
members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
21725
21878
|
}
|
|
21726
21879
|
if (new TextDecoder().decode(mgr.groupId) !== groupId) {
|
|
@@ -21739,7 +21892,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
21739
21892
|
expected_epoch: priorEpoch,
|
|
21740
21893
|
member_device_ids: members,
|
|
21741
21894
|
op: "add",
|
|
21742
|
-
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : []
|
|
21895
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex22(welcome) }] : [],
|
|
21896
|
+
// Task 24b (ruling §3 A): the exact Welcome cover's leaf keys.
|
|
21897
|
+
added_leaf_signature_keys: leafKeyClaims(welcome ? [newDeviceId] : [], welcome ? [pkg] : [])
|
|
21743
21898
|
}, mgr, prior), mgr);
|
|
21744
21899
|
if (!res.landed) {
|
|
21745
21900
|
const e = res.error;
|
|
@@ -22060,6 +22215,181 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22060
22215
|
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
22061
22216
|
}
|
|
22062
22217
|
}
|
|
22218
|
+
/**
|
|
22219
|
+
* ADR-0024 step 2a, round 2 F4 (Task 24): the committer sweep's DEVICE-level
|
|
22220
|
+
* Remove of one leaf the server's committer-work lists in `remove_devices`
|
|
22221
|
+
* (on the hidden voice channel V: a stale device of an agent or relay
|
|
22222
|
+
* identity that is still on V's roster).
|
|
22223
|
+
*
|
|
22224
|
+
* ⛔ WHY NOT {@link removeDeviceFromLiveGroup}. That path resolves a FOREIGN
|
|
22225
|
+
* identity's leaf by add-order position, which RFC 9420's leftmost-blank
|
|
22226
|
+
* refill permutes. Here the identity keeps a CURRENT device, so a wrong guess
|
|
22227
|
+
* evicts the current device's leaf while the distribute's `removed_device_id`
|
|
22228
|
+
* makes the server drop the STALE device from its mirror. The current device
|
|
22229
|
+
* is then in the roster with no leaf, outside the adds delta forever: an
|
|
22230
|
+
* unrecoverable wedge. So this resolves the leaf by EXACT evidence only:
|
|
22231
|
+
*
|
|
22232
|
+
* - the target device's PUBLISHED KeyPackage, whose LeafNode signature key
|
|
22233
|
+
* is matched against THIS committer's live tree (the device-level leaf
|
|
22234
|
+
* predicate, {@link leafIndicesForKeyPackage}). `generateKeyPackage`
|
|
22235
|
+
* mints a fresh signature keypair per call, so a match names exactly the
|
|
22236
|
+
* leaf that KeyPackage was Added as;
|
|
22237
|
+
* - exactly ONE matching leaf, or nothing is committed;
|
|
22238
|
+
* - that leaf's credential identity is read from the tree, never from the
|
|
22239
|
+
* server, and is what the distribute names as `removed_identity_id`.
|
|
22240
|
+
*
|
|
22241
|
+
* Outcomes. `"removed"`: one leaf evicted and the distribute landed.
|
|
22242
|
+
* `"unresolved"`: no KeyPackage is published for the device, or it matches no
|
|
22243
|
+
* leaf (the device rotated it after its Add, or the leaf is already gone).
|
|
22244
|
+
* Nothing is committed: the leaf, if any, stays, and is reported, never
|
|
22245
|
+
* guessed. The server's next delta lists it again (the caller's throttle
|
|
22246
|
+
* bounds the retries).
|
|
22247
|
+
*
|
|
22248
|
+
* Refusals (thrown before any commit is built, so nothing is distributed):
|
|
22249
|
+
* the committer's own device (`own_device`), any leaf of the committer's own
|
|
22250
|
+
* identity, the committer's own leaf, or an unknown own leaf
|
|
22251
|
+
* (`own_leaf` / `own_leaf_unknown`, {@link OwnLeafRemoveRefusedError}); two
|
|
22252
|
+
* or more matching leaves; a held group that is not the channel's active
|
|
22253
|
+
* group (#757 binding, checked BEFORE init).
|
|
22254
|
+
*/
|
|
22255
|
+
async removeStaleDeviceLeafFromLiveGroup(channelId, deviceId, bound) {
|
|
22256
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
22257
|
+
await this.requirePendingResolvedUnlocked(channelId);
|
|
22258
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, 0, bound);
|
|
22259
|
+
}));
|
|
22260
|
+
}
|
|
22261
|
+
async removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt, bound) {
|
|
22262
|
+
if (await this.custodyHeld(channelId))
|
|
22263
|
+
throw new CustodyPersistError(channelId);
|
|
22264
|
+
if (deviceId === this.o.deviceId)
|
|
22265
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_device");
|
|
22266
|
+
const mgr = await this.manager(channelId);
|
|
22267
|
+
const resolved = bound ? this.resolveBoundDeviceLeaf(mgr, bound) : await this.resolveServedKeyPackageLeaf(mgr, deviceId);
|
|
22268
|
+
if (resolved === "unresolved")
|
|
22269
|
+
return "unresolved";
|
|
22270
|
+
const { leafIndex, leafIdentity } = resolved;
|
|
22271
|
+
await this.refuseUnlessHeldGroupIsActive(channelId, mgr, "removeStaleDeviceLeafFromLiveGroup");
|
|
22272
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
22273
|
+
if (new TextDecoder().decode(mgr.groupId) !== init.group_id) {
|
|
22274
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: group binding mismatch");
|
|
22275
|
+
}
|
|
22276
|
+
const priorEpoch = Number(mgr.epoch);
|
|
22277
|
+
const prior = tagOf(mgr);
|
|
22278
|
+
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
22279
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
22280
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: unexpected epoch drift before distribute");
|
|
22281
|
+
}
|
|
22282
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
22283
|
+
const res = await this.commitWithPendingRecord(channelId, this.pendingRecord("remove", init.group_id, {
|
|
22284
|
+
device_id: this.o.deviceId,
|
|
22285
|
+
commit: bytesToHex22(commit),
|
|
22286
|
+
commit_epoch: Number(mgr.epoch),
|
|
22287
|
+
expected_epoch: priorEpoch,
|
|
22288
|
+
member_device_ids: members,
|
|
22289
|
+
op: "remove",
|
|
22290
|
+
removed_identity_id: leafIdentity,
|
|
22291
|
+
removed_device_id: deviceId,
|
|
22292
|
+
welcomes: []
|
|
22293
|
+
}, mgr, prior), mgr);
|
|
22294
|
+
if (!res.landed) {
|
|
22295
|
+
const e = res.error;
|
|
22296
|
+
if (isSupersededRefusal(e))
|
|
22297
|
+
throw new GroupSupersededError(channelId, init.group_id);
|
|
22298
|
+
if (_attempt >= 2 || !isEpochConflict(e))
|
|
22299
|
+
throw e;
|
|
22300
|
+
try {
|
|
22301
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
22302
|
+
} catch (e2) {
|
|
22303
|
+
if (e2 instanceof CustodyPersistError)
|
|
22304
|
+
throw e2;
|
|
22305
|
+
}
|
|
22306
|
+
return this.removeStaleDeviceLeafUnlocked(channelId, deviceId, _attempt + 1, bound);
|
|
22307
|
+
}
|
|
22308
|
+
return "removed";
|
|
22309
|
+
}
|
|
22310
|
+
/** F4 (Task 24): the target's PUBLISHED KeyPackage, matched by key against
|
|
22311
|
+
* the committer's tree. Unchanged from Task 24 and its fix round 1. */
|
|
22312
|
+
async resolveServedKeyPackageLeaf(mgr, deviceId) {
|
|
22313
|
+
const kps = await this.o.backend.fetchKeyPackages([deviceId]);
|
|
22314
|
+
const hex = kps[deviceId];
|
|
22315
|
+
if (!hex) {
|
|
22316
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_key_package: true });
|
|
22317
|
+
return "unresolved";
|
|
22318
|
+
}
|
|
22319
|
+
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes22(hex));
|
|
22320
|
+
const leaves = leafIndicesForKeyPackage(mgr, pkg);
|
|
22321
|
+
if (leaves.length === 0) {
|
|
22322
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_matching_leaf: true });
|
|
22323
|
+
return "unresolved";
|
|
22324
|
+
}
|
|
22325
|
+
if (leaves.length > 1) {
|
|
22326
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous key match (more than one leaf)");
|
|
22327
|
+
}
|
|
22328
|
+
const leafIndex = leaves[0];
|
|
22329
|
+
const tree = mgr.state?.ratchetTree;
|
|
22330
|
+
if (!tree)
|
|
22331
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
22332
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
22333
|
+
if (cred.credentialType !== "basic") {
|
|
22334
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: leaf carries no basic credential");
|
|
22335
|
+
}
|
|
22336
|
+
const leafIdentity = new TextDecoder().decode(cred.identity);
|
|
22337
|
+
const ownIdentity = new TextDecoder().decode(this.o.identity);
|
|
22338
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
22339
|
+
if (!leafIdentity || leafIdentity === ownIdentity) {
|
|
22340
|
+
throw new OwnLeafRemoveRefusedError("removeDevice", "own_leaf");
|
|
22341
|
+
}
|
|
22342
|
+
if (mgr.leafIndicesForIdentity(leafIdentity).length <= 1) {
|
|
22343
|
+
writeServiceLog2("warn", "committer.remove_device_refused_last_leaf", {});
|
|
22344
|
+
return "unresolved";
|
|
22345
|
+
}
|
|
22346
|
+
return { leafIndex, leafIdentity };
|
|
22347
|
+
}
|
|
22348
|
+
/**
|
|
22349
|
+
* ADR-0024 step 2a Task 24b (architect ruling "carry A", §3 B and §4 R5):
|
|
22350
|
+
* resolve the leaf by the SERVER-BOUND leaf signature key (committer work's
|
|
22351
|
+
* `remove_device_leaves`). The server recorded the key when the leaf was
|
|
22352
|
+
* born, from the KeyPackage it vended, so it survives the target's
|
|
22353
|
+
* KeyPackage rotation, revocation and deletion, which the served-KeyPackage
|
|
22354
|
+
* path cannot.
|
|
22355
|
+
*
|
|
22356
|
+
* - Leaves are matched by the bound key ONLY: exactly one match, else
|
|
22357
|
+
* `unresolved` (zero) or a throw (two or more). ⛔ NEVER a fall back to
|
|
22358
|
+
* the served KeyPackage: two evidence sources that disagree fail closed.
|
|
22359
|
+
* - The matched leaf's basic-credential identity must equal the server's
|
|
22360
|
+
* `identityId` for the device (R4), else a throw.
|
|
22361
|
+
* - The committer's own leaf index (or an unknown one) is refused
|
|
22362
|
+
* ({@link refuseOwnLeafRemove}); the own device was refused by the caller.
|
|
22363
|
+
* - R5: a leaf of the committer's OWN identity is admitted (a sibling
|
|
22364
|
+
* device: a revoked laptop). The brick class the own-identity refusal
|
|
22365
|
+
* guards came from positional or identity-level resolution, which this
|
|
22366
|
+
* exact-key path cannot reach, and the own leaf index is still refused.
|
|
22367
|
+
* - No last-leaf refusal: the binding is exact evidence, and an identity's
|
|
22368
|
+
* last leaf is exactly the revoked single-device case (and carry A's
|
|
22369
|
+
* Remove before its re-Add).
|
|
22370
|
+
* Synchronous and before any commit is built: a refusal distributes nothing.
|
|
22371
|
+
*/
|
|
22372
|
+
resolveBoundDeviceLeaf(mgr, bound) {
|
|
22373
|
+
const leaves = leafIndicesForSignatureKey(mgr, bound.leafSignatureKeyHex);
|
|
22374
|
+
if (leaves.length === 0) {
|
|
22375
|
+
writeServiceLog2("info", "committer.remove_device_unresolved", { no_bound_leaf: true });
|
|
22376
|
+
return "unresolved";
|
|
22377
|
+
}
|
|
22378
|
+
if (leaves.length > 1) {
|
|
22379
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: ambiguous bound-key match (more than one leaf)");
|
|
22380
|
+
}
|
|
22381
|
+
const leafIndex = leaves[0];
|
|
22382
|
+
const tree = mgr.state?.ratchetTree;
|
|
22383
|
+
if (!tree)
|
|
22384
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: no local group state");
|
|
22385
|
+
const cred = getCredentialFromLeafIndex(tree, toLeafIndex2(leafIndex));
|
|
22386
|
+
const leafIdentity = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
22387
|
+
if (!leafIdentity || leafIdentity !== bound.identityId) {
|
|
22388
|
+
throw new Error("removeStaleDeviceLeafFromLiveGroup: the bound leaf's credential identity is not the device's identity");
|
|
22389
|
+
}
|
|
22390
|
+
this.refuseOwnLeafRemove(mgr, [leafIndex], "removeDevice");
|
|
22391
|
+
return { leafIndex, leafIdentity };
|
|
22392
|
+
}
|
|
22063
22393
|
/**
|
|
22064
22394
|
* Committer-side (owner): evict a SINGLE device leaf from a LIVE VAULT group,
|
|
22065
22395
|
* targeted by its owner-observed leaf `signaturePublicKey` against the live
|
|
@@ -22616,6 +22946,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
22616
22946
|
out.push({
|
|
22617
22947
|
queueId: `replay-${m.id}`,
|
|
22618
22948
|
messageId: envelope.message_id,
|
|
22949
|
+
// #1246 C-1: the group-messages row id is the same mls_messages key.
|
|
22950
|
+
...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
|
|
22619
22951
|
channelId,
|
|
22620
22952
|
plaintext,
|
|
22621
22953
|
envelope,
|
|
@@ -23516,6 +23848,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23516
23848
|
const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
|
|
23517
23849
|
const w2Acked = /* @__PURE__ */ new Map();
|
|
23518
23850
|
const w2Kept = /* @__PURE__ */ new Set();
|
|
23851
|
+
const keptWelcomeId = /* @__PURE__ */ new Map();
|
|
23852
|
+
const deferredThisPass = /* @__PURE__ */ new Map();
|
|
23519
23853
|
const unpinnedRefusals = /* @__PURE__ */ new Map();
|
|
23520
23854
|
const welcomes = rows.filter((r) => r.message_type === "welcome");
|
|
23521
23855
|
const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
|
|
@@ -23674,6 +24008,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23674
24008
|
writeServiceLog2("warn", "welcome.unopenable");
|
|
23675
24009
|
} else {
|
|
23676
24010
|
w2Kept.add(wcid);
|
|
24011
|
+
if (!keptWelcomeId.has(wcid))
|
|
24012
|
+
keptWelcomeId.set(wcid, row.queue_id);
|
|
23677
24013
|
if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
|
|
23678
24014
|
writeServiceLog2("debug", "delivery.welcome_no_local_state");
|
|
23679
24015
|
}
|
|
@@ -23684,6 +24020,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23684
24020
|
const cid = row.connect_channel_id;
|
|
23685
24021
|
if (!cid)
|
|
23686
24022
|
continue;
|
|
24023
|
+
if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
|
|
24024
|
+
deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
|
|
24025
|
+
continue;
|
|
24026
|
+
}
|
|
23687
24027
|
let advancedEpoch;
|
|
23688
24028
|
let custodyHeld = false;
|
|
23689
24029
|
await this.locks.run(cid, async () => {
|
|
@@ -23840,6 +24180,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23840
24180
|
this.leftStreak.set(cid, { pulls: 1, since: pullNow });
|
|
23841
24181
|
this.scheduleAutoResolve(cid);
|
|
23842
24182
|
}
|
|
24183
|
+
if (rows.length < DELIVERY_PULL_PAGE) {
|
|
24184
|
+
for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
|
|
24185
|
+
if (!w2Kept.has(cid))
|
|
24186
|
+
this.keptWelcomeDeferSince.delete(cid);
|
|
24187
|
+
}
|
|
24188
|
+
}
|
|
23843
24189
|
for (const [cid, gid] of w2Acked) {
|
|
23844
24190
|
if (w2Kept.has(cid))
|
|
23845
24191
|
continue;
|
|
@@ -23880,6 +24226,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
23880
24226
|
if (supersededChannelIds.length) {
|
|
23881
24227
|
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
23882
24228
|
}
|
|
24229
|
+
if (w2Kept.size) {
|
|
24230
|
+
Object.defineProperty(appEntries, "pendingWelcomeChannelIds", { value: [...w2Kept], enumerable: false });
|
|
24231
|
+
}
|
|
23883
24232
|
return appEntries;
|
|
23884
24233
|
}
|
|
23885
24234
|
/**
|
|
@@ -24116,7 +24465,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
|
|
|
24116
24465
|
voiceDefaultResponder: row.voice_default_responder ?? void 0,
|
|
24117
24466
|
...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
|
|
24118
24467
|
payloadHex: row.payload,
|
|
24119
|
-
openedAt
|
|
24468
|
+
openedAt,
|
|
24469
|
+
// #1246 C-1: the server row id, beside the sender-chosen envelope id.
|
|
24470
|
+
...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
|
|
24120
24471
|
};
|
|
24121
24472
|
const custodiedAt = this.nowMs();
|
|
24122
24473
|
this.custody.set(row.queue_id, entry);
|
|
@@ -25671,9 +26022,9 @@ var RestMlsBackend = class {
|
|
|
25671
26022
|
current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
|
|
25672
26023
|
};
|
|
25673
26024
|
}
|
|
25674
|
-
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
26025
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
25675
26026
|
const path = `/api/v1/mls/connect-channels/${channelId}/init`;
|
|
25676
|
-
const body = { member_device_ids: memberDeviceIds };
|
|
26027
|
+
const body = opts?.creatorLeafSignatureKey !== void 0 ? { member_device_ids: memberDeviceIds, creator_leaf_signature_key: opts.creatorLeafSignatureKey } : { member_device_ids: memberDeviceIds };
|
|
25677
26028
|
let j;
|
|
25678
26029
|
for (let attempt = 0; ; attempt++) {
|
|
25679
26030
|
try {
|
|
@@ -25915,6 +26266,22 @@ var RestMlsBackend = class {
|
|
|
25915
26266
|
throw new Error(`unrequested-removals: reports is ${describeType(rows)}`);
|
|
25916
26267
|
return rows;
|
|
25917
26268
|
}
|
|
26269
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
26270
|
+
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 }) });
|
|
26271
|
+
const text = await res.text().catch(() => "");
|
|
26272
|
+
if (res.ok)
|
|
26273
|
+
return { status: res.status };
|
|
26274
|
+
let parsed = null;
|
|
26275
|
+
if (text) {
|
|
26276
|
+
try {
|
|
26277
|
+
parsed = JSON.parse(text);
|
|
26278
|
+
} catch {
|
|
26279
|
+
parsed = null;
|
|
26280
|
+
}
|
|
26281
|
+
}
|
|
26282
|
+
const code = parseErrorCode(parsed);
|
|
26283
|
+
return code === void 0 ? { status: res.status } : { status: res.status, code };
|
|
26284
|
+
}
|
|
25918
26285
|
async getCommitterWork(channelId) {
|
|
25919
26286
|
const j = await this.get("mls_committer_work", `/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
25920
26287
|
const requireList = (field) => {
|
|
@@ -25924,7 +26291,29 @@ var RestMlsBackend = class {
|
|
|
25924
26291
|
}
|
|
25925
26292
|
return v.map(String);
|
|
25926
26293
|
};
|
|
25927
|
-
|
|
26294
|
+
const work = {
|
|
26295
|
+
adds: requireList("adds"),
|
|
26296
|
+
removes: requireList("removes")
|
|
26297
|
+
};
|
|
26298
|
+
if (j?.remove_devices !== void 0) {
|
|
26299
|
+
work.removeDevices = requireList("remove_devices");
|
|
26300
|
+
}
|
|
26301
|
+
const rawLeaves = j?.remove_device_leaves;
|
|
26302
|
+
if (rawLeaves !== void 0) {
|
|
26303
|
+
if (!Array.isArray(rawLeaves))
|
|
26304
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves", describeType(rawLeaves));
|
|
26305
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26306
|
+
work.removeDeviceLeaves = rawLeaves.map((e) => {
|
|
26307
|
+
const o = e !== null && typeof e === "object" && !Array.isArray(e) ? e : null;
|
|
26308
|
+
const key = typeof o?.leaf_signature_key === "string" ? o.leaf_signature_key.toLowerCase() : null;
|
|
26309
|
+
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)) {
|
|
26310
|
+
throw new MalformedCommitterWorkError(channelId, "remove_device_leaves[]", describeType(e));
|
|
26311
|
+
}
|
|
26312
|
+
seen.add(o.device_id);
|
|
26313
|
+
return { deviceId: o.device_id, identityId: o.identity_id, leafSignatureKeyHex: key };
|
|
26314
|
+
});
|
|
26315
|
+
}
|
|
26316
|
+
return work;
|
|
25928
26317
|
}
|
|
25929
26318
|
async acquireCommitLease(channelId) {
|
|
25930
26319
|
try {
|
|
@@ -25974,6 +26363,48 @@ var RestMlsBackend = class {
|
|
|
25974
26363
|
|
|
25975
26364
|
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
25976
26365
|
var DELIVERY_PULL_LIMIT = 100;
|
|
26366
|
+
function keyPackageSignatureKeyHex(kpHex) {
|
|
26367
|
+
const b = hexToBytes22(kpHex);
|
|
26368
|
+
let at = 0;
|
|
26369
|
+
const u16 = () => {
|
|
26370
|
+
if (at + 2 > b.length)
|
|
26371
|
+
throw new Error("t");
|
|
26372
|
+
const v = b[at] << 8 | b[at + 1];
|
|
26373
|
+
at += 2;
|
|
26374
|
+
return v;
|
|
26375
|
+
};
|
|
26376
|
+
const opaque = () => {
|
|
26377
|
+
if (at >= b.length)
|
|
26378
|
+
throw new Error("t");
|
|
26379
|
+
const prefix = b[at] >> 6;
|
|
26380
|
+
if (prefix === 3)
|
|
26381
|
+
throw new Error("t");
|
|
26382
|
+
const size = 1 << prefix;
|
|
26383
|
+
if (at + size > b.length)
|
|
26384
|
+
throw new Error("t");
|
|
26385
|
+
let n = b[at] & 63;
|
|
26386
|
+
for (let i = 1; i < size; i++)
|
|
26387
|
+
n = n << 8 | b[at + i];
|
|
26388
|
+
at += size;
|
|
26389
|
+
if (at + n > b.length)
|
|
26390
|
+
throw new Error("t");
|
|
26391
|
+
const out = b.slice(at, at + n);
|
|
26392
|
+
at += n;
|
|
26393
|
+
return out;
|
|
26394
|
+
};
|
|
26395
|
+
try {
|
|
26396
|
+
if (u16() !== 1 || u16() !== 5)
|
|
26397
|
+
return null;
|
|
26398
|
+
u16();
|
|
26399
|
+
u16();
|
|
26400
|
+
opaque();
|
|
26401
|
+
opaque();
|
|
26402
|
+
const sig = opaque();
|
|
26403
|
+
return sig.length > 0 ? bytesToHex22(sig) : null;
|
|
26404
|
+
} catch {
|
|
26405
|
+
return null;
|
|
26406
|
+
}
|
|
26407
|
+
}
|
|
25977
26408
|
var FakeConnectBackend = class _FakeConnectBackend {
|
|
25978
26409
|
// Key-package store: deviceId → hex-encoded KeyPackage
|
|
25979
26410
|
kps = /* @__PURE__ */ new Map();
|
|
@@ -26058,6 +26489,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26058
26489
|
* server's purge_device_queue. */
|
|
26059
26490
|
linkIdentityDevice(identityId, deviceId) {
|
|
26060
26491
|
this.deviceByIdentity.set(identityId, deviceId);
|
|
26492
|
+
this.identityByDevice.set(deviceId, identityId);
|
|
26061
26493
|
}
|
|
26062
26494
|
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
26063
26495
|
this.kps.set(deviceId, keyPackageHex);
|
|
@@ -26107,8 +26539,38 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26107
26539
|
current_epoch: this.epochByChannel.get(channelId) ?? 0
|
|
26108
26540
|
};
|
|
26109
26541
|
}
|
|
26110
|
-
|
|
26542
|
+
/** ADR-0024 step 2a (P-7): channel ids the test registers as a hidden
|
|
26543
|
+
* voice channel V. Init on an EXISTING V returns it unchanged and merges
|
|
26544
|
+
* nothing, as the real backend does. */
|
|
26545
|
+
voiceChannelIds = /* @__PURE__ */ new Set();
|
|
26546
|
+
/** Task 24b (architect ruling "carry A", §3 A-C), modelled for a channel in
|
|
26547
|
+
* {@link voiceChannelIds} only, as the PR-E server does for V:
|
|
26548
|
+
* `channel -> device -> { key (hex), unusableSignaledAt }`, written ONLY
|
|
26549
|
+
* when a V leaf is born (the creator's init, an Add's Welcome) from the
|
|
26550
|
+
* verified claim, and deleted when a Remove of that device (or its
|
|
26551
|
+
* identity) lands. */
|
|
26552
|
+
voiceLeafBindings = /* @__PURE__ */ new Map();
|
|
26553
|
+
/** The device -> identity the server knows (the devices table). Written by
|
|
26554
|
+
* {@link linkIdentityDevice}; read for `remove_device_leaves`. */
|
|
26555
|
+
identityByDevice = /* @__PURE__ */ new Map();
|
|
26556
|
+
static voiceRefusal(path, code) {
|
|
26557
|
+
return new MlsBackendHttpError(path, 409, code);
|
|
26558
|
+
}
|
|
26559
|
+
/** The current vendable KeyPackage key of `deviceId`, or null. */
|
|
26560
|
+
currentLeafKey(deviceId) {
|
|
26561
|
+
const hex = this.kps.get(deviceId);
|
|
26562
|
+
return hex ? keyPackageSignatureKeyHex(hex) : null;
|
|
26563
|
+
}
|
|
26564
|
+
async initConnectChannelGroup(channelId, memberDeviceIds, opts) {
|
|
26111
26565
|
const alreadyExists = this.groupIdByChannel.has(channelId);
|
|
26566
|
+
if (!alreadyExists && this.voiceChannelIds.has(channelId)) {
|
|
26567
|
+
const creator = memberDeviceIds[0];
|
|
26568
|
+
const current = creator ? this.currentLeafKey(creator) : null;
|
|
26569
|
+
if (!opts?.creatorLeafSignatureKey || current === null || opts.creatorLeafSignatureKey.toLowerCase() !== current) {
|
|
26570
|
+
throw new Error(`/api/v1/mls/connect-channels/${channelId}/init failed: 409 {"detail":"voice_stale_key_package"}`);
|
|
26571
|
+
}
|
|
26572
|
+
this.voiceLeafBindings.set(channelId, /* @__PURE__ */ new Map([[creator, { key: current, unusableSignaledAt: null }]]));
|
|
26573
|
+
}
|
|
26112
26574
|
let groupId = this.groupIdByChannel.get(channelId);
|
|
26113
26575
|
if (!groupId) {
|
|
26114
26576
|
groupId = `00000000-0000-0000-0000-${String(this.groupIdByChannel.size + 1).padStart(12, "0")}`;
|
|
@@ -26116,7 +26578,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26116
26578
|
this.channelByGroup.set(groupId, channelId);
|
|
26117
26579
|
this.epochByChannel.set(channelId, 0);
|
|
26118
26580
|
}
|
|
26119
|
-
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
26581
|
+
const members = alreadyExists && this.voiceChannelIds.has(channelId) ? [...this.membersByChannel.get(channelId) ?? []] : [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...memberDeviceIds])];
|
|
26120
26582
|
this.membersByChannel.set(channelId, members);
|
|
26121
26583
|
return {
|
|
26122
26584
|
group_id: groupId,
|
|
@@ -26253,6 +26715,27 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26253
26715
|
throw new MlsBackendHttpError(_FakeConnectBackend.distributePath(groupId), 409, "EPOCH_CONFLICT", "stale_epoch");
|
|
26254
26716
|
}
|
|
26255
26717
|
}
|
|
26718
|
+
const vBindings = this.voiceChannelIds.has(channelId) ? this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map() : null;
|
|
26719
|
+
let bindNow = [];
|
|
26720
|
+
if (vBindings && req.commit && req.op !== "remove") {
|
|
26721
|
+
const targets = (req.welcomes ?? []).map((w) => w.target_device_id);
|
|
26722
|
+
const claims = req.added_leaf_signature_keys;
|
|
26723
|
+
const path = _FakeConnectBackend.distributePath(groupId);
|
|
26724
|
+
if (!claims)
|
|
26725
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26726
|
+
const claimed = Object.keys(claims);
|
|
26727
|
+
if (claimed.length !== targets.length || targets.some((t) => !(t in claims))) {
|
|
26728
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26729
|
+
}
|
|
26730
|
+
for (const t of targets) {
|
|
26731
|
+
const current = this.currentLeafKey(t);
|
|
26732
|
+
if (current === null || claims[t].toLowerCase() !== current)
|
|
26733
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_stale_key_package");
|
|
26734
|
+
if (vBindings.has(t))
|
|
26735
|
+
throw _FakeConnectBackend.voiceRefusal(path, "voice_leaf_already_bound");
|
|
26736
|
+
}
|
|
26737
|
+
bindNow = targets.map((t) => [t, claims[t].toLowerCase()]);
|
|
26738
|
+
}
|
|
26256
26739
|
if (req.commit) {
|
|
26257
26740
|
const log = this.commitLog.get(groupId) ?? [];
|
|
26258
26741
|
log.push({
|
|
@@ -26275,6 +26758,12 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26275
26758
|
this.membersByChannel.set(channelId, remaining);
|
|
26276
26759
|
if (evictedDevice)
|
|
26277
26760
|
this.queues.delete(evictedDevice);
|
|
26761
|
+
if (vBindings) {
|
|
26762
|
+
for (const d of [...vBindings.keys()]) {
|
|
26763
|
+
if (d === evictedDevice || !req.removed_device_id && this.identityByDevice.get(d) === req.removed_identity_id)
|
|
26764
|
+
vBindings.delete(d);
|
|
26765
|
+
}
|
|
26766
|
+
}
|
|
26278
26767
|
} else {
|
|
26279
26768
|
const members = [.../* @__PURE__ */ new Set([...this.membersByChannel.get(channelId) ?? [], ...req.member_device_ids ?? []])];
|
|
26280
26769
|
this.membersByChannel.set(channelId, members);
|
|
@@ -26298,6 +26787,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26298
26787
|
});
|
|
26299
26788
|
}
|
|
26300
26789
|
}
|
|
26790
|
+
if (vBindings && bindNow.length > 0) {
|
|
26791
|
+
for (const [d, k] of bindNow)
|
|
26792
|
+
vBindings.set(d, { key: k, unusableSignaledAt: null });
|
|
26793
|
+
this.voiceLeafBindings.set(channelId, vBindings);
|
|
26794
|
+
}
|
|
26301
26795
|
for (const w of req.welcomes ?? []) {
|
|
26302
26796
|
this.q(w.target_device_id).push({
|
|
26303
26797
|
queue_id: "q-" + this._seq++,
|
|
@@ -26358,6 +26852,51 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26358
26852
|
async getCommitterWork(_channelId) {
|
|
26359
26853
|
return { adds: [], removes: [] };
|
|
26360
26854
|
}
|
|
26855
|
+
/** Task 24b (ruling §3 C): the carry-A signal, every call recorded. Gates
|
|
26856
|
+
* modelled: the group is a V (else 404), a binding exists (else 409), the
|
|
26857
|
+
* device's current vendable key differs from the bound one (409
|
|
26858
|
+
* `voice_leaf_key_current` when equal, `voice_no_current_key_package`
|
|
26859
|
+
* when it has none). An already-signalled row is an idempotent 202. The
|
|
26860
|
+
* bind and roster gates are the server's (backend tests). */
|
|
26861
|
+
signalVoiceLeafUnusableCalls = [];
|
|
26862
|
+
async signalVoiceLeafUnusable(groupId, deviceId) {
|
|
26863
|
+
this.signalVoiceLeafUnusableCalls.push({ groupId, deviceId });
|
|
26864
|
+
const channelId = this.channelByGroup.get(groupId);
|
|
26865
|
+
if (!channelId || !this.voiceChannelIds.has(channelId))
|
|
26866
|
+
return { status: 404, code: "Not Found" };
|
|
26867
|
+
const row = this.voiceLeafBindings.get(channelId)?.get(deviceId);
|
|
26868
|
+
if (!row)
|
|
26869
|
+
return { status: 409, code: "voice_leaf_binding_missing" };
|
|
26870
|
+
const current = this.currentLeafKey(deviceId);
|
|
26871
|
+
if (current === null)
|
|
26872
|
+
return { status: 409, code: "voice_no_current_key_package" };
|
|
26873
|
+
if (current === row.key)
|
|
26874
|
+
return { status: 409, code: "voice_leaf_key_current" };
|
|
26875
|
+
if (row.unusableSignaledAt === null)
|
|
26876
|
+
row.unusableSignaledAt = this.now();
|
|
26877
|
+
return { status: 202 };
|
|
26878
|
+
}
|
|
26879
|
+
/** Task 24b test seam: V's committer work as the PR-E server derives it,
|
|
26880
|
+
* for a test's `roster` (the server's roster devices): `adds` = roster
|
|
26881
|
+
* devices not in the mirror; `remove_devices` / `remove_device_leaves` =
|
|
26882
|
+
* roster devices whose binding is signalled unusable (NOT in `adds`: they
|
|
26883
|
+
* are still in the mirror). The re-Add follows only from a later call,
|
|
26884
|
+
* after the Remove landed and the mirror dropped the device. */
|
|
26885
|
+
voiceCommitterWork(channelId, roster) {
|
|
26886
|
+
const mirror = this.membersByChannel.get(channelId) ?? [];
|
|
26887
|
+
const bindings = this.voiceLeafBindings.get(channelId) ?? /* @__PURE__ */ new Map();
|
|
26888
|
+
const signalled = roster.filter((d) => mirror.includes(d) && bindings.get(d)?.unusableSignaledAt != null);
|
|
26889
|
+
return {
|
|
26890
|
+
adds: roster.filter((d) => !mirror.includes(d)),
|
|
26891
|
+
removes: [],
|
|
26892
|
+
removeDevices: signalled,
|
|
26893
|
+
removeDeviceLeaves: signalled.map((d) => ({
|
|
26894
|
+
deviceId: d,
|
|
26895
|
+
identityId: this.identityByDevice.get(d) ?? "",
|
|
26896
|
+
leafSignatureKeyHex: bindings.get(d).key
|
|
26897
|
+
}))
|
|
26898
|
+
};
|
|
26899
|
+
}
|
|
26361
26900
|
async acquireCommitLease(_channelId) {
|
|
26362
26901
|
return "fake-lease-token";
|
|
26363
26902
|
}
|
|
@@ -26450,6 +26989,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26450
26989
|
// Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
|
|
26451
26990
|
// the envelope's `identity_proof.signature` as `identity_signature` beside the
|
|
26452
26991
|
// unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
|
|
26992
|
+
/** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
|
|
26993
|
+
* the envelope's id. The REST send path keys the row by the envelope id
|
|
26994
|
+
* (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
|
|
26995
|
+
* path stores through `store_mls_message`, whose row id is server-generated,
|
|
26996
|
+
* so there the two differ and the envelope id is the sender's free choice. */
|
|
26453
26997
|
async ingestApplication(envelope, opts) {
|
|
26454
26998
|
const channelId = envelope.channel_id;
|
|
26455
26999
|
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
@@ -26461,7 +27005,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
|
|
|
26461
27005
|
continue;
|
|
26462
27006
|
this.q(dev).push({
|
|
26463
27007
|
queue_id: "q-" + this._seq++,
|
|
26464
|
-
message_id: envelope.message_id,
|
|
27008
|
+
message_id: opts?.rowMessageId ?? envelope.message_id,
|
|
26465
27009
|
group_id: groupId,
|
|
26466
27010
|
message_type: "application",
|
|
26467
27011
|
sender_device_id: senderDeviceId ?? "",
|
|
@@ -26845,6 +27389,7 @@ function openSchema(db) {
|
|
|
26845
27389
|
ensureRuntimeInboxDeliveryAttempts(db);
|
|
26846
27390
|
ensureRuntimeReplyIntent(db);
|
|
26847
27391
|
ensureRuntimeReplyIntentEnvelopeGroup(db);
|
|
27392
|
+
ensureRuntimeReplyIntentCredit(db);
|
|
26848
27393
|
ensureGroupLink(db);
|
|
26849
27394
|
ensureMlsCustody(db);
|
|
26850
27395
|
ensureToolGrantTables(db);
|
|
@@ -26925,6 +27470,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
|
|
|
26925
27470
|
throw e;
|
|
26926
27471
|
}
|
|
26927
27472
|
}
|
|
27473
|
+
function ensureRuntimeReplyIntentCredit(db) {
|
|
27474
|
+
const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
|
|
27475
|
+
const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
|
|
27476
|
+
if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
|
|
27477
|
+
return;
|
|
27478
|
+
try {
|
|
27479
|
+
db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
|
|
27480
|
+
} catch (e) {
|
|
27481
|
+
if (hasColumn())
|
|
27482
|
+
return;
|
|
27483
|
+
throw e;
|
|
27484
|
+
}
|
|
27485
|
+
}
|
|
26928
27486
|
var RECALL_STATE_COLUMNS = [
|
|
26929
27487
|
["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
|
|
26930
27488
|
["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
|
|
@@ -27976,8 +28534,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27976
28534
|
this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
|
|
27977
28535
|
const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
|
|
27978
28536
|
(identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27979
|
-
sealed_body, sealed_envelope, state, created_at, updated_at)
|
|
27980
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
|
|
28537
|
+
sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
|
|
28538
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
|
|
27981
28539
|
const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
|
|
27982
28540
|
if (count.n > RUNTIME_REPLY_INTENT_MAX) {
|
|
27983
28541
|
this.db.prepare(`DELETE FROM runtime_reply_intent
|
|
@@ -27987,7 +28545,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27987
28545
|
)`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
|
|
27988
28546
|
}
|
|
27989
28547
|
const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27990
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
28548
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
27991
28549
|
FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
|
|
27992
28550
|
if (!row)
|
|
27993
28551
|
throw new Error("reply intent vanished after claim");
|
|
@@ -27996,7 +28554,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
27996
28554
|
/** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
|
|
27997
28555
|
async listReplyIntents(inReplyTo) {
|
|
27998
28556
|
const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
27999
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
28557
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
28000
28558
|
FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
|
|
28001
28559
|
ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
|
|
28002
28560
|
const out = [];
|
|
@@ -28007,7 +28565,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
28007
28565
|
/** SEC-06 #18: re-read one intent by its key (null when absent). */
|
|
28008
28566
|
async getReplyIntent(scope, part) {
|
|
28009
28567
|
const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
|
|
28010
|
-
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
|
|
28568
|
+
sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
|
|
28011
28569
|
FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
|
|
28012
28570
|
return row ? this.openReplyIntent(row) : null;
|
|
28013
28571
|
}
|
|
@@ -28081,7 +28639,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
|
|
|
28081
28639
|
envelope,
|
|
28082
28640
|
envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
|
|
28083
28641
|
state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
|
|
28084
|
-
createdAt: Number(row.created_at)
|
|
28642
|
+
createdAt: Number(row.created_at),
|
|
28643
|
+
replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
|
|
28085
28644
|
};
|
|
28086
28645
|
}
|
|
28087
28646
|
async recordMemory(input) {
|
|
@@ -29851,8 +30410,8 @@ function decodeReplyBody(bytes) {
|
|
|
29851
30410
|
if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
|
|
29852
30411
|
throw new Error("reply intent body is malformed");
|
|
29853
30412
|
}
|
|
29854
|
-
function replyBodyMac(key, replyId, channelId, body) {
|
|
29855
|
-
return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
|
|
30413
|
+
function replyBodyMac(key, replyId, channelId, body, credit) {
|
|
30414
|
+
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");
|
|
29856
30415
|
}
|
|
29857
30416
|
function partOrdinal(part) {
|
|
29858
30417
|
const m = /^p:(\d+)$/.exec(part);
|
|
@@ -34693,6 +35252,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
|
|
|
34693
35252
|
}
|
|
34694
35253
|
const search = skillsJson.search;
|
|
34695
35254
|
if (search?.keyFile) paths.push(search.keyFile);
|
|
35255
|
+
const weather = skillsJson.weather;
|
|
35256
|
+
if (weather?.keyFile) paths.push(weather.keyFile);
|
|
34696
35257
|
return paths.map((path) => {
|
|
34697
35258
|
let st;
|
|
34698
35259
|
try {
|
|
@@ -34718,7 +35279,7 @@ function parseSecretPathsReport(raw) {
|
|
|
34718
35279
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
34719
35280
|
const r = raw;
|
|
34720
35281
|
const f = r.outside_by_field;
|
|
34721
|
-
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;
|
|
35282
|
+
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;
|
|
34722
35283
|
return {
|
|
34723
35284
|
mode: r.mode,
|
|
34724
35285
|
inside: r.inside,
|
|
@@ -34728,7 +35289,8 @@ function parseSecretPathsReport(raw) {
|
|
|
34728
35289
|
headers_file: f.headers_file,
|
|
34729
35290
|
postgres_url_file: f.postgres_url_file,
|
|
34730
35291
|
search_key_file: f.search_key_file,
|
|
34731
|
-
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
|
|
35292
|
+
...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
|
|
35293
|
+
...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
|
|
34732
35294
|
}
|
|
34733
35295
|
};
|
|
34734
35296
|
}
|
|
@@ -34808,6 +35370,7 @@ function readLocalReport(home, identityId) {
|
|
|
34808
35370
|
has_search_key: hasSearchKey,
|
|
34809
35371
|
has_llm_key: hasLlmKey,
|
|
34810
35372
|
has_extract_provider: doc.has_extract_provider === true,
|
|
35373
|
+
has_weather_key: doc.has_weather_key === true,
|
|
34811
35374
|
...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
|
|
34812
35375
|
legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
|
|
34813
35376
|
secret_paths: parseSecretPathsReport(doc.secret_paths)
|
|
@@ -34909,6 +35472,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
|
|
|
34909
35472
|
return removed;
|
|
34910
35473
|
}
|
|
34911
35474
|
|
|
35475
|
+
// src/reply-credit.ts
|
|
35476
|
+
var REPLY_CREDIT_FEATURE = "reply_credit_v1";
|
|
35477
|
+
var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
|
|
35478
|
+
var WEATHER_CREDIT_SUFFIX = `
|
|
35479
|
+
|
|
35480
|
+
${WEATHER_CREDIT_LINE}`;
|
|
35481
|
+
function isReplyCredit(v) {
|
|
35482
|
+
return v === "weatherapi";
|
|
35483
|
+
}
|
|
35484
|
+
function stripCreditSuffix(text) {
|
|
35485
|
+
return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
|
|
35486
|
+
}
|
|
35487
|
+
|
|
34912
35488
|
// src/grants/binding.ts
|
|
34913
35489
|
import { sha256 as sha25611 } from "@noble/hashes/sha256";
|
|
34914
35490
|
|
|
@@ -34916,9 +35492,10 @@ import { sha256 as sha25611 } from "@noble/hashes/sha256";
|
|
|
34916
35492
|
import { ed25519 as ed255195 } from "@noble/curves/ed25519";
|
|
34917
35493
|
var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
|
|
34918
35494
|
var MANIFEST_KNOWN_TOOLS = Object.freeze({
|
|
34919
|
-
owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
|
|
35495
|
+
owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
|
|
34920
35496
|
// web_extract: owner DM only, never team_room (spec §5.4, D5)
|
|
34921
|
-
|
|
35497
|
+
// Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
|
|
35498
|
+
team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
|
|
34922
35499
|
});
|
|
34923
35500
|
function manifestSignedBytes(manifest) {
|
|
34924
35501
|
const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
|
|
@@ -35242,20 +35819,30 @@ function bindTargets(raw, signed, equals) {
|
|
|
35242
35819
|
}
|
|
35243
35820
|
return { written, mismatches };
|
|
35244
35821
|
}
|
|
35245
|
-
|
|
35246
|
-
|
|
35822
|
+
var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
|
|
35823
|
+
var TOOL_FEATURE = Object.freeze({
|
|
35824
|
+
web_extract: "search_providers_v2",
|
|
35825
|
+
weather_forecast: "utility_tools_v1",
|
|
35826
|
+
currency_convert: "utility_tools_v1"
|
|
35827
|
+
});
|
|
35828
|
+
function toolAllowedByFeatures(tool, features) {
|
|
35829
|
+
if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
|
|
35830
|
+
return features.has(TOOL_FEATURE[tool]);
|
|
35831
|
+
}
|
|
35832
|
+
function signedManifestTools(manifest, audience, features) {
|
|
35833
|
+
return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
|
|
35247
35834
|
}
|
|
35248
|
-
function manifestPolicyFor(manifest, switches,
|
|
35835
|
+
function manifestPolicyFor(manifest, switches, features) {
|
|
35249
35836
|
return {
|
|
35250
35837
|
v: 1,
|
|
35251
35838
|
manifest_tools: [.../* @__PURE__ */ new Set([
|
|
35252
|
-
...switches.ownerDm ? signedManifestTools(manifest, "owner_dm",
|
|
35253
|
-
...switches.room ? signedManifestTools(manifest, "team_room",
|
|
35839
|
+
...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
|
|
35840
|
+
...switches.room ? signedManifestTools(manifest, "team_room", features) : []
|
|
35254
35841
|
])].sort(),
|
|
35255
35842
|
secret_path_mode: "enforce"
|
|
35256
35843
|
};
|
|
35257
35844
|
}
|
|
35258
|
-
function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true },
|
|
35845
|
+
function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
|
|
35259
35846
|
const mismatches = /* @__PURE__ */ new Set();
|
|
35260
35847
|
const cfg = manifest.config;
|
|
35261
35848
|
const out = {};
|
|
@@ -35284,7 +35871,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35284
35871
|
for (const name of Object.keys(files)) {
|
|
35285
35872
|
if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
|
|
35286
35873
|
}
|
|
35287
|
-
const ownerDmTools = signedManifestTools(manifest, "owner_dm",
|
|
35874
|
+
const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
|
|
35875
|
+
const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
|
|
35288
35876
|
const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
|
|
35289
35877
|
const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
|
|
35290
35878
|
const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
|
|
@@ -35305,17 +35893,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35305
35893
|
const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
|
|
35306
35894
|
searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
|
|
35307
35895
|
}
|
|
35896
|
+
const pw = isObj2(sj?.weather) ? sj.weather : void 0;
|
|
35897
|
+
const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
|
|
35308
35898
|
const skillsDoc = {
|
|
35309
35899
|
endpoints: eps.written,
|
|
35310
35900
|
databases: dbs.written,
|
|
35311
35901
|
flags: [...cfg.flags],
|
|
35312
35902
|
...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
|
|
35313
35903
|
...searchBlock ? { search: searchBlock } : {},
|
|
35904
|
+
...weatherBlock ? { weather: weatherBlock } : {},
|
|
35314
35905
|
// D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
|
|
35315
35906
|
search_signed: cfg.search !== void 0,
|
|
35316
35907
|
// D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
|
|
35317
35908
|
// otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
|
|
35318
|
-
...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm &&
|
|
35909
|
+
...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
|
|
35319
35910
|
};
|
|
35320
35911
|
out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
|
|
35321
35912
|
const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
|
|
@@ -35346,7 +35937,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
|
|
|
35346
35937
|
mismatches.add("legacy_assign");
|
|
35347
35938
|
legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
|
|
35348
35939
|
}
|
|
35349
|
-
const policy = manifestPolicyFor(manifest, switches,
|
|
35940
|
+
const policy = manifestPolicyFor(manifest, switches, features);
|
|
35350
35941
|
return {
|
|
35351
35942
|
mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
|
|
35352
35943
|
targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
|
|
@@ -35489,7 +36080,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
|
|
|
35489
36080
|
// D5: spends the owner's key and sends URLs out
|
|
35490
36081
|
fetch_url: Object.freeze(["egress"]),
|
|
35491
36082
|
sql_query: Object.freeze(["owner_credential"]),
|
|
35492
|
-
http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
|
|
36083
|
+
http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
|
|
36084
|
+
// Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
|
|
36085
|
+
// spends the owner's key.
|
|
36086
|
+
weather_forecast: Object.freeze(["owner_credential", "egress"]),
|
|
36087
|
+
currency_convert: Object.freeze(["egress"])
|
|
35493
36088
|
});
|
|
35494
36089
|
var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
|
|
35495
36090
|
function isLiveRow(r) {
|
|
@@ -37303,7 +37898,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37303
37898
|
}
|
|
37304
37899
|
let bound;
|
|
37305
37900
|
try {
|
|
37306
|
-
bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.
|
|
37901
|
+
bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
|
|
37307
37902
|
} catch (e) {
|
|
37308
37903
|
bound = null;
|
|
37309
37904
|
return this.bindFailed(manifest, payload.memory, e);
|
|
@@ -37324,7 +37919,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37324
37919
|
try {
|
|
37325
37920
|
this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
|
|
37326
37921
|
if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
|
|
37327
|
-
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.
|
|
37922
|
+
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
|
|
37328
37923
|
} catch (e) {
|
|
37329
37924
|
this.manifestBinding = null;
|
|
37330
37925
|
throw e;
|
|
@@ -37378,6 +37973,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37378
37973
|
async postRuntimeConfigReport(base, version, skillsJson) {
|
|
37379
37974
|
const local = readLocalReport(this.home, this.cfg.identityId);
|
|
37380
37975
|
const reportedSearchProviders = this.searchProvidersCapable();
|
|
37976
|
+
const reportedUtilityTools = this.utilityToolsCapable();
|
|
37381
37977
|
const report = {
|
|
37382
37978
|
applied_version: version,
|
|
37383
37979
|
paths: checkConfiguredPaths(skillsJson),
|
|
@@ -37395,6 +37991,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37395
37991
|
// Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
|
|
37396
37992
|
// the bridge withholds web_extract from the runtime.
|
|
37397
37993
|
has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
|
|
37994
|
+
// Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
|
|
37995
|
+
// while the bridge withholds weather_forecast from the runtime.
|
|
37996
|
+
has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
|
|
37398
37997
|
// D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
|
|
37399
37998
|
...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
|
|
37400
37999
|
// Same idea, for the BYOK LLM key file: only the runtime can see
|
|
@@ -37442,7 +38041,9 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37442
38041
|
// D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
|
|
37443
38042
|
search_providers_v2: reportedSearchProviders,
|
|
37444
38043
|
// D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
|
|
37445
|
-
search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
|
|
38044
|
+
search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
|
|
38045
|
+
// Weather/currency §8 item 5: a separate key, false with no runtime attached.
|
|
38046
|
+
utility_tools_v1: reportedUtilityTools
|
|
37446
38047
|
}
|
|
37447
38048
|
};
|
|
37448
38049
|
const reportedVerifyFailures = this.ownerVerifyFailures;
|
|
@@ -37464,6 +38065,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37464
38065
|
}
|
|
37465
38066
|
this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
|
|
37466
38067
|
this.lastReportedSearchProviders = reportedSearchProviders;
|
|
38068
|
+
this.lastReportedUtilityTools = reportedUtilityTools;
|
|
37467
38069
|
if (version) writeServiceLog("info", "runtime_config.applied", { version });
|
|
37468
38070
|
else writeServiceLog("info", "runtime_config.legacy_memory_reported");
|
|
37469
38071
|
}
|
|
@@ -37533,7 +38135,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
37533
38135
|
this.startedAt = Date.now();
|
|
37534
38136
|
await this.restoreRuntimeInbox();
|
|
37535
38137
|
await this.loadToolGrantState();
|
|
37536
|
-
this.recomputeManifestPolicy(
|
|
38138
|
+
this.recomputeManifestPolicy(NO_FEATURES);
|
|
37537
38139
|
try {
|
|
37538
38140
|
this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
|
|
37539
38141
|
} catch (e) {
|
|
@@ -38516,13 +39118,13 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38516
39118
|
* absent, because the id is derived.
|
|
38517
39119
|
*/
|
|
38518
39120
|
async send(channelId, text, messageId, opts = {}) {
|
|
38519
|
-
const body = { kind: "text", text };
|
|
39121
|
+
const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
|
|
38520
39122
|
if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
|
|
38521
39123
|
const id = resolveMessageId(messageId);
|
|
38522
39124
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
|
|
38523
39125
|
const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
|
|
38524
39126
|
await this.publishBody(channelId, body, id);
|
|
38525
|
-
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
|
|
39127
|
+
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
|
|
38526
39128
|
return { message_id: id, status: "sent" };
|
|
38527
39129
|
}
|
|
38528
39130
|
/** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
|
|
@@ -38541,7 +39143,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38541
39143
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
|
|
38542
39144
|
const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
|
|
38543
39145
|
await this.publishBody(channelId, body, id);
|
|
38544
|
-
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
|
|
39146
|
+
this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
|
|
38545
39147
|
return { message_id: id, status: "sent" };
|
|
38546
39148
|
}
|
|
38547
39149
|
/**
|
|
@@ -38674,18 +39276,24 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38674
39276
|
const meta = this.freshMeta(capChannel);
|
|
38675
39277
|
if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
|
|
38676
39278
|
}
|
|
38677
|
-
/**
|
|
38678
|
-
|
|
39279
|
+
/**
|
|
39280
|
+
* Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
|
|
39281
|
+
* reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
|
|
39282
|
+
* `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
|
|
39283
|
+
* undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
|
|
39284
|
+
*/
|
|
39285
|
+
afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
|
|
39286
|
+
const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
|
|
38679
39287
|
if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
|
|
38680
39288
|
const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
|
|
38681
|
-
const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(
|
|
39289
|
+
const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
|
|
38682
39290
|
if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
|
|
38683
|
-
if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(
|
|
38684
|
-
this.callBuffer.appendAgent(channelId,
|
|
39291
|
+
if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
|
|
39292
|
+
this.callBuffer.appendAgent(channelId, recorded, "reply");
|
|
38685
39293
|
}
|
|
38686
39294
|
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";
|
|
38687
|
-
if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId,
|
|
38688
|
-
if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId,
|
|
39295
|
+
if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
|
|
39296
|
+
if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
|
|
38689
39297
|
}
|
|
38690
39298
|
/**
|
|
38691
39299
|
* Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
|
|
@@ -38763,14 +39371,14 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38763
39371
|
const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
|
|
38764
39372
|
if (!k) throw new Error("idempotent send without a key");
|
|
38765
39373
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
|
|
38766
|
-
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
|
|
38767
|
-
const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
|
|
39374
|
+
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
|
|
39375
|
+
const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
|
|
38768
39376
|
if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
|
|
38769
39377
|
throw new IdempotencyConflictError();
|
|
38770
39378
|
}
|
|
38771
39379
|
return k;
|
|
38772
39380
|
}
|
|
38773
|
-
return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
|
|
39381
|
+
return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
|
|
38774
39382
|
});
|
|
38775
39383
|
return this.publishIntent(key, opts);
|
|
38776
39384
|
}
|
|
@@ -38779,22 +39387,23 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38779
39387
|
* the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
|
|
38780
39388
|
* after a slot collision: the body must go to a NEW part.
|
|
38781
39389
|
*/
|
|
38782
|
-
async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
|
|
39390
|
+
async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
|
|
39391
|
+
const credit = replyCredit ?? void 0;
|
|
38783
39392
|
const scope = `reply:${inReplyTo}`;
|
|
38784
39393
|
let latest = null;
|
|
38785
39394
|
for (const intent of await this.store.listReplyIntents(inReplyTo)) {
|
|
38786
39395
|
const n = intent.scope === scope ? partOrdinal(intent.part) : null;
|
|
38787
39396
|
if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
|
|
38788
39397
|
}
|
|
38789
|
-
if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
|
|
39398
|
+
if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
|
|
38790
39399
|
return { scope, part: latest.intent.part, inReplyTo };
|
|
38791
39400
|
}
|
|
38792
39401
|
let ordinal = latest ? latest.ordinal + 1 : 0;
|
|
38793
39402
|
for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
|
|
38794
39403
|
const k = { scope, part: `p:${ordinal}`, inReplyTo };
|
|
38795
39404
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
|
|
38796
|
-
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
|
|
38797
|
-
const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
|
|
39405
|
+
const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
|
|
39406
|
+
const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
|
|
38798
39407
|
if (created) return k;
|
|
38799
39408
|
if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
|
|
38800
39409
|
writeServiceLog("info", "reply.part_slot_taken", { attempt: i + 1 });
|
|
@@ -38839,6 +39448,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38839
39448
|
}
|
|
38840
39449
|
const bytes = intent.body;
|
|
38841
39450
|
const body = decodeReplyBody(bytes);
|
|
39451
|
+
const recordText = this.recordTextOf(intent.replyCredit, body);
|
|
39452
|
+
if (recordText === void 0) {
|
|
39453
|
+
writeServiceLog("error", "reply.intent_integrity_refused");
|
|
39454
|
+
this.store.deletePendingReplyIntent(key.scope, key.part);
|
|
39455
|
+
throw new ReplyIntentIntegrityError();
|
|
39456
|
+
}
|
|
38842
39457
|
const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
|
|
38843
39458
|
const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
|
|
38844
39459
|
try {
|
|
@@ -38867,14 +39482,17 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38867
39482
|
const inReplyTo = intent.inReplyTo;
|
|
38868
39483
|
const next = await this.withReplyLock(
|
|
38869
39484
|
JSON.stringify(["scope", key.scope]),
|
|
38870
|
-
() =>
|
|
39485
|
+
() => (
|
|
39486
|
+
// r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
|
|
39487
|
+
this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
|
|
39488
|
+
)
|
|
38871
39489
|
);
|
|
38872
39490
|
return this.publishIntent(next, opts, advances + 1);
|
|
38873
39491
|
}
|
|
38874
39492
|
throw e;
|
|
38875
39493
|
}
|
|
38876
39494
|
this.store.markReplyIntentSent(key.scope, key.part);
|
|
38877
|
-
this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
|
|
39495
|
+
this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
|
|
38878
39496
|
return { message_id: intent.replyMessageId, status: "sent" };
|
|
38879
39497
|
});
|
|
38880
39498
|
}
|
|
@@ -38884,9 +39502,21 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
38884
39502
|
*/
|
|
38885
39503
|
intentVerifies(intent) {
|
|
38886
39504
|
if (!intent.body) return false;
|
|
39505
|
+
const credit = intent.replyCredit ?? null;
|
|
39506
|
+
if (credit !== null && !isReplyCredit(credit)) return false;
|
|
38887
39507
|
const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
|
|
38888
39508
|
if (id !== intent.replyMessageId) return false;
|
|
38889
|
-
return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
|
|
39509
|
+
return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
|
|
39510
|
+
}
|
|
39511
|
+
/**
|
|
39512
|
+
* Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
|
|
39513
|
+
* for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
|
|
39514
|
+
* credit, a credited a2a body, or a credited text without the exact suffix).
|
|
39515
|
+
*/
|
|
39516
|
+
recordTextOf(credit, body) {
|
|
39517
|
+
if (credit === null) return body.kind === "text" ? body.text : null;
|
|
39518
|
+
if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
|
|
39519
|
+
return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
|
|
38890
39520
|
}
|
|
38891
39521
|
/**
|
|
38892
39522
|
* SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
|
|
@@ -40293,6 +40923,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40293
40923
|
* frozen at receipt) AND a window-eligible turn (voiceWindowEligible), whatever the window size. Two call sites: the voice
|
|
40294
40924
|
* 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;
|
|
40295
40925
|
* otherwise nothing is written (H-4). Logs are content-free (U17). Never throws.
|
|
40926
|
+
*
|
|
40927
|
+
* Bridge 0.33.1 (architect Q2, C5 not accepted): a turn on a voice channel V carries `parent`, its P from the
|
|
40928
|
+
* per-frame verdict. The scope is ownerScopeForWrite(P) (P's pin and roster, as for a turn on P), the item keeps
|
|
40929
|
+
* channelId = V (so V's own capture still guards it in the store), and P's receipt capture is checked at commit,
|
|
40930
|
+
* inside the memory mutex: a purge or room clear of P since receipt writes nothing. No P capture at receipt, or no
|
|
40931
|
+
* persisted V -> P link (the purge of P reaches V's items only through it): nothing is written.
|
|
40296
40932
|
*/
|
|
40297
40933
|
async tapUnifiedVoice(m, c) {
|
|
40298
40934
|
const skip = (reason) => this.logUnifiedTapSkip(reason);
|
|
@@ -40301,9 +40937,22 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40301
40937
|
if (m.authorization?.relay_speaker !== "owner" || m.speaker === void 0) return skip("audience");
|
|
40302
40938
|
if (c.stamp === void 0) return skip("stamp_missing");
|
|
40303
40939
|
if (c.capture.purgeEpoch < 0) return skip("purged");
|
|
40304
|
-
const
|
|
40940
|
+
const parent3 = c.parent;
|
|
40941
|
+
const parentCapture = parent3?.capture;
|
|
40942
|
+
if (parent3 === void 0 && this.voiceChannels.isVoice(m.channel_id)) return skip("scope");
|
|
40943
|
+
if (parent3 !== void 0) {
|
|
40944
|
+
if (parentCapture === void 0 || parentCapture.purgeEpoch < 0) return skip("scope");
|
|
40945
|
+
if (this.store.voiceChannelParent(m.channel_id) !== parent3.channelId) return skip("scope");
|
|
40946
|
+
}
|
|
40947
|
+
const scopeId = await this.ownerScopeForWrite(parent3?.channelId ?? m.channel_id);
|
|
40305
40948
|
if (scopeId === null) return skip("scope");
|
|
40306
40949
|
if (parseOwnerDmScopeId(scopeId)?.ownerIdentityId !== m.speaker.identityId) return skip("scope");
|
|
40950
|
+
const precommit = parent3 === void 0 || parentCapture === void 0 ? void 0 : () => {
|
|
40951
|
+
const now = this.store.itemCapture(parent3.channelId);
|
|
40952
|
+
if (now.purgeEpoch !== parentCapture.purgeEpoch) return "purged";
|
|
40953
|
+
if (now.roomClearGeneration !== parentCapture.roomClearGeneration) return "room_cleared";
|
|
40954
|
+
return null;
|
|
40955
|
+
};
|
|
40307
40956
|
const r = await this.items.commit({
|
|
40308
40957
|
scopeKind: "owner_dm",
|
|
40309
40958
|
scopeId,
|
|
@@ -40314,7 +40963,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
40314
40963
|
source: "owner_voice",
|
|
40315
40964
|
trust: OWNER_VOICE_TRUST,
|
|
40316
40965
|
text: typeof m.text === "string" ? m.text : "",
|
|
40317
|
-
expect: { ...c.capture, clearGeneration: c.stamp }
|
|
40966
|
+
expect: { ...c.capture, clearGeneration: c.stamp },
|
|
40967
|
+
...precommit ? { precommit } : {}
|
|
40318
40968
|
});
|
|
40319
40969
|
return r.ok ? { ok: true } : { ok: false, reason: r.reason };
|
|
40320
40970
|
} catch {
|
|
@@ -42493,11 +43143,20 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42493
43143
|
const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
|
|
42494
43144
|
const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
|
|
42495
43145
|
const recallModeEpochAtReceipt = this.store.recallState().modeEpoch;
|
|
43146
|
+
const unifiedVoiceParentAt = (p) => {
|
|
43147
|
+
const cap = itemCaptureAtReceipt.get(p);
|
|
43148
|
+
return { channelId: p, capture: cap === void 0 ? void 0 : { ...cap, purgeEpoch: purgeEpochAtReceipt.get(p) ?? -1 } };
|
|
43149
|
+
};
|
|
42496
43150
|
for (const m of entries) {
|
|
42497
43151
|
this.lastInboundAt.set(m.channelId, receivedAt);
|
|
42498
43152
|
if (m.voiceMode === true) this.heartbeat.noteVoiceActivity();
|
|
42499
43153
|
if (!purgeEpochAtReceipt.has(m.channelId)) purgeEpochAtReceipt.set(m.channelId, this.store.conversationPurgeEpoch(m.channelId));
|
|
42500
43154
|
if (this.unifiedOn && !itemCaptureAtReceipt.has(m.channelId)) itemCaptureAtReceipt.set(m.channelId, this.store.itemCapture(m.channelId));
|
|
43155
|
+
const receiptParent = this.voiceChannels.parentOf(m.channelId);
|
|
43156
|
+
if (receiptParent !== void 0) {
|
|
43157
|
+
if (!purgeEpochAtReceipt.has(receiptParent)) purgeEpochAtReceipt.set(receiptParent, this.store.conversationPurgeEpoch(receiptParent));
|
|
43158
|
+
if (this.unifiedOn && !itemCaptureAtReceipt.has(receiptParent)) itemCaptureAtReceipt.set(receiptParent, this.store.itemCapture(receiptParent));
|
|
43159
|
+
}
|
|
42501
43160
|
this.recallReceipt.set(m.messageId, { purgeEpoch: purgeEpochAtReceipt.get(m.channelId), modeEpoch: recallModeEpochAtReceipt });
|
|
42502
43161
|
receiptIds.push(m.messageId);
|
|
42503
43162
|
const stamp = voiceStamp.get(m.channelId);
|
|
@@ -42843,6 +43502,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42843
43502
|
pending: Promise.resolve(),
|
|
42844
43503
|
purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
|
|
42845
43504
|
itemCapture: itemCaptureAtReceipt.get(m.channelId),
|
|
43505
|
+
...voiceParent !== void 0 ? { voiceParent: unifiedVoiceParentAt(voiceParent) } : {},
|
|
42846
43506
|
bufferGeneration
|
|
42847
43507
|
};
|
|
42848
43508
|
this.voiceCoalescing.set(windowKey, win);
|
|
@@ -42873,7 +43533,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42873
43533
|
const merged = { ...held, text, memory: windowMemory };
|
|
42874
43534
|
if (this.unifiedOn) {
|
|
42875
43535
|
const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
|
|
42876
|
-
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
|
|
43536
|
+
const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 }, ...win.voiceParent ? { parent: win.voiceParent } : {} });
|
|
42877
43537
|
this.unifiedWork.add(work);
|
|
42878
43538
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42879
43539
|
}
|
|
@@ -42963,7 +43623,11 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42963
43623
|
);
|
|
42964
43624
|
if (windowEligible && turnAuthorization.relay_speaker === "owner") {
|
|
42965
43625
|
const snap = itemCaptureAtReceipt.get(m.channelId) ?? { ...this.store.itemCapture(m.channelId), purgeEpoch: -1 };
|
|
42966
|
-
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43626
|
+
const work = this.tapUnifiedVoice(runtimeEntry(false, { recent: [], facts: [] }), {
|
|
43627
|
+
stamp: this.ingestGeneration.get(m.messageId),
|
|
43628
|
+
capture: { ...snap, purgeEpoch: purgeEpochAtReceipt.get(m.channelId) ?? -1 },
|
|
43629
|
+
...voiceParent !== void 0 ? { parent: unifiedVoiceParentAt(voiceParent) } : {}
|
|
43630
|
+
});
|
|
42967
43631
|
this.unifiedWork.add(work);
|
|
42968
43632
|
void work.finally(() => this.unifiedWork.delete(work));
|
|
42969
43633
|
}
|
|
@@ -42976,7 +43640,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
42976
43640
|
this.memory,
|
|
42977
43641
|
m.channelId,
|
|
42978
43642
|
m.envelope.sender_id,
|
|
42979
|
-
parsed.text,
|
|
43643
|
+
m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
|
|
42980
43644
|
m.messageId,
|
|
42981
43645
|
m.voiceMode ? "voice" : "text",
|
|
42982
43646
|
purgeEpochAtTap
|
|
@@ -43774,21 +44438,49 @@ ${channelId}`;
|
|
|
43774
44438
|
*/
|
|
43775
44439
|
searchProvidersSockets = /* @__PURE__ */ new Map();
|
|
43776
44440
|
/**
|
|
43777
|
-
*
|
|
43778
|
-
*
|
|
44441
|
+
* Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
|
|
44442
|
+
* runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
|
|
44443
|
+
* sees a utility name.
|
|
44444
|
+
*/
|
|
44445
|
+
utilityToolsSockets = /* @__PURE__ */ new Map();
|
|
44446
|
+
/**
|
|
44447
|
+
* The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
|
|
44448
|
+
* observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
|
|
43779
44449
|
*/
|
|
43780
|
-
|
|
44450
|
+
capabilityState = null;
|
|
43781
44451
|
searchCapabilityRefetchTimer = null;
|
|
43782
44452
|
/** When the current burst's first change happened (valid while the timer is set). */
|
|
43783
44453
|
searchCapabilityBurstStart = 0;
|
|
43784
44454
|
/** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
|
|
43785
44455
|
lastReportedSearchProviders = null;
|
|
44456
|
+
/** U6: the utility_tools_v1 value in the last report posted (null until one is). */
|
|
44457
|
+
lastReportedUtilityTools = null;
|
|
43786
44458
|
noteSearchProvidersSocket(token, declared) {
|
|
43787
44459
|
this.searchProvidersSockets.set(token, declared);
|
|
43788
|
-
this.
|
|
44460
|
+
this.onCapabilityMaybeChanged();
|
|
43789
44461
|
}
|
|
43790
44462
|
clearSearchProvidersSocket(token) {
|
|
43791
|
-
if (this.searchProvidersSockets.delete(token)) this.
|
|
44463
|
+
if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
|
|
44464
|
+
}
|
|
44465
|
+
noteUtilityToolsSocket(token, declared) {
|
|
44466
|
+
this.utilityToolsSockets.set(token, declared);
|
|
44467
|
+
this.onCapabilityMaybeChanged();
|
|
44468
|
+
}
|
|
44469
|
+
clearUtilityToolsSocket(token) {
|
|
44470
|
+
if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
|
|
44471
|
+
}
|
|
44472
|
+
/** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
|
|
44473
|
+
utilityToolsCapable() {
|
|
44474
|
+
if (this.utilityToolsSockets.size === 0) return false;
|
|
44475
|
+
for (const v of this.utilityToolsSockets.values()) if (!v) return false;
|
|
44476
|
+
return true;
|
|
44477
|
+
}
|
|
44478
|
+
/** U6: the features every attached runtime socket declared, for the policy functions. */
|
|
44479
|
+
capabilityFeatures() {
|
|
44480
|
+
const out = /* @__PURE__ */ new Set();
|
|
44481
|
+
if (this.searchProvidersCapable()) out.add("search_providers_v2");
|
|
44482
|
+
if (this.utilityToolsCapable()) out.add("utility_tools_v1");
|
|
44483
|
+
return out;
|
|
43792
44484
|
}
|
|
43793
44485
|
/** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
|
|
43794
44486
|
searchProvidersCapable() {
|
|
@@ -43815,14 +44507,14 @@ ${channelId}`;
|
|
|
43815
44507
|
* D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
|
|
43816
44508
|
* switches and the new capability, then a debounced refetch (one per burst) only when needed.
|
|
43817
44509
|
*/
|
|
43818
|
-
|
|
43819
|
-
const
|
|
43820
|
-
const prev = this.
|
|
43821
|
-
if (
|
|
43822
|
-
this.
|
|
43823
|
-
this.recomputeManifestPolicy(
|
|
44510
|
+
onCapabilityMaybeChanged() {
|
|
44511
|
+
const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
|
|
44512
|
+
const prev = this.capabilityState;
|
|
44513
|
+
if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
|
|
44514
|
+
this.capabilityState = next;
|
|
44515
|
+
this.recomputeManifestPolicy(this.capabilityFeatures());
|
|
43824
44516
|
if (this.closingDown) return;
|
|
43825
|
-
if (prev === null && !
|
|
44517
|
+
if (prev === null && !next.search && !next.utility) return;
|
|
43826
44518
|
const now = Date.now();
|
|
43827
44519
|
if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
|
|
43828
44520
|
else this.searchCapabilityBurstStart = now;
|
|
@@ -43840,7 +44532,7 @@ ${channelId}`;
|
|
|
43840
44532
|
searchCapabilityRefetchNeeded() {
|
|
43841
44533
|
if (!this.bootConfigSettled || this.closingDown) return false;
|
|
43842
44534
|
const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
|
|
43843
|
-
return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
|
|
44535
|
+
return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
|
|
43844
44536
|
}
|
|
43845
44537
|
/**
|
|
43846
44538
|
* D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
|
|
@@ -43848,15 +44540,16 @@ ${channelId}`;
|
|
|
43848
44540
|
* lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
|
|
43849
44541
|
* Content-free; a write failure is logged and the debounced refetch tries again.
|
|
43850
44542
|
*/
|
|
43851
|
-
recomputeManifestPolicy(
|
|
44543
|
+
recomputeManifestPolicy(features) {
|
|
43852
44544
|
const pin = this.cfg.ownerGrantPin?.pin ?? null;
|
|
43853
44545
|
const manifest = pin !== null ? this.toolGrantManifest : null;
|
|
43854
44546
|
try {
|
|
43855
44547
|
if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
|
|
43856
|
-
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants },
|
|
44548
|
+
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
|
|
43857
44549
|
return;
|
|
43858
44550
|
}
|
|
43859
|
-
|
|
44551
|
+
const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
|
|
44552
|
+
if (withheld.size === 0) return;
|
|
43860
44553
|
const path = join19(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
|
|
43861
44554
|
if (!existsSync5(path)) return;
|
|
43862
44555
|
let doc;
|
|
@@ -43866,10 +44559,10 @@ ${channelId}`;
|
|
|
43866
44559
|
return;
|
|
43867
44560
|
}
|
|
43868
44561
|
const tools = doc?.manifest_tools;
|
|
43869
|
-
if (!Array.isArray(tools) || !tools.
|
|
44562
|
+
if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
|
|
43870
44563
|
this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
|
|
43871
44564
|
v: 1,
|
|
43872
|
-
manifest_tools: tools.filter((t) => typeof t === "string" && t
|
|
44565
|
+
manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
|
|
43873
44566
|
secret_path_mode: "enforce"
|
|
43874
44567
|
});
|
|
43875
44568
|
} catch (e) {
|
|
@@ -44260,6 +44953,9 @@ var BRIDGE_ERRORS = {
|
|
|
44260
44953
|
// C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
|
|
44261
44954
|
// session (WS 4409), so this copy stopped. Every local route but /healthz.
|
|
44262
44955
|
session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
|
|
44956
|
+
// Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
|
|
44957
|
+
// the inbound is retired, so the model (and the weather call) never runs again for it.
|
|
44958
|
+
reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
|
|
44263
44959
|
internal: { http: 500, message: "internal error" }
|
|
44264
44960
|
};
|
|
44265
44961
|
function errorBody(code, detail) {
|
|
@@ -44289,6 +44985,7 @@ function withConsumerGrants(m, perToolConsumer) {
|
|
|
44289
44985
|
}
|
|
44290
44986
|
var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
|
|
44291
44987
|
var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
|
|
44988
|
+
var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
|
|
44292
44989
|
var CONSTANT_TOOL_NAMES = Object.freeze({
|
|
44293
44990
|
owner_dm: Object.freeze(["current_time", "web_search"]),
|
|
44294
44991
|
team_room: Object.freeze(["current_time"])
|
|
@@ -44320,6 +45017,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
|
|
|
44320
45017
|
if (kept.length === a.tool_grants.length) return m;
|
|
44321
45018
|
return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
|
|
44322
45019
|
}
|
|
45020
|
+
var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
|
|
45021
|
+
function withUtilityCapability(m, consumerHasFeature) {
|
|
45022
|
+
if (consumerHasFeature) return m;
|
|
45023
|
+
const a = m.authorization;
|
|
45024
|
+
if (!a) return m;
|
|
45025
|
+
const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
|
|
45026
|
+
if (kept.length === a.tool_grants.length) return m;
|
|
45027
|
+
return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
|
|
45028
|
+
}
|
|
44323
45029
|
|
|
44324
45030
|
// src/http-server.ts
|
|
44325
45031
|
function writeSse(res, msgs, onWriteError) {
|
|
@@ -44343,7 +45049,7 @@ data: ${JSON.stringify(msgs[i])}
|
|
|
44343
45049
|
function deliverSseBatch(res, batch, agent, onWriteError) {
|
|
44344
45050
|
const withheld = agent.harnessGrantsWithheld();
|
|
44345
45051
|
const provider = agent.signedSearchProvider();
|
|
44346
|
-
const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
|
|
45052
|
+
const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
|
|
44347
45053
|
const leftIds = new Set(left3.map((message) => message.message_id));
|
|
44348
45054
|
agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
|
|
44349
45055
|
if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
|
|
@@ -44375,7 +45081,7 @@ var OTHER_CAP = 8 * 1024;
|
|
|
44375
45081
|
var a2aSchema = z3.object({
|
|
44376
45082
|
a2a_version: z3.string().min(1),
|
|
44377
45083
|
kind: z3.enum(["message", "task", "artifact"]),
|
|
44378
|
-
object: z3.record(z3.unknown())
|
|
45084
|
+
object: z3.record(z3.string(), z3.unknown())
|
|
44379
45085
|
});
|
|
44380
45086
|
var sendAttachmentSchema = z3.object({
|
|
44381
45087
|
channel_id: z3.string().min(1),
|
|
@@ -44617,7 +45323,7 @@ function createHttpServer(agent, opts) {
|
|
|
44617
45323
|
const drained = await agent.drain(channel, wait);
|
|
44618
45324
|
const withheld = agent.harnessGrantsWithheld();
|
|
44619
45325
|
const provider = agent.signedSearchProvider();
|
|
44620
|
-
return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
|
|
45326
|
+
return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
|
|
44621
45327
|
}
|
|
44622
45328
|
if (method === "GET" && path === "/v1/events") {
|
|
44623
45329
|
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
@@ -44792,6 +45498,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44792
45498
|
perToolGrants: false,
|
|
44793
45499
|
manifestGrants: false,
|
|
44794
45500
|
searchProviders: false,
|
|
45501
|
+
utilityTools: false,
|
|
44795
45502
|
// helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
|
|
44796
45503
|
deliveryOpen: helloWaitMs <= 0,
|
|
44797
45504
|
deliveryToken: /* @__PURE__ */ new Map(),
|
|
@@ -44845,14 +45552,14 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44845
45552
|
continue;
|
|
44846
45553
|
}
|
|
44847
45554
|
const out = withConsumerNotes(
|
|
44848
|
-
withSearchCapability(
|
|
45555
|
+
withUtilityCapability(withSearchCapability(
|
|
44849
45556
|
withManifestCapability(
|
|
44850
45557
|
withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
|
|
44851
45558
|
session.manifestGrants
|
|
44852
45559
|
),
|
|
44853
45560
|
session.searchProviders,
|
|
44854
45561
|
agent.signedSearchProvider()
|
|
44855
|
-
),
|
|
45562
|
+
), session.utilityTools),
|
|
44856
45563
|
batch[i],
|
|
44857
45564
|
session,
|
|
44858
45565
|
agent
|
|
@@ -44909,6 +45616,7 @@ function attachAgentWebSocket(server, agent, opts) {
|
|
|
44909
45616
|
if (session.perToolGrants) agent.clearPerToolConsumer(session);
|
|
44910
45617
|
if (session.manifestGrants) agent.clearManifestConsumer(session);
|
|
44911
45618
|
agent.clearSearchProvidersSocket(session);
|
|
45619
|
+
agent.clearUtilityToolsSocket(session);
|
|
44912
45620
|
};
|
|
44913
45621
|
current = { sock, cleanup, sweepStale };
|
|
44914
45622
|
sock.on("close", cleanup);
|
|
@@ -44949,7 +45657,10 @@ function sendJson(sock, body) {
|
|
|
44949
45657
|
} catch {
|
|
44950
45658
|
}
|
|
44951
45659
|
}
|
|
45660
|
+
var ReplyCreditUnknownError = class extends Error {
|
|
45661
|
+
};
|
|
44952
45662
|
function sendErrorCode(e) {
|
|
45663
|
+
if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
|
|
44953
45664
|
if (e instanceof ChannelPendingError) return "channel_pending";
|
|
44954
45665
|
if (e instanceof RateLimitedError) return "rate_limited";
|
|
44955
45666
|
if (e instanceof AgentPausedSendError) return "agent_paused";
|
|
@@ -45010,6 +45721,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
45010
45721
|
agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
|
|
45011
45722
|
session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
|
|
45012
45723
|
agent.noteSearchProvidersSocket(session, session.searchProviders);
|
|
45724
|
+
session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
|
|
45725
|
+
agent.noteUtilityToolsSocket(session, session.utilityTools);
|
|
45013
45726
|
if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
|
|
45014
45727
|
agent.noteNotesConsumer();
|
|
45015
45728
|
}
|
|
@@ -45017,7 +45730,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
45017
45730
|
if (session.recallConsumer) agent.noteRecallConsumer();
|
|
45018
45731
|
session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
|
|
45019
45732
|
if (session.unifiedConsumer) agent.noteUnifiedConsumer();
|
|
45020
|
-
sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
|
|
45733
|
+
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] });
|
|
45021
45734
|
if (!session.deliveryOpen) {
|
|
45022
45735
|
session.deliveryOpen = true;
|
|
45023
45736
|
flush();
|
|
@@ -45046,7 +45759,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
45046
45759
|
}
|
|
45047
45760
|
const replyFailed = (e, inReplyTo) => {
|
|
45048
45761
|
const code = sendErrorCode(e);
|
|
45049
|
-
|
|
45762
|
+
const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
|
|
45763
|
+
if (terminal && typeof inReplyTo === "string") {
|
|
45050
45764
|
inflight.delete(inReplyTo);
|
|
45051
45765
|
session.deliveryToken.delete(inReplyTo);
|
|
45052
45766
|
session.shownRecall.delete(inReplyTo);
|
|
@@ -45054,7 +45768,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
45054
45768
|
agent.ackRuntimeMessages([inReplyTo]);
|
|
45055
45769
|
}
|
|
45056
45770
|
sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
|
|
45057
|
-
if (
|
|
45771
|
+
if (terminal) flush();
|
|
45058
45772
|
};
|
|
45059
45773
|
if (m.type === "send") {
|
|
45060
45774
|
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
@@ -45071,8 +45785,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
|
|
|
45071
45785
|
}
|
|
45072
45786
|
const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
|
|
45073
45787
|
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);
|
|
45788
|
+
if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
|
|
45789
|
+
writeServiceLog("error", "reply.credit_unknown");
|
|
45790
|
+
replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
|
|
45791
|
+
return;
|
|
45792
|
+
}
|
|
45074
45793
|
const sendOpts = {
|
|
45075
45794
|
agentTurn: isAgentTurn(agent, m),
|
|
45795
|
+
...isReplyCredit(m.credit) ? { credit: m.credit } : {},
|
|
45076
45796
|
...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
|
|
45077
45797
|
...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
|
|
45078
45798
|
...callBufferReply ? { callBufferReply: true } : {}
|