@securecoms/agent-bridge 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13317,17 +13317,17 @@ async function makeNobleSignatureImpl(alg) {
13317
13317
  };
13318
13318
  }
13319
13319
  try {
13320
- const { ed25519: ed255194 } = await Promise.resolve().then(() => (init_ed25519(), ed25519_exports));
13320
+ const { ed25519: ed255197 } = await Promise.resolve().then(() => (init_ed25519(), ed25519_exports));
13321
13321
  return {
13322
13322
  async sign(signKey, message) {
13323
- return ed255194.sign(message, signKey);
13323
+ return ed255197.sign(message, signKey);
13324
13324
  },
13325
13325
  async verify(publicKey, message, signature) {
13326
- return ed255194.verify(signature, message, publicKey);
13326
+ return ed255197.verify(signature, message, publicKey);
13327
13327
  },
13328
13328
  async keygen() {
13329
- const signKey = ed255194.utils.randomSecretKey();
13330
- return { signKey, publicKey: ed255194.getPublicKey(signKey) };
13329
+ const signKey = ed255197.utils.randomSecretKey();
13330
+ return { signKey, publicKey: ed255197.getPublicKey(signKey) };
13331
13331
  }
13332
13332
  };
13333
13333
  } catch (err) {
@@ -17605,11 +17605,11 @@ var MlsOperationQueue = class _MlsOperationQueue {
17605
17605
  const prev = this.chains.get(channelId) ?? Promise.resolve();
17606
17606
  const entry = { group: this.group, kind: this.kind, key: channelId, released: false };
17607
17607
  const parentHeld = als?.getStore() ?? [];
17608
- const guarded = als ? () => als.run([...parentHeld, entry], op) : op;
17608
+ const guarded2 = als ? () => als.run([...parentHeld, entry], op) : op;
17609
17609
  const settle = (p) => p.finally(() => {
17610
17610
  entry.released = true;
17611
17611
  });
17612
- const next = settle(prev.then(guarded, guarded));
17612
+ const next = settle(prev.then(guarded2, guarded2));
17613
17613
  this.chains.set(channelId, next);
17614
17614
  this.inFlight.set(channelId, (this.inFlight.get(channelId) ?? 0) + 1);
17615
17615
  try {
@@ -23342,7 +23342,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23342
23342
  retryCount: 0,
23343
23343
  expires_at: row.expires_at,
23344
23344
  voiceMode: row.voice_mode ?? void 0,
23345
- voiceDefaultResponder: row.voice_default_responder ?? void 0
23345
+ voiceDefaultResponder: row.voice_default_responder ?? void 0,
23346
+ ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
23347
+ payloadHex: row.payload
23346
23348
  };
23347
23349
  const custodiedAt = this.nowMs();
23348
23350
  this.custody.set(row.queue_id, entry);
@@ -24089,6 +24091,7 @@ function base64UrlNoPad(s) {
24089
24091
  }
24090
24092
  var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
24091
24093
  var WS_CLOSE_IDENTITY_REVOKED = 4006;
24094
+ var WS_CLOSE_SESSION_SUPERSEDED = 4409;
24092
24095
  function isIdentityRevokedClose(code, reason) {
24093
24096
  return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
24094
24097
  }
@@ -24304,7 +24307,7 @@ function openDeliveryPings(o) {
24304
24307
  end();
24305
24308
  };
24306
24309
  if (!hardened) {
24307
- if (code === 4001 || isIdentityRevokedClose(code, reason)) {
24310
+ if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
24308
24311
  terminalClose();
24309
24312
  return;
24310
24313
  }
@@ -24313,7 +24316,7 @@ function openDeliveryPings(o) {
24313
24316
  }
24314
24317
  const tr = terminalReason;
24315
24318
  terminalReason = void 0;
24316
- if (code === 4006 || tr && code === 4001) {
24319
+ if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
24317
24320
  lifecycle("terminal", tr ?? `code ${code}`);
24318
24321
  terminalClose();
24319
24322
  return;
@@ -24470,10 +24473,14 @@ var RestTransport = class {
24470
24473
  f;
24471
24474
  ws;
24472
24475
  tokenProvider;
24473
- /** The most recent device-wide WS send handle, captured on subscribe and
24474
- * cleared on its close. Control frames (status keepalive) route through it;
24475
- * a no-op when no subscription is active. */
24476
+ /** The shared delivery-ping socket's send, set while that socket is open and
24477
+ * cleared when it closes (last subscriber gone, or terminal). Control frames
24478
+ * (status keepalive) route through it; a no-op when no subscription is active. */
24476
24479
  _controlSend = null;
24480
+ /** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
24481
+ pingHub = null;
24482
+ /** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
24483
+ pingsTerminal = false;
24477
24484
  constructor(opts) {
24478
24485
  this.opts = opts;
24479
24486
  this.f = opts.fetchImpl ?? fetch;
@@ -24575,25 +24582,52 @@ var RestTransport = class {
24575
24582
  idempotentReplay: j.idempotent_replay === true
24576
24583
  };
24577
24584
  }
24585
+ /** One device-wide delivery-ping socket per transport (P-5): the backend
24586
+ * supersedes a second socket for the same device, so every subscriber shares
24587
+ * one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
24588
+ * detaches only itself; the socket closes with the last subscriber and a later
24589
+ * subscribe opens a fresh one. After a terminal close the transport is
24590
+ * terminal: later calls open no socket and get an already-ended iterator. */
24578
24591
  subscribeDeliveryPings(_channelId) {
24592
+ if (this.pingsTerminal) {
24593
+ const ended = new PingSubscriber();
24594
+ ended.end();
24595
+ return { pings: ended.pings, close: () => {
24596
+ }, send: () => {
24597
+ } };
24598
+ }
24599
+ const hub = this.pingHub ??= this.openPingHub();
24600
+ const sub = hub.attach();
24601
+ return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
24602
+ }
24603
+ openPingHub() {
24579
24604
  let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
24580
24605
  if (this.opts.connectIdentityId) {
24581
24606
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
24582
24607
  }
24583
- const handle = openDeliveryPings({
24608
+ const hub = new PingHub(openDeliveryPings({
24584
24609
  url: wsUrl,
24585
24610
  tokenProvider: this.tokenProvider,
24586
24611
  wsImpl: this.ws,
24587
24612
  hardened: this.opts.hardenedReconnect ?? false,
24588
- onLifecycle: this.opts.onWsLifecycle
24589
- });
24590
- this._controlSend = handle.send;
24591
- const close = () => {
24592
- if (this._controlSend === handle.send)
24593
- this._controlSend = null;
24594
- handle.close();
24595
- };
24596
- return { pings: handle.pings, close, send: handle.send };
24613
+ onLifecycle: this.opts.onWsLifecycle,
24614
+ // Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
24615
+ // socket makes this transport terminal. The shared iterator ends right
24616
+ // after this call, so the pump exit tears down every subscriber.
24617
+ onTerminalClose: (close) => {
24618
+ this.pingsTerminal = true;
24619
+ this.opts.onTerminalClose?.(close);
24620
+ }
24621
+ }), () => this.dropPingHub(hub));
24622
+ this._controlSend = hub.send;
24623
+ return hub;
24624
+ }
24625
+ dropPingHub(hub) {
24626
+ if (this.pingHub === hub) {
24627
+ this.pingHub = null;
24628
+ this._controlSend = null;
24629
+ }
24630
+ hub.shutdown();
24597
24631
  }
24598
24632
  sendControlFrame(_channelId, frame) {
24599
24633
  try {
@@ -24621,6 +24655,90 @@ var RestTransport = class {
24621
24655
  }));
24622
24656
  }
24623
24657
  };
24658
+ var SUBSCRIBER_QUEUE_MAX = 8;
24659
+ var PingSubscriber = class {
24660
+ queue = [];
24661
+ waiter;
24662
+ ended = false;
24663
+ started = false;
24664
+ pings = {
24665
+ [Symbol.asyncIterator]: () => {
24666
+ if (this.started)
24667
+ throw new Error("subscribeDeliveryPings: single-use async iterable");
24668
+ this.started = true;
24669
+ return {
24670
+ next: () => {
24671
+ if (this.ended)
24672
+ return Promise.resolve({ value: void 0, done: true });
24673
+ if (this.queue.length)
24674
+ return Promise.resolve({ value: this.queue.shift(), done: false });
24675
+ return new Promise((resolve) => {
24676
+ this.waiter = resolve;
24677
+ });
24678
+ }
24679
+ };
24680
+ }
24681
+ };
24682
+ push(p) {
24683
+ if (this.ended)
24684
+ return;
24685
+ if (this.waiter) {
24686
+ const w = this.waiter;
24687
+ this.waiter = void 0;
24688
+ w({ value: p, done: false });
24689
+ } else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
24690
+ this.queue.push(p);
24691
+ }
24692
+ }
24693
+ end() {
24694
+ this.ended = true;
24695
+ this.queue.length = 0;
24696
+ if (this.waiter) {
24697
+ const w = this.waiter;
24698
+ this.waiter = void 0;
24699
+ w({ value: void 0, done: true });
24700
+ }
24701
+ }
24702
+ };
24703
+ var PingHub = class {
24704
+ handle;
24705
+ onGone;
24706
+ subs = /* @__PURE__ */ new Set();
24707
+ send;
24708
+ constructor(handle, onGone) {
24709
+ this.handle = handle;
24710
+ this.onGone = onGone;
24711
+ this.send = handle.send;
24712
+ void this.pump();
24713
+ }
24714
+ /** `openDeliveryPings` iterables never reject and push() never throws, so
24715
+ * this loop only ends when the shared stream ends (our close, or terminal). */
24716
+ async pump() {
24717
+ for await (const p of this.handle.pings)
24718
+ for (const s of this.subs)
24719
+ s.push(p);
24720
+ this.onGone();
24721
+ }
24722
+ attach() {
24723
+ const s = new PingSubscriber();
24724
+ this.subs.add(s);
24725
+ return s;
24726
+ }
24727
+ /** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
24728
+ detach(s) {
24729
+ this.subs.delete(s);
24730
+ s.end();
24731
+ if (this.subs.size === 0)
24732
+ this.onGone();
24733
+ }
24734
+ /** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
24735
+ shutdown() {
24736
+ for (const s of this.subs)
24737
+ s.end();
24738
+ this.subs.clear();
24739
+ this.handle.close();
24740
+ }
24741
+ };
24624
24742
 
24625
24743
  // ../connect-sdk-ts/dist/mls/rest-backend.js
24626
24744
  function describeType(v) {
@@ -25542,7 +25660,9 @@ var FakeConnectBackend = class _FakeConnectBackend {
25542
25660
  epoch: p.epoch,
25543
25661
  payload: bytesToHex22(canonical),
25544
25662
  connect_channel_id: p.channelId,
25545
- created_at: ""
25663
+ created_at: "",
25664
+ identity_signature: null
25665
+ // the envelope above is unsigned
25546
25666
  });
25547
25667
  }
25548
25668
  }
@@ -25554,7 +25674,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
25554
25674
  // edge stores the canonical envelope bytes (hex) and fans out one delivery row
25555
25675
  // per active member device except the sender's. We resolve the sender's device
25556
25676
  // from sender_id (recorded at registerDevice); if unknown, every member gets it.
25557
- async ingestApplication(envelope) {
25677
+ //
25678
+ // Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
25679
+ // the envelope's `identity_proof.signature` as `identity_signature` beside the
25680
+ // unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
25681
+ async ingestApplication(envelope, opts) {
25558
25682
  const channelId = envelope.channel_id;
25559
25683
  const canonical = await canonicalEnvelopeBytes(envelope);
25560
25684
  const members = this.membersByChannel.get(channelId) ?? [];
@@ -25572,7 +25696,8 @@ var FakeConnectBackend = class _FakeConnectBackend {
25572
25696
  epoch: envelope.mls_group_epoch,
25573
25697
  payload: bytesToHex22(canonical),
25574
25698
  connect_channel_id: channelId,
25575
- created_at: ""
25699
+ created_at: "",
25700
+ identity_signature: opts?.omitSignature ? null : envelope.identity_proof.signature || null
25576
25701
  });
25577
25702
  }
25578
25703
  }
@@ -25944,11 +26069,15 @@ function openSchema(db) {
25944
26069
  ensureUnifiedDmOwner(db);
25945
26070
  ensureConversationLastAbsentAt(db);
25946
26071
  ensureRecallForgetTables(db);
26072
+ ensureAgentMemoryOrigin(db);
25947
26073
  ensureRuntimeInboxDeliveryAttempts(db);
25948
26074
  ensureRuntimeReplyIntent(db);
25949
26075
  ensureRuntimeReplyIntentEnvelopeGroup(db);
25950
26076
  ensureGroupLink(db);
25951
26077
  ensureMlsCustody(db);
26078
+ ensureToolGrantTables(db);
26079
+ ensureToolGrantAppliedTable(db);
26080
+ ensureRoomSearchCapTable(db);
25952
26081
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
25953
26082
  }
25954
26083
  function ensureMlsCustody(db) {
@@ -26043,6 +26172,11 @@ function ensureRecallStateColumns(db) {
26043
26172
  }
26044
26173
  }
26045
26174
  }
26175
+ function ensureAgentMemoryOrigin(db) {
26176
+ const cols = db.prepare("PRAGMA table_info(agent_memory)").all();
26177
+ if (cols.length > 0 && !cols.some((c) => c.name === "origin"))
26178
+ db.exec("ALTER TABLE agent_memory ADD COLUMN origin TEXT");
26179
+ }
26046
26180
  function ensureRecallForgetTables(db) {
26047
26181
  db.exec(`
26048
26182
  CREATE TABLE IF NOT EXISTS agent_memory_recall_forgotten (
@@ -26118,6 +26252,67 @@ function ensureUnifiedStateColumns(db) {
26118
26252
  }
26119
26253
  }
26120
26254
  }
26255
+ function ensureToolGrantAppliedTable(db) {
26256
+ db.exec(`
26257
+ CREATE TABLE IF NOT EXISTS tool_grant_applied (
26258
+ identity_id TEXT NOT NULL,
26259
+ pin_key_b64 TEXT NOT NULL,
26260
+ sealed_applied BLOB NOT NULL,
26261
+ updated_at INTEGER NOT NULL,
26262
+ PRIMARY KEY (identity_id, pin_key_b64)
26263
+ );
26264
+ `);
26265
+ }
26266
+ function ensureRoomSearchCapTable(db) {
26267
+ db.exec(`
26268
+ CREATE TABLE IF NOT EXISTS room_search_cap (
26269
+ identity_id TEXT NOT NULL,
26270
+ channel_id TEXT NOT NULL,
26271
+ utc_day TEXT NOT NULL,
26272
+ granted_turns INTEGER NOT NULL,
26273
+ reached_by TEXT,
26274
+ notice_posted INTEGER NOT NULL,
26275
+ PRIMARY KEY (identity_id, channel_id)
26276
+ );
26277
+ `);
26278
+ }
26279
+ function ensureToolGrantTables(db) {
26280
+ db.exec(`
26281
+ CREATE TABLE IF NOT EXISTS tool_grant_manifest (
26282
+ identity_id TEXT NOT NULL,
26283
+ pin_key_b64 TEXT NOT NULL,
26284
+ counter INTEGER NOT NULL,
26285
+ sealed_manifest BLOB NOT NULL,
26286
+ updated_at INTEGER NOT NULL,
26287
+ PRIMARY KEY (identity_id, pin_key_b64)
26288
+ );
26289
+ CREATE TABLE IF NOT EXISTS owner_replay (
26290
+ identity_id TEXT NOT NULL,
26291
+ channel_id TEXT NOT NULL,
26292
+ message_id TEXT NOT NULL,
26293
+ group_id TEXT NOT NULL,
26294
+ epoch INTEGER NOT NULL,
26295
+ processed_at INTEGER NOT NULL,
26296
+ PRIMARY KEY (identity_id, channel_id, message_id)
26297
+ );
26298
+ CREATE INDEX IF NOT EXISTS owner_replay_age ON owner_replay (identity_id, channel_id, processed_at);
26299
+ CREATE TABLE IF NOT EXISTS owner_verify_failed (
26300
+ identity_id TEXT NOT NULL,
26301
+ channel_id TEXT NOT NULL,
26302
+ message_id TEXT NOT NULL,
26303
+ failed_at INTEGER NOT NULL,
26304
+ PRIMARY KEY (identity_id, message_id)
26305
+ );
26306
+ CREATE TABLE IF NOT EXISTS owner_replay_closed_epoch (
26307
+ identity_id TEXT NOT NULL,
26308
+ channel_id TEXT NOT NULL,
26309
+ group_id TEXT NOT NULL,
26310
+ epoch INTEGER NOT NULL,
26311
+ closed_at INTEGER NOT NULL,
26312
+ PRIMARY KEY (identity_id, channel_id, group_id, epoch)
26313
+ );
26314
+ `);
26315
+ }
26121
26316
 
26122
26317
  // ../connect-mls-store/dist/errors.js
26123
26318
  var MlsStoreCorruptError = class extends Error {
@@ -26272,7 +26467,16 @@ function itemVecAad(identityId, f, modelId) {
26272
26467
  return enc4.encode(["agent_memory_item_vec", identityId, f.scopeKind, f.scopeId, f.originId, String(f.chunkIndex), modelId].join(NUL));
26273
26468
  }
26274
26469
  function encodeItemRecord(r) {
26275
- return enc4.encode(JSON.stringify({ v: 1, t: r.text, s: r.source, tr: r.trust, ...r.sourceRef !== void 0 ? { sr: r.sourceRef } : {}, ...r.chunks !== void 0 ? { c: r.chunks } : {} }));
26470
+ return enc4.encode(JSON.stringify({
26471
+ v: 1,
26472
+ t: r.text,
26473
+ s: r.source,
26474
+ tr: r.trust,
26475
+ ...r.sourceRef !== void 0 ? { sr: r.sourceRef } : {},
26476
+ ...r.chunks !== void 0 ? { c: r.chunks } : {},
26477
+ ...r.role !== void 0 ? { ro: r.role } : {},
26478
+ ...r.origin !== void 0 ? { or: r.origin } : {}
26479
+ }));
26276
26480
  }
26277
26481
  function decodeItemRecord(b) {
26278
26482
  let o;
@@ -26283,7 +26487,7 @@ function decodeItemRecord(b) {
26283
26487
  }
26284
26488
  if (!o || typeof o !== "object" || Array.isArray(o))
26285
26489
  return null;
26286
- const { v, t, s, tr, sr, c } = o;
26490
+ const { v, t, s, tr, sr, c, ro, or } = o;
26287
26491
  if (v !== 1 || typeof t !== "string" || typeof s !== "string" || !ITEM_SOURCES.includes(s))
26288
26492
  return null;
26289
26493
  if (tr !== "owner" && tr !== "reference")
@@ -26294,7 +26498,19 @@ function decodeItemRecord(b) {
26294
26498
  return null;
26295
26499
  if (c !== void 0 && (typeof c !== "number" || !Number.isSafeInteger(c) || c < 1))
26296
26500
  return null;
26297
- return { text: t, source: s, trust: tr, ...sr !== void 0 ? { sourceRef: sr } : {}, ...c !== void 0 ? { chunks: c } : {} };
26501
+ if (ro !== void 0 && ro !== "peer" && ro !== "agent")
26502
+ return null;
26503
+ if (or !== void 0 && or !== "fast_path" && or !== "reply" && or !== "untrusted")
26504
+ return null;
26505
+ return {
26506
+ text: t,
26507
+ source: s,
26508
+ trust: tr,
26509
+ ...sr !== void 0 ? { sourceRef: sr } : {},
26510
+ ...c !== void 0 ? { chunks: c } : {},
26511
+ ...ro !== void 0 ? { role: ro } : {},
26512
+ ...or !== void 0 ? { origin: or } : {}
26513
+ };
26298
26514
  }
26299
26515
  var noteSourceRef = (conversationId, noteId) => `agent_memory_note:${conversationId}:${noteId}`;
26300
26516
  var recallSourceRef = (conversationId, messageId) => `agent_memory_recall:${conversationId}:${messageId}`;
@@ -26338,6 +26554,14 @@ function bytesEqual(a, b) {
26338
26554
  function sameTag(prior, tag) {
26339
26555
  return prior !== null && prior.groupId === tag.groupId && prior.epoch === tag.epoch;
26340
26556
  }
26557
+ function metaOf(r) {
26558
+ return {
26559
+ ...r.message_id !== null ? { messageId: r.message_id } : {},
26560
+ modality: r.modality,
26561
+ ...isOrigin(r.origin) ? { origin: r.origin } : {}
26562
+ };
26563
+ }
26564
+ var isOrigin = (v) => v === "fast_path" || v === "reply" || v === "untrusted";
26341
26565
  var UTF8 = new TextDecoder();
26342
26566
  var DAY_MS = 24 * 60 * 60 * 1e3;
26343
26567
  var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
@@ -26386,6 +26610,8 @@ function cachePreparedStatements(db) {
26386
26610
  return st;
26387
26611
  };
26388
26612
  }
26613
+ var OWNER_REPLAY_MAX_PER_CHANNEL = 1e4;
26614
+ var OWNER_REPLAY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
26389
26615
  var SqliteMlsStateStore = class _SqliteMlsStateStore {
26390
26616
  db;
26391
26617
  id;
@@ -26759,7 +26985,13 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
26759
26985
  * same message (the bridge's ingest stamp). The staged row is never
26760
26986
  * rewritten: on a match the FIRST staging stays authoritative.
26761
26987
  */
26762
- async stageRuntimeInbox(messageId, payload, receivedAt = Date.now(), samePayload = bytesEqual) {
26988
+ /**
26989
+ * Owner-signed tool grants (P-3, plan 1 task 4): when `ownerReplay` is given,
26990
+ * the owner replay entry and the runtime_inbox row are written in ONE
26991
+ * BEGIN IMMEDIATE transaction, so a failed stage records no replay entry and a
26992
+ * recorded replay entry always has its staged message.
26993
+ */
26994
+ async stageRuntimeInbox(messageId, payload, receivedAt = Date.now(), samePayload = bytesEqual, ownerReplay) {
26763
26995
  const prior = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
26764
26996
  if (prior) {
26765
26997
  const priorPayload = await this.openRuntimeInboxPayload(prior.sealed_message);
@@ -26769,9 +27001,20 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
26769
27001
  return false;
26770
27002
  }
26771
27003
  const sealed = await this.cipher.seal(payload);
26772
- const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
26773
- (identity_id, message_id, sealed_message, received_at)
26774
- VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
27004
+ const info = this.immediate(() => {
27005
+ if (ownerReplay) {
27006
+ this.db.prepare(`INSERT OR IGNORE INTO owner_replay
27007
+ (identity_id, channel_id, message_id, group_id, epoch, processed_at)
27008
+ VALUES (?, ?, ?, ?, ?, ?)`).run(this.id, ownerReplay.channelId, messageId, ownerReplay.groupId, ownerReplay.epoch, ownerReplay.processedAt);
27009
+ }
27010
+ if (this.failNextRuntimeInboxInsert) {
27011
+ this.failNextRuntimeInboxInsert = false;
27012
+ throw new Error("runtime_inbox insert failed (test)");
27013
+ }
27014
+ return this.db.prepare(`INSERT OR IGNORE INTO runtime_inbox
27015
+ (identity_id, message_id, sealed_message, received_at)
27016
+ VALUES (?, ?, ?, ?)`).run(this.id, messageId, sealed, receivedAt);
27017
+ });
26775
27018
  if (info.changes === 0) {
26776
27019
  const raced = this.db.prepare("SELECT sealed_message FROM runtime_inbox WHERE identity_id=? AND message_id=?").get(this.id, messageId);
26777
27020
  const racedPayload = await this.openRuntimeInboxPayload(raced.sealed_message);
@@ -27054,10 +27297,10 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27054
27297
  return { seq: 0, deduped: false, dropped: true };
27055
27298
  }
27056
27299
  const info = this.db.prepare(`INSERT OR IGNORE INTO agent_memory
27057
- (identity_id, conversation_id, seq, role, peer_id, sealed_text, modality, message_id, idempotency_key, dedup_key, recorded_at)
27300
+ (identity_id, conversation_id, seq, role, peer_id, sealed_text, modality, message_id, idempotency_key, dedup_key, recorded_at, origin)
27058
27301
  VALUES (?, ?,
27059
27302
  (SELECT COALESCE(MAX(seq),0)+1 FROM agent_memory WHERE identity_id=? AND conversation_id=?),
27060
- ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.id, input.conversationId, this.id, input.conversationId, input.role, input.peerId ?? null, sealed, input.modality, input.messageId ?? null, input.idempotencyKey ?? null, dedupKey, now);
27303
+ ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.id, input.conversationId, this.id, input.conversationId, input.role, input.peerId ?? null, sealed, input.modality, input.messageId ?? null, input.idempotencyKey ?? null, dedupKey, now, isOrigin(input.origin) ? input.origin : null);
27061
27304
  if (info.changes === 0) {
27062
27305
  const row2 = this.db.prepare("SELECT seq FROM agent_memory WHERE identity_id=? AND conversation_id=? AND dedup_key=?").get(this.id, input.conversationId, dedupKey);
27063
27306
  return { seq: row2.seq, deduped: true };
@@ -27081,6 +27324,29 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27081
27324
  facts.push(new TextDecoder().decode(await this.cipher.open(f.sealed_fact)));
27082
27325
  return { recent, facts };
27083
27326
  }
27327
+ /** Owner grants plan 4 Task 16: message id, modality and origin of the given lines (clear columns only, no text). */
27328
+ memoryLineMeta(conversationId, seqs) {
27329
+ const out = /* @__PURE__ */ new Map();
27330
+ const stmt = this.db.prepare("SELECT message_id, modality, origin FROM agent_memory WHERE identity_id=? AND conversation_id=? AND seq=?");
27331
+ for (const seq of seqs) {
27332
+ const r = stmt.get(this.id, conversationId, seq);
27333
+ if (r)
27334
+ out.set(seq, metaOf(r));
27335
+ }
27336
+ return out;
27337
+ }
27338
+ /** Owner grants plan 4 Task 16 (audit r2 M-1): every peer row's message id in the conversation, not the 50-row window. */
27339
+ memoryPeerMessageIds(conversationId) {
27340
+ return this.db.prepare("SELECT message_id FROM agent_memory WHERE identity_id=? AND conversation_id=? AND role='peer' AND message_id IS NOT NULL").all(this.id, conversationId).map((r) => r.message_id);
27341
+ }
27342
+ /** Owner grants plan 4 Task 16 (OQ-9): the history row an imported_history item came from, or null when it is gone. */
27343
+ memoryRowForTrace(conversationId, at) {
27344
+ const cols = "role, peer_id, message_id, modality, origin, recorded_at";
27345
+ const r = at.seq !== void 0 ? this.db.prepare(`SELECT ${cols} FROM agent_memory WHERE identity_id=? AND conversation_id=? AND seq=?`).get(this.id, conversationId, at.seq) : at.messageId !== void 0 ? this.db.prepare(`SELECT ${cols} FROM agent_memory WHERE identity_id=? AND conversation_id=? AND message_id=?`).get(this.id, conversationId, at.messageId) : void 0;
27346
+ if (!r)
27347
+ return null;
27348
+ return { role: r.role === "agent" ? "agent" : "peer", ts: Number(r.recorded_at), ...r.peer_id ? { peerId: r.peer_id } : {}, ...metaOf(r) };
27349
+ }
27084
27350
  async recordMemoryFact(conversationId, fact) {
27085
27351
  const now = this.memoryNow();
27086
27352
  const sealed = await this.cipher.seal(new TextEncoder().encode(fact));
@@ -28108,7 +28374,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28108
28374
  }
28109
28375
  /** §14.1 (R-U12): one conversation's history in seq order, opened under the EMPTY AAD. Batched by `afterSeq`; `next` is {at: recorded_at, id: seq}. */
28110
28376
  async readHistoryForMigration(conversationId, sinceMs, afterSeq, limit) {
28111
- const raw = this.db.prepare(`SELECT seq, message_id, role, sealed_text, recorded_at FROM agent_memory WHERE identity_id=? AND conversation_id=? AND recorded_at>=? AND seq>?
28377
+ const raw = this.db.prepare(`SELECT seq, message_id, role, sealed_text, recorded_at, modality, origin FROM agent_memory WHERE identity_id=? AND conversation_id=? AND recorded_at>=? AND seq>?
28112
28378
  ORDER BY seq ASC LIMIT ?`).all(this.id, conversationId, sinceMs, afterSeq, limit);
28113
28379
  const rows = [];
28114
28380
  let unreadable = 0;
@@ -28117,7 +28383,15 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28117
28383
  await yieldToLoop();
28118
28384
  try {
28119
28385
  const text = UTF8.decode(await this.cipher.open(r.sealed_text));
28120
- rows.push({ seq: Number(r.seq), messageId: r.message_id ?? null, role: r.role === "agent" ? "agent" : "peer", text, recordedAt: Number(r.recorded_at) });
28386
+ rows.push({
28387
+ seq: Number(r.seq),
28388
+ messageId: r.message_id ?? null,
28389
+ role: r.role === "agent" ? "agent" : "peer",
28390
+ text,
28391
+ recordedAt: Number(r.recorded_at),
28392
+ modality: r.modality,
28393
+ ...isOrigin(r.origin) ? { origin: r.origin } : {}
28394
+ });
28121
28395
  } catch {
28122
28396
  unreadable++;
28123
28397
  }
@@ -28519,6 +28793,156 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28519
28793
  const res = expectCreatedAt === void 0 ? this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=?").run(this.id, channelId) : this.db.prepare("DELETE FROM mls_owed_followup WHERE identity_id=? AND channel_id=? AND created_at=?").run(this.id, channelId, expectCreatedAt);
28520
28794
  return Number(res.changes) > 0;
28521
28795
  }
28796
+ // ---------------------------------------------------------------------------
28797
+ // Owner-signed tool grants, step 1 (plan 1 task 4; spec §7.3, §7.11; D-2).
28798
+ /** The last good manifest wrapper stored for this pinned public key, or null. */
28799
+ async loadToolGrantManifest(pinKeyB64) {
28800
+ const row = this.db.prepare("SELECT counter, sealed_manifest FROM tool_grant_manifest WHERE identity_id=? AND pin_key_b64=?").get(this.id, pinKeyB64);
28801
+ if (!row)
28802
+ return null;
28803
+ return { counter: Number(row.counter), wrapper: await this.unsealJson(row.sealed_manifest) };
28804
+ }
28805
+ /** Stores a verified wrapper. The counter can never fall or repeat, whatever the
28806
+ * caller does: the upsert's WHERE refuses it and this throws. */
28807
+ async saveToolGrantManifest(pinKeyB64, counter, wrapper) {
28808
+ const sealed = await this.sealJson(wrapper);
28809
+ const info = this.db.prepare(`INSERT INTO tool_grant_manifest (identity_id, pin_key_b64, counter, sealed_manifest, updated_at)
28810
+ VALUES (?, ?, ?, ?, ?)
28811
+ ON CONFLICT(identity_id, pin_key_b64) DO UPDATE SET
28812
+ counter = excluded.counter, sealed_manifest = excluded.sealed_manifest, updated_at = excluded.updated_at
28813
+ WHERE excluded.counter > tool_grant_manifest.counter`).run(this.id, pinKeyB64, counter, sealed, Date.now());
28814
+ if (Number(info.changes) === 0)
28815
+ throw new Error("tool grant counter not increasing");
28816
+ }
28817
+ /** Counter rule §6.6: state is per pinned key; a new key starts a new counter. */
28818
+ dropToolGrantManifestsExcept(pinKeyB64) {
28819
+ return Number(this.db.prepare("DELETE FROM tool_grant_manifest WHERE identity_id=? AND pin_key_b64<>?").run(this.id, pinKeyB64).changes);
28820
+ }
28821
+ // Owner-signed tool grants plan 4, Task 12 (spec §7.5): the room web_search backstop, per room per UTC day.
28822
+ /** A row of another day reads as zero. */
28823
+ roomSearchCapState(channelId, utcDay) {
28824
+ const row = this.db.prepare("SELECT granted_turns, reached_by, notice_posted FROM room_search_cap WHERE identity_id=? AND channel_id=? AND utc_day=?").get(this.id, channelId, utcDay);
28825
+ if (!row)
28826
+ return { grantedTurns: 0, reachedBy: null, noticePosted: false };
28827
+ const by = row.reached_by === "bridge" || row.reached_by === "runtime" ? row.reached_by : null;
28828
+ return { grantedTurns: Number(row.granted_turns), reachedBy: by, noticePosted: Number(row.notice_posted) === 1 };
28829
+ }
28830
+ /** One statement: a row of an earlier day is reset, then +1. Returns the new count. */
28831
+ countRoomSearchGrant(channelId, utcDay) {
28832
+ const row = this.db.prepare(`INSERT INTO room_search_cap (identity_id, channel_id, utc_day, granted_turns, reached_by, notice_posted)
28833
+ VALUES (?, ?, ?, 1, NULL, 0)
28834
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
28835
+ granted_turns = CASE WHEN room_search_cap.utc_day = excluded.utc_day THEN room_search_cap.granted_turns + 1 ELSE 1 END,
28836
+ reached_by = CASE WHEN room_search_cap.utc_day = excluded.utc_day THEN room_search_cap.reached_by ELSE NULL END,
28837
+ notice_posted = CASE WHEN room_search_cap.utc_day = excluded.utc_day THEN room_search_cap.notice_posted ELSE 0 END,
28838
+ utc_day = excluded.utc_day
28839
+ RETURNING granted_turns`).get(this.id, channelId, utcDay);
28840
+ return Number(row.granted_turns);
28841
+ }
28842
+ /** True when this call set the flag (the first time today). */
28843
+ markRoomSearchCapReached(channelId, utcDay, by) {
28844
+ const info = this.db.prepare(`INSERT INTO room_search_cap (identity_id, channel_id, utc_day, granted_turns, reached_by, notice_posted)
28845
+ VALUES (?, ?, ?, 0, ?, 0)
28846
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET
28847
+ granted_turns = CASE WHEN room_search_cap.utc_day = excluded.utc_day THEN room_search_cap.granted_turns ELSE 0 END,
28848
+ notice_posted = CASE WHEN room_search_cap.utc_day = excluded.utc_day THEN room_search_cap.notice_posted ELSE 0 END,
28849
+ reached_by = excluded.reached_by,
28850
+ utc_day = excluded.utc_day
28851
+ WHERE room_search_cap.utc_day <> excluded.utc_day OR room_search_cap.reached_by IS NULL`).run(this.id, channelId, utcDay, by);
28852
+ return Number(info.changes) > 0;
28853
+ }
28854
+ markRoomSearchCapNoticePosted(channelId, utcDay) {
28855
+ this.db.prepare("UPDATE room_search_cap SET notice_posted = 1 WHERE identity_id=? AND channel_id=? AND utc_day=?").run(this.id, channelId, utcDay);
28856
+ }
28857
+ /** Deletes rows of earlier days; called at start(). */
28858
+ pruneRoomSearchCap(utcDay) {
28859
+ return Number(this.db.prepare("DELETE FROM room_search_cap WHERE identity_id=? AND utc_day < ?").run(this.id, utcDay).changes);
28860
+ }
28861
+ // Owner-signed tool grants plan 4, Task 11 (rulings item 3): the last applied good search block.
28862
+ /** The applied record for this pinned key, or null. `search: null` means "applied with no search block". */
28863
+ async loadToolGrantApplied(pinKeyB64) {
28864
+ const row = this.db.prepare("SELECT sealed_applied FROM tool_grant_applied WHERE identity_id=? AND pin_key_b64=?").get(this.id, pinKeyB64);
28865
+ if (!row)
28866
+ return null;
28867
+ return await this.unsealJson(row.sealed_applied);
28868
+ }
28869
+ async saveToolGrantApplied(pinKeyB64, applied) {
28870
+ const sealed = await this.sealJson({ counter: applied.counter, search: applied.search });
28871
+ this.db.prepare(`INSERT INTO tool_grant_applied (identity_id, pin_key_b64, sealed_applied, updated_at)
28872
+ VALUES (?, ?, ?, ?)
28873
+ ON CONFLICT(identity_id, pin_key_b64) DO UPDATE SET
28874
+ sealed_applied = excluded.sealed_applied, updated_at = excluded.updated_at`).run(this.id, pinKeyB64, sealed, Date.now());
28875
+ }
28876
+ /** Counter rule §6.6: a new pinned key drops the old key's applied record. */
28877
+ dropToolGrantAppliedExcept(pinKeyB64) {
28878
+ return Number(this.db.prepare("DELETE FROM tool_grant_applied WHERE identity_id=? AND pin_key_b64<>?").run(this.id, pinKeyB64).changes);
28879
+ }
28880
+ hasOwnerMessage(channelId, messageId) {
28881
+ return this.db.prepare("SELECT 1 FROM owner_replay WHERE identity_id=? AND channel_id=? AND message_id=?").get(this.id, channelId, messageId) !== void 0;
28882
+ }
28883
+ isOwnerEpochClosed(channelId, groupId, epoch) {
28884
+ return this.db.prepare("SELECT 1 FROM owner_replay_closed_epoch WHERE identity_id=? AND channel_id=? AND group_id=? AND epoch=?").get(this.id, channelId, groupId, epoch) !== void 0;
28885
+ }
28886
+ recordOwnerVerifyFailure(channelId, messageId, failedAt = Date.now()) {
28887
+ this.db.prepare("INSERT OR IGNORE INTO owner_verify_failed (identity_id, channel_id, message_id, failed_at) VALUES (?, ?, ?, ?)").run(this.id, channelId, messageId, failedAt);
28888
+ }
28889
+ isOwnerVerifyFailed(messageId) {
28890
+ return this.db.prepare("SELECT 1 FROM owner_verify_failed WHERE identity_id=? AND message_id=?").get(this.id, messageId) !== void 0;
28891
+ }
28892
+ listOwnerReplayChannels() {
28893
+ return this.db.prepare("SELECT DISTINCT channel_id FROM owner_replay WHERE identity_id=? ORDER BY channel_id").all(this.id).map((r) => r.channel_id);
28894
+ }
28895
+ /**
28896
+ * Decision D-2. Per channel with a known current (group, epoch): rows of the
28897
+ * current pair are never pruned; among the others, drop rows older than
28898
+ * `maxAgeMs`, then every row past the newest `maxPerChannel`, oldest first.
28899
+ * Every pair that lost a row is recorded closed. A channel with no current
28900
+ * position is skipped this pass. Old `owner_verify_failed` rows go too.
28901
+ * Returns the number of owner_replay rows removed.
28902
+ */
28903
+ pruneOwnerReplay(nowMs, currentOf, opts = {}) {
28904
+ const maxPerChannel = opts.maxPerChannel ?? OWNER_REPLAY_MAX_PER_CHANNEL;
28905
+ const maxAgeMs = opts.maxAgeMs ?? OWNER_REPLAY_MAX_AGE_MS;
28906
+ const cutoff = nowMs - maxAgeMs;
28907
+ return this.immediate(() => {
28908
+ let removed = 0;
28909
+ for (const channelId of this.listOwnerReplayChannels()) {
28910
+ const cur = currentOf.get(channelId);
28911
+ if (!cur)
28912
+ continue;
28913
+ const others = this.db.prepare(`SELECT rowid AS rid, group_id, epoch, processed_at FROM owner_replay
28914
+ WHERE identity_id=? AND channel_id=? AND NOT (group_id=? AND epoch=?)
28915
+ ORDER BY processed_at DESC, rowid DESC`).all(this.id, channelId, cur.groupId, cur.epoch);
28916
+ const young = others.filter((r) => Number(r.processed_at) >= cutoff);
28917
+ const victims = [...others.filter((r) => Number(r.processed_at) < cutoff), ...young.slice(maxPerChannel)];
28918
+ const closed = /* @__PURE__ */ new Map();
28919
+ const del = this.db.prepare("DELETE FROM owner_replay WHERE rowid=?");
28920
+ for (const v of victims) {
28921
+ removed += Number(del.run(v.rid).changes);
28922
+ closed.set(`${v.group_id}\0${v.epoch}`, { groupId: v.group_id, epoch: Number(v.epoch) });
28923
+ }
28924
+ const close = this.db.prepare(`INSERT OR IGNORE INTO owner_replay_closed_epoch (identity_id, channel_id, group_id, epoch, closed_at)
28925
+ VALUES (?, ?, ?, ?, ?)`);
28926
+ for (const p of closed.values()) {
28927
+ if (p.groupId === cur.groupId && p.epoch === cur.epoch)
28928
+ continue;
28929
+ close.run(this.id, channelId, p.groupId, p.epoch, nowMs);
28930
+ }
28931
+ }
28932
+ this.db.prepare("DELETE FROM owner_verify_failed WHERE identity_id=? AND failed_at < ?").run(this.id, cutoff);
28933
+ return removed;
28934
+ });
28935
+ }
28936
+ failNextRuntimeInboxInsert = false;
28937
+ /** @internal test-only: the next runtime_inbox INSERT inside the staging transaction throws once. */
28938
+ _failNextRuntimeInboxInsertForTest() {
28939
+ this.failNextRuntimeInboxInsert = true;
28940
+ }
28941
+ /** @internal test-only */
28942
+ _insertOwnerReplayForTest(channelId, messageId, groupId, epoch, processedAt) {
28943
+ this.db.prepare(`INSERT INTO owner_replay (identity_id, channel_id, message_id, group_id, epoch, processed_at)
28944
+ VALUES (?, ?, ?, ?, ?, ?)`).run(this.id, channelId, messageId, groupId, epoch, processedAt);
28945
+ }
28522
28946
  /** @internal test-only */
28523
28947
  _rawMainForTest(identityId, channelId) {
28524
28948
  const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
@@ -28671,8 +29095,8 @@ function loadPackageMeta(importMetaUrl) {
28671
29095
  }
28672
29096
 
28673
29097
  // src/bridge-agent.ts
28674
- import { promises as fsp, statSync as statSync4 } from "node:fs";
28675
- import { join as join16, dirname as dirname4 } from "node:path";
29098
+ import { existsSync as existsSync5, promises as fsp, statSync as statSync4 } from "node:fs";
29099
+ import { join as join17, dirname as dirname5 } from "node:path";
28676
29100
  import { fileURLToPath as fileURLToPath2 } from "node:url";
28677
29101
 
28678
29102
  // src/latency.ts
@@ -29263,8 +29687,8 @@ async function prepareAttachment(filePath2, caption, channelId, deps) {
29263
29687
  async function materializeAttachment(body, ctx, deps) {
29264
29688
  await deps.ready();
29265
29689
  const ciphertext = await deps.downloadBlob(body.blobId);
29266
- const sha25611 = await deps.computeFileDigest(ciphertext);
29267
- if (sha25611 !== body.digest) {
29690
+ const sha25612 = await deps.computeFileDigest(ciphertext);
29691
+ if (sha25612 !== body.digest) {
29268
29692
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
29269
29693
  }
29270
29694
  const plain = await deps.decryptFile(
@@ -29277,7 +29701,7 @@ async function materializeAttachment(body, ctx, deps) {
29277
29701
  const path = `${dir}/${ctx.messageId}-${safeName}`;
29278
29702
  await deps.mkdirp(dir);
29279
29703
  await deps.writeFile(path, plain);
29280
- return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha25611 };
29704
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha25612 };
29281
29705
  }
29282
29706
 
29283
29707
  // src/sodium-ready.ts
@@ -29866,11 +30290,11 @@ function quoteFromMessage(note, record) {
29866
30290
  }
29867
30291
  function renderNotes(notes) {
29868
30292
  if (notes.length === 0) return void 0;
29869
- const lines = notes.map((n) => {
30293
+ const lines2 = notes.map((n) => {
29870
30294
  const one = oneLine(n.text);
29871
30295
  return `- [n${n.id}] ${/^[#\-\[]/.test(one) ? `\\${one}` : one}`;
29872
30296
  });
29873
- return [NOTES_HEADING, ...lines].join("\n");
30297
+ return [NOTES_HEADING, ...lines2].join("\n");
29874
30298
  }
29875
30299
 
29876
30300
  // src/memory/secret-scan.ts
@@ -30619,7 +31043,7 @@ function gcModels(root3, keepModelIds, now, maxAgeMs = THIRTY_DAYS) {
30619
31043
 
30620
31044
  // src/memory/embedder/manifest.ts
30621
31045
  var MODEL_ORIGIN = "https://downloads.securecoms.ai";
30622
- var file = (modelId, name, sha25611, size) => Object.freeze({ name, urlPath: `/models/embeddings/${modelId}/${sha25611}/${name}`, sha256: sha25611, size });
31046
+ var file = (modelId, name, sha25612, size) => Object.freeze({ name, urlPath: `/models/embeddings/${modelId}/${sha25612}/${name}`, sha256: sha25612, size });
30623
31047
  var MODEL_ID = "minilm-l6-v2-int8";
30624
31048
  var EMBEDDING_MANIFEST = Object.freeze({
30625
31049
  modelId: MODEL_ID,
@@ -30963,8 +31387,8 @@ import { join as join11, win32 } from "node:path";
30963
31387
  var WINDOWS_SEMANTIC_ENABLED = false;
30964
31388
  var windowsSemanticOn = (platform, enabled = WINDOWS_SEMANTIC_ENABLED) => platform !== "win32" || enabled;
30965
31389
  function parseSaveSddl(saveFile) {
30966
- const lines = saveFile.toString("utf16le").replace(/^\uFEFF/, "").split(/\r?\n/);
30967
- return (lines[1] ?? "").trim();
31390
+ const lines2 = saveFile.toString("utf16le").replace(/^\uFEFF/, "").split(/\r?\n/);
31391
+ return (lines2[1] ?? "").trim();
30968
31392
  }
30969
31393
  function aclAllows(sddl, ownerSid) {
30970
31394
  const allowed = /* @__PURE__ */ new Set([ownerSid, "SY", "BA"]);
@@ -32359,7 +32783,15 @@ var ItemBook = class {
32359
32783
  originAt: r.originAt,
32360
32784
  storedAt
32361
32785
  });
32362
- const rec = (text, chunks) => ({ text, source: r.source, trust: r.trust, ...r.sourceRef !== void 0 ? { sourceRef: r.sourceRef } : {}, ...chunks ? { chunks } : {} });
32786
+ const rec = (text, chunks) => ({
32787
+ text,
32788
+ source: r.source,
32789
+ trust: r.trust,
32790
+ ...r.sourceRef !== void 0 ? { sourceRef: r.sourceRef } : {},
32791
+ ...chunks ? { chunks } : {},
32792
+ ...r.role !== void 0 ? { role: r.role } : {},
32793
+ ...r.origin !== void 0 ? { origin: r.origin } : {}
32794
+ });
32363
32795
  if (chunked) parts.push({ itemId: first, chunkIndex: 0, parentId: null, parentOriginId: "", sealedRecord: await this.d.store.sealItemRecord(fields(0), rec("", texts.length)), sealedVec: null, modelId: null, tokenMacs: [], embeddable: false });
32364
32796
  const inlineOk = r.pendingOnly !== true && !this.d.paused() && this.d.embedOn() && this.d.embedder.state().state === "ready";
32365
32797
  for (const [i, text] of texts.entries()) {
@@ -32638,7 +33070,7 @@ var UnifiedReadBook = class {
32638
33070
  this.entries.set(p.messageId, { channelId: p.channelId, scopeId: p.scopeId, capture, pins: pins?.pins ?? [], pinnedMore: pins?.more ?? 0, items });
32639
33071
  }
32640
33072
  /** ws flush (synchronous): the entry's own channel and owner scope, its capture still current (H-4, U11). */
32641
- deliverable(messageId, channelId, scopeIdNow) {
33073
+ deliverable(messageId, channelId, scopeIdNow, keep = () => true) {
32642
33074
  const e = this.entries.get(messageId);
32643
33075
  if (!e || e.channelId !== channelId || e.scopeId !== scopeIdNow) return void 0;
32644
33076
  if (!same(e.capture, this.d.store.itemCapture(channelId))) {
@@ -32647,8 +33079,9 @@ var UnifiedReadBook = class {
32647
33079
  }
32648
33080
  const at = (i) => ({ id: i.itemId, source: i.source, at: i.originAt, text: clampText(i.text) });
32649
33081
  const all = [
32650
- ...e.pins.map((i) => ({ block: "pinned", ...at(i) })),
32651
- ...e.items.map((i) => ({ block: i.trust === "owner" ? "owner" : "reference", ...at(i) }))
33082
+ // Owner grants plan 4 Task 16: `keep` is the voice fence on a pinned agent (never a SQL read here; the bridge supplies it).
33083
+ ...e.pins.filter(keep).map((i) => ({ block: "pinned", ...at(i) })),
33084
+ ...e.items.filter(keep).map((i) => ({ block: i.trust === "owner" ? "owner" : "reference", ...at(i) }))
32652
33085
  ];
32653
33086
  const items = all.slice(0, FRAME_ITEMS_MAX);
32654
33087
  const pinsCut = Math.max(0, e.pins.length - FRAME_ITEMS_MAX);
@@ -32693,6 +33126,56 @@ var UnifiedReadBook = class {
32693
33126
  }
32694
33127
  };
32695
33128
 
33129
+ // src/grants/voice-fence.ts
33130
+ import { createHash as createHash4 } from "node:crypto";
33131
+ var FAST_ID_EPOCH_MS = 17887392e5;
33132
+ function legacyFastReplyId(messageId) {
33133
+ const h = createHash4("sha256").update(`fast:${messageId}`).digest();
33134
+ h[6] = h[6] & 15 | 64;
33135
+ h[8] = h[8] & 63 | 128;
33136
+ const x = h.subarray(0, 16).toString("hex");
33137
+ return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
33138
+ }
33139
+ function classifyAgentLine(line, ctx) {
33140
+ if (line.origin === "fast_path" || line.origin === "reply" || line.origin === "untrusted") return line.origin;
33141
+ if (line.modality === "voice") return "fast_path";
33142
+ if (line.messageId !== void 0 && ctx.fastIds.has(line.messageId)) return "fast_path";
33143
+ if (line.ts !== void 0 && line.ts < FAST_ID_EPOCH_MS) return "fast_path";
33144
+ if (line.modality === "text") return "reply";
33145
+ return "fast_path";
33146
+ }
33147
+ function isRelayClassPeer(line, ownerId) {
33148
+ return line.modality === "voice" || line.peerId !== ownerId;
33149
+ }
33150
+ function parseHistoryRef(ref) {
33151
+ const m = /^agent_memory:(.+):(\d+)$/.exec(ref);
33152
+ return m ? { conversationId: m[1], seq: Number(m[2]) } : null;
33153
+ }
33154
+ function classifyUnifiedItem(item, trace, ctx, strict, keepUntrusted = false) {
33155
+ if (strict?.turnChannelId !== void 0 && item.channelId !== strict.turnChannelId) {
33156
+ if (item.source === "owner_typed") return "keep";
33157
+ if (item.source !== "imported_history") return "exclude";
33158
+ const row2 = traceItem(item, trace);
33159
+ return row2 !== null && row2.role === "peer" && !isRelayClassPeer(row2, strict.ownerId) ? "keep" : "exclude";
33160
+ }
33161
+ if (strict && item.source === "owner_voice") return "exclude";
33162
+ if (item.role === "peer" && !(strict && item.source === "imported_history")) return "keep";
33163
+ if (item.role !== "peer" && item.origin !== void 0) return item.origin === "reply" || keepUntrusted && item.origin === "untrusted" ? "keep" : "exclude";
33164
+ if (item.source === "agent_reply") return "keep";
33165
+ if (item.source !== "imported_history") return "keep";
33166
+ const row = traceItem(item, trace);
33167
+ if (row === null) return "exclude";
33168
+ if (row.role === "peer") return strict && isRelayClassPeer(row, strict.ownerId) ? "exclude" : "keep";
33169
+ const origin = classifyAgentLine(row, ctx);
33170
+ return origin === "reply" || keepUntrusted && origin === "untrusted" ? "keep" : "exclude";
33171
+ }
33172
+ function traceItem(item, trace) {
33173
+ const ref = item.sourceRef !== void 0 ? parseHistoryRef(item.sourceRef) : null;
33174
+ if (ref !== null) return trace(ref.conversationId, { seq: ref.seq });
33175
+ const seqForm = /^seq:(\d+)$/.exec(item.originId);
33176
+ return seqForm ? trace(item.channelId, { seq: Number(seqForm[1]) }) : trace(item.channelId, { messageId: item.originId });
33177
+ }
33178
+
32696
33179
  // src/memory/unified/migrate.ts
32697
33180
  var OLD_TABLES_PRESENT = true;
32698
33181
  var MIGRATION_BATCH = 100;
@@ -32845,7 +33328,10 @@ async function runMigration(d, o = {}) {
32845
33328
  text: r.text,
32846
33329
  sourceRef: historySourceRef(c, r.seq),
32847
33330
  pendingOnly: true,
32848
- expect
33331
+ expect,
33332
+ // Owner grants plan 4 Task 16: the row's role and, for an agent row, its classified origin.
33333
+ role: r.role,
33334
+ ...r.role === "agent" ? { origin: d.classifyHistoryOrigin ? d.classifyHistoryOrigin(c, r) : classifyAgentLine({ origin: r.origin, modality: r.modality, ...r.messageId ? { messageId: r.messageId } : {}, ts: r.recordedAt }, { fastIds: /* @__PURE__ */ new Set() }) } : {}
32849
33335
  }),
32850
33336
  (r) => r.recordedAt,
32851
33337
  t
@@ -32903,9 +33389,9 @@ async function recordInboundMemory(sink, channelId, senderId, text, messageId, m
32903
33389
  } catch {
32904
33390
  }
32905
33391
  }
32906
- async function recordOutboundMemory(sink, channelId, text, messageId, modality = "text", expectPurgeEpoch) {
33392
+ async function recordOutboundMemory(sink, channelId, text, messageId, modality = "text", expectPurgeEpoch, origin) {
32907
33393
  try {
32908
- await sink.record({ conversationId: channelId, role: "agent", text, modality, messageId, ...expectPurgeEpoch === void 0 ? {} : { expectPurgeEpoch } });
33394
+ await sink.record({ conversationId: channelId, role: "agent", text, modality, messageId, ...expectPurgeEpoch === void 0 ? {} : { expectPurgeEpoch }, ...origin ? { origin } : {} });
32909
33395
  } catch {
32910
33396
  }
32911
33397
  }
@@ -33261,10 +33747,10 @@ function splitFastReply(raw, maxChars = 200, maxSentences = 2) {
33261
33747
  return { reply: normalizeFastReply(raw.replace(DEFER_RE, " "), maxChars, maxSentences), defer };
33262
33748
  }
33263
33749
  function normalizeFastReply(raw, maxChars = 200, maxSentences = 2) {
33264
- const lines = raw.split(/\r?\n+/).map(
33750
+ const lines2 = raw.split(/\r?\n+/).map(
33265
33751
  (l) => l.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/[*_`]+/g, "").trim()
33266
33752
  ).filter(Boolean);
33267
- const sentences = lines.flatMap((l) => l.split(SENTENCE_END)).map((s) => s.trim()).filter(Boolean);
33753
+ const sentences = lines2.flatMap((l) => l.split(SENTENCE_END)).map((s) => s.trim()).filter(Boolean);
33268
33754
  return sentences.slice(0, maxSentences).join(" ").slice(0, maxChars);
33269
33755
  }
33270
33756
 
@@ -33476,18 +33962,18 @@ function parseSummarizeBody(parsed, selfIdentityId, log = () => {
33476
33962
  function transcriptToPrompt(transcript) {
33477
33963
  const names = /* @__PURE__ */ new Map();
33478
33964
  for (const p of transcript.participants ?? []) names.set(p.identity_id, p.display_name);
33479
- const lines = transcript.turns.map(
33965
+ const lines2 = transcript.turns.map(
33480
33966
  (t) => `${names.get(t.speaker_identity_id) ?? t.speaker_identity_id}: ${t.text}`
33481
33967
  );
33482
- return `${SUMMARY_PROMPT}${lines.join("\n")}
33968
+ return `${SUMMARY_PROMPT}${lines2.join("\n")}
33483
33969
  `;
33484
33970
  }
33485
33971
  function parseSummaryOutput(text) {
33486
- const lines = text.split("\n");
33487
- const idx = lines.findIndex((line) => /^\s*Action items:\s*$/i.test(line));
33972
+ const lines2 = text.split("\n");
33973
+ const idx = lines2.findIndex((line) => /^\s*Action items:\s*$/i.test(line));
33488
33974
  if (idx < 0) return { paragraph: text.trim(), actionItems: [] };
33489
- const paragraph = lines.slice(0, idx).join("\n").trim();
33490
- const actionItems = lines.slice(idx + 1).map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim()).filter((line) => line.length > 0);
33975
+ const paragraph = lines2.slice(0, idx).join("\n").trim();
33976
+ const actionItems = lines2.slice(idx + 1).map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim()).filter((line) => line.length > 0);
33491
33977
  return { paragraph, actionItems };
33492
33978
  }
33493
33979
  function buildSummaryBody(request, out, now) {
@@ -33527,7 +34013,7 @@ async function runSummarize(opts) {
33527
34013
  }
33528
34014
 
33529
34015
  // src/service/runtime-config-dir.ts
33530
- import { createHash as createHash4, randomUUID } from "node:crypto";
34016
+ import { createHash as createHash5, randomUUID } from "node:crypto";
33531
34017
  import {
33532
34018
  chmodSync as chmodSync4,
33533
34019
  existsSync as existsSync4,
@@ -33562,7 +34048,24 @@ function assertManagedName(name) {
33562
34048
  }
33563
34049
  function isLocalOnlyName(name) {
33564
34050
  const n = name.toLowerCase().replace(/[. ]+$/, "");
33565
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
34051
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets" || n === MANIFEST_POLICY_FILE;
34052
+ }
34053
+ var MANIFEST_POLICY_FILE = "manifest-policy.json";
34054
+ function removeManagedLlmSource(home, identityId, deps = REAL_FS) {
34055
+ try {
34056
+ deps.unlink(join15(runtimeConfigDir(home, identityId), "llm-source.json"));
34057
+ } catch (e) {
34058
+ if (e?.code !== "ENOENT") throw e;
34059
+ }
34060
+ }
34061
+ function writeManifestPolicy(home, identityId, policy, deps = REAL_FS) {
34062
+ const parent3 = join15(home, ".securecoms", "runtime-config");
34063
+ const dir = runtimeConfigDir(home, identityId);
34064
+ for (const d of [join15(home, ".securecoms"), parent3, dir]) {
34065
+ deps.mkdir(d, 448);
34066
+ deps.chmod(d, 448);
34067
+ }
34068
+ atomicWrite(join15(dir, MANIFEST_POLICY_FILE), JSON.stringify(policy) + "\n", deps);
33566
34069
  }
33567
34070
  function atomicWrite(path, body, deps) {
33568
34071
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -33618,7 +34121,7 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
33618
34121
  });
33619
34122
  }
33620
34123
  function lineHash(line) {
33621
- return createHash4("sha256").update(line.trim(), "utf8").digest("hex");
34124
+ return createHash5("sha256").update(line.trim(), "utf8").digest("hex");
33622
34125
  }
33623
34126
  var LOCAL_REPORT_FILE = "local-config.json";
33624
34127
  var SECRET_PATH_COUNT_MAX = 1e4;
@@ -33647,7 +34150,7 @@ var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
33647
34150
  var LEGACY_MEMORY_FACT_MAX_BYTES = 1024;
33648
34151
  var LEGACY_MEMORY_FACT_DECISION_MAX = 500;
33649
34152
  function legacySourceId(source, bytes) {
33650
- return createHash4("sha256").update(source.kind).update("\0").update(source.channel_id ?? "").update("\0").update(source.path).update("\0").update(bytes).digest("hex");
34153
+ return createHash5("sha256").update(source.kind).update("\0").update(source.channel_id ?? "").update("\0").update(source.path).update("\0").update(bytes).digest("hex");
33651
34154
  }
33652
34155
  function localLegacySources(doc) {
33653
34156
  if (!Array.isArray(doc.legacy_memory)) return [];
@@ -33819,6 +34322,568 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
33819
34322
  return removed;
33820
34323
  }
33821
34324
 
34325
+ // src/grants/binding.ts
34326
+ import { sha256 as sha25611 } from "@noble/hashes/sha256";
34327
+
34328
+ // src/grants/manifest.ts
34329
+ import { ed25519 as ed255195 } from "@noble/curves/ed25519";
34330
+
34331
+ // src/grants/pin.ts
34332
+ import { lstatSync as lstatSync7, readFileSync as readFileSync9 } from "node:fs";
34333
+ import { ed25519 as ed255194 } from "@noble/curves/ed25519";
34334
+ import { dirname as dirname4, join as join16 } from "node:path";
34335
+ var PIN_FILE_NAME = "owner-signing-key.json";
34336
+ var UUID_LOWER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
34337
+ var PIN_KEYS = ["key_algo", "owner_identity_id", "public_key"];
34338
+ function canonicalBase64Bytes(v, len) {
34339
+ if (typeof v !== "string" || v.length === 0) return null;
34340
+ const buf = Buffer.from(v, "base64");
34341
+ if (buf.length !== len || buf.toString("base64") !== v) return null;
34342
+ return new Uint8Array(buf);
34343
+ }
34344
+ function parsePinObject(v) {
34345
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return { ok: false, state: "malformed" };
34346
+ const keys = Object.keys(v).sort();
34347
+ if (keys.length !== PIN_KEYS.length || keys.some((k, i) => k !== PIN_KEYS[i])) return { ok: false, state: "malformed" };
34348
+ const o = v;
34349
+ if (typeof o.key_algo !== "string") return { ok: false, state: "malformed" };
34350
+ if (o.key_algo !== "ed25519") return { ok: false, state: "unsupported_algo" };
34351
+ if (typeof o.owner_identity_id !== "string" || !UUID_LOWER.test(o.owner_identity_id)) return { ok: false, state: "malformed" };
34352
+ const publicKey = canonicalBase64Bytes(o.public_key, 32);
34353
+ if (!publicKey) return { ok: false, state: "malformed" };
34354
+ try {
34355
+ if (ed255194.ExtendedPoint.fromHex(publicKey).isSmallOrder()) return { ok: false, state: "malformed" };
34356
+ } catch {
34357
+ return { ok: false, state: "malformed" };
34358
+ }
34359
+ return {
34360
+ ok: true,
34361
+ pin: Object.freeze({ ownerIdentityId: o.owner_identity_id, keyAlgo: "ed25519", publicKey, publicKeyB64: o.public_key })
34362
+ };
34363
+ }
34364
+ var NODE_FS = { lstat: (p) => lstatSync7(p), readFile: (p) => readFileSync9(p, "utf8") };
34365
+ function fromValue(v) {
34366
+ const r = parsePinObject(v);
34367
+ return r.ok ? { kind: "ok", pin: r.pin } : { kind: "bad", state: r.state };
34368
+ }
34369
+ function readPinFile(path, fs) {
34370
+ let st;
34371
+ try {
34372
+ st = fs.lstat(path);
34373
+ } catch (e) {
34374
+ if (e?.code === "ENOENT") return { kind: "absent" };
34375
+ return { kind: "bad", state: "unreadable" };
34376
+ }
34377
+ if (st.isSymbolicLink()) return { kind: "bad", state: "symlink_refused" };
34378
+ let text;
34379
+ try {
34380
+ text = fs.readFile(path);
34381
+ } catch {
34382
+ return { kind: "bad", state: "unreadable" };
34383
+ }
34384
+ if (st.mode & 63) writeServiceLog2("warn", "config.pin_permissions_unsafe");
34385
+ let json2;
34386
+ try {
34387
+ json2 = JSON.parse(text);
34388
+ } catch {
34389
+ return { kind: "bad", state: "malformed" };
34390
+ }
34391
+ return fromValue(json2);
34392
+ }
34393
+ function samePin(a, b) {
34394
+ return a.ownerIdentityId === b.ownerIdentityId && a.keyAlgo === b.keyAlgo && a.publicKeyB64 === b.publicKeyB64;
34395
+ }
34396
+ function loadOwnerGrantPin(opts) {
34397
+ const fs = opts.fs ?? NODE_FS;
34398
+ const file2 = readPinFile(join16(dirname4(opts.credentialPath), PIN_FILE_NAME), fs);
34399
+ const field = "owner_signing_key" in opts.credentialJson && opts.credentialJson.owner_signing_key !== void 0 ? fromValue(opts.credentialJson.owner_signing_key) : { kind: "absent" };
34400
+ if (file2.kind === "bad") return { state: file2.state, pin: null };
34401
+ if (field.kind === "bad") return { state: field.state, pin: null };
34402
+ if (file2.kind === "absent" && field.kind === "absent") return { state: "no_pin", pin: null };
34403
+ if (file2.kind === "ok" && field.kind === "ok") {
34404
+ return samePin(file2.pin, field.pin) ? { state: "ok", pin: file2.pin } : { state: "pin_conflict", pin: null };
34405
+ }
34406
+ const one = file2.kind === "ok" ? file2.pin : field.pin;
34407
+ return { state: "ok", pin: one };
34408
+ }
34409
+
34410
+ // src/grants/manifest.ts
34411
+ var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
34412
+ var MANIFEST_KNOWN_TOOLS = Object.freeze({
34413
+ owner_dm: Object.freeze(["web_search", "fetch_url", "http_request", "sql_query"]),
34414
+ team_room: Object.freeze(["web_search"])
34415
+ });
34416
+ function manifestSignedBytes(manifest) {
34417
+ const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
34418
+ const body = canonicalize(manifest);
34419
+ const out = new Uint8Array(domain.length + body.length);
34420
+ out.set(domain, 0);
34421
+ out.set(body, domain.length);
34422
+ return out;
34423
+ }
34424
+ var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34425
+ function wellTyped(v) {
34426
+ if (typeof v === "string") return true;
34427
+ if (typeof v === "number") return Number.isSafeInteger(v) && v >= 0;
34428
+ if (Array.isArray(v)) return v.every(wellTyped);
34429
+ if (isPlainObject(v)) return Object.values(v).every(wellTyped);
34430
+ return false;
34431
+ }
34432
+ var WRAPPER_KEYS = ["algo", "manifest", "signature"];
34433
+ var HEX64 = /^[0-9a-f]{64}$/;
34434
+ var PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
34435
+ var absolutePath = (v) => typeof v === "string" && v.startsWith("/") && PRINTABLE_ASCII.test(v);
34436
+ var exactKeys = (o, required, optional = []) => required.every((k) => k in o) && Object.keys(o).every((k) => required.includes(k) || optional.includes(k));
34437
+ var isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
34438
+ function parseEndpoints(v) {
34439
+ if (v === void 0) return {};
34440
+ if (!isPlainObject(v)) return null;
34441
+ const out = {};
34442
+ for (const [name, raw] of Object.entries(v)) {
34443
+ if (!isPlainObject(raw)) return null;
34444
+ const e = { ...raw };
34445
+ if (e.headersFile === "") delete e.headersFile;
34446
+ if (!exactKeys(e, ["baseUrl"], ["headersFile"])) return null;
34447
+ if (typeof e.baseUrl !== "string" || e.baseUrl === "" || !PRINTABLE_ASCII.test(e.baseUrl)) return null;
34448
+ let url;
34449
+ try {
34450
+ url = new URL(e.baseUrl);
34451
+ } catch {
34452
+ return null;
34453
+ }
34454
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
34455
+ if (url.username !== "" || url.password !== "" || url.href !== e.baseUrl) return null;
34456
+ if ("headersFile" in e && !absolutePath(e.headersFile)) return null;
34457
+ out[name] = Object.freeze({ baseUrl: e.baseUrl, ...typeof e.headersFile === "string" ? { headersFile: e.headersFile } : {} });
34458
+ }
34459
+ return out;
34460
+ }
34461
+ function parseDatabases(v) {
34462
+ if (v === void 0) return {};
34463
+ if (!isPlainObject(v)) return null;
34464
+ const out = {};
34465
+ for (const [name, d] of Object.entries(v)) {
34466
+ if (!isPlainObject(d)) return null;
34467
+ const keys = Object.keys(d);
34468
+ if (keys.length !== 1 || keys[0] !== "sqlite" && keys[0] !== "postgresUrlFile") return null;
34469
+ const path = d[keys[0]];
34470
+ if (!absolutePath(path)) return null;
34471
+ out[name] = Object.freeze(keys[0] === "sqlite" ? { sqlite: path } : { postgresUrlFile: path });
34472
+ }
34473
+ return out;
34474
+ }
34475
+ function parseConfig(v) {
34476
+ if (!isPlainObject(v)) return null;
34477
+ if (!exactKeys(v, ["persona_sha256", "flags", "memory", "legacy_assign_sha256"], ["llm", "search", "tools_allow"])) return null;
34478
+ if (typeof v.persona_sha256 !== "string" || !HEX64.test(v.persona_sha256)) return null;
34479
+ if (typeof v.legacy_assign_sha256 !== "string" || !HEX64.test(v.legacy_assign_sha256)) return null;
34480
+ if (!isStringArray(v.flags)) return null;
34481
+ if ("tools_allow" in v && !isStringArray(v.tools_allow)) return null;
34482
+ if ("llm" in v) {
34483
+ const l = v.llm;
34484
+ if (!isPlainObject(l) || !exactKeys(l, ["provider", "model"]) || typeof l.provider !== "string" || !l.provider || typeof l.model !== "string" || !l.model) return null;
34485
+ }
34486
+ if ("search" in v) {
34487
+ const sr = v.search;
34488
+ if (!isPlainObject(sr) || !exactKeys(sr, ["provider"]) || sr.provider !== "brave" && sr.provider !== "tavily") return null;
34489
+ }
34490
+ const mem = v.memory;
34491
+ if (!isPlainObject(mem) || !exactKeys(mem, ["mode"], ["recall_days"])) return null;
34492
+ if (mem.mode !== "markdown" && mem.mode !== "semantic" && mem.mode !== "off") return null;
34493
+ if (mem.mode === "semantic" !== "recall_days" in mem) return null;
34494
+ if ("recall_days" in mem && mem.recall_days !== 30 && mem.recall_days !== 90 && mem.recall_days !== 365) return null;
34495
+ return Object.freeze({
34496
+ persona_sha256: v.persona_sha256,
34497
+ ...isPlainObject(v.llm) ? { llm: Object.freeze({ provider: v.llm.provider, model: v.llm.model }) } : {},
34498
+ ...isPlainObject(v.search) ? { search: Object.freeze({ provider: v.search.provider }) } : {},
34499
+ ...isStringArray(v.tools_allow) ? { tools_allow: Object.freeze([...v.tools_allow]) } : {},
34500
+ flags: Object.freeze([...v.flags]),
34501
+ memory: Object.freeze({ mode: mem.mode, ..."recall_days" in mem ? { recall_days: mem.recall_days } : {} }),
34502
+ legacy_assign_sha256: v.legacy_assign_sha256
34503
+ });
34504
+ }
34505
+ function verifyToolGrantManifest(raw, ctx) {
34506
+ if (!isPlainObject(raw)) return { state: "malformed" };
34507
+ const keys = Object.keys(raw).sort();
34508
+ if (keys.length !== WRAPPER_KEYS.length || keys.some((k, i) => k !== WRAPPER_KEYS[i])) return { state: "malformed" };
34509
+ if (typeof raw.algo !== "string") return { state: "malformed" };
34510
+ if (raw.algo !== "ed25519") return { state: "unsupported_algo" };
34511
+ const m = raw.manifest;
34512
+ if (!isPlainObject(m)) return { state: "malformed" };
34513
+ if (m.type !== "securecoms.tool-grants" || m.v !== 1) return { state: "malformed" };
34514
+ if (!wellTyped(m)) return { state: "malformed" };
34515
+ if (typeof m.agent_identity_id !== "string" || typeof m.owner_identity_id !== "string") return { state: "malformed" };
34516
+ for (const k of ["owner_dm_channel_id", "owner_dm_relay_identity_id"]) {
34517
+ if (k in m && typeof m[k] !== "string") return { state: "malformed" };
34518
+ }
34519
+ if (!isPlainObject(m.grants)) return { state: "malformed" };
34520
+ for (const list of Object.values(m.grants)) {
34521
+ if (!Array.isArray(list) || !list.every((t) => typeof t === "string")) return { state: "malformed" };
34522
+ }
34523
+ const endpoints = parseEndpoints(m.endpoints);
34524
+ const databases = parseDatabases(m.databases);
34525
+ const config = parseConfig(m.config);
34526
+ if (endpoints === null || databases === null || config === null) return { state: "malformed" };
34527
+ const sig = canonicalBase64Bytes(raw.signature, 64);
34528
+ if (!sig) return { state: "malformed" };
34529
+ let good;
34530
+ try {
34531
+ good = ed255195.verify(sig, manifestSignedBytes(m), ctx.pin.publicKey, { zip215: false });
34532
+ } catch {
34533
+ return { state: "malformed" };
34534
+ }
34535
+ if (!good) return { state: "bad_signature" };
34536
+ if (m.agent_identity_id !== ctx.agentIdentityId) return { state: "wrong_agent" };
34537
+ if (m.owner_identity_id !== ctx.pin.ownerIdentityId) return { state: "wrong_owner" };
34538
+ const counter = m.counter;
34539
+ if (typeof counter !== "number" || !Number.isSafeInteger(counter) || counter < 1) return { state: "malformed" };
34540
+ if (ctx.storedCounter !== null) {
34541
+ if (counter === ctx.storedCounter) return { state: "unchanged" };
34542
+ if (counter < ctx.storedCounter) return { state: "rollback" };
34543
+ }
34544
+ let droppedUnknown = false;
34545
+ const grants = {};
34546
+ for (const [audience, list] of Object.entries(m.grants)) {
34547
+ if (audience !== "owner_dm" && audience !== "team_room") {
34548
+ droppedUnknown = true;
34549
+ continue;
34550
+ }
34551
+ const known = MANIFEST_KNOWN_TOOLS[audience];
34552
+ const kept = list.filter((t) => known.includes(t));
34553
+ if (kept.length !== list.length) droppedUnknown = true;
34554
+ grants[audience] = Object.freeze(kept);
34555
+ }
34556
+ const manifest = Object.freeze({
34557
+ type: "securecoms.tool-grants",
34558
+ v: 1,
34559
+ agent_identity_id: m.agent_identity_id,
34560
+ owner_identity_id: m.owner_identity_id,
34561
+ ...typeof m.owner_dm_channel_id === "string" ? { owner_dm_channel_id: m.owner_dm_channel_id } : {},
34562
+ ...typeof m.owner_dm_relay_identity_id === "string" ? { owner_dm_relay_identity_id: m.owner_dm_relay_identity_id } : {},
34563
+ counter,
34564
+ grants: Object.freeze(grants),
34565
+ endpoints: Object.freeze(endpoints),
34566
+ databases: Object.freeze(databases),
34567
+ config
34568
+ });
34569
+ return { state: "ok", manifest, droppedUnknown, wrapper: raw };
34570
+ }
34571
+
34572
+ // src/grants/persona-render.ts
34573
+ var PERSONA_MAX_BYTES = 8192;
34574
+ var TONE_FIELD_ORDER = ["humor", "length", "warmth", "formality"];
34575
+ var TONE_WORDS = Object.freeze({
34576
+ warmth: { reserved: "Be plain and businesslike.", plain: "Be plain and businesslike.", warm: "Be warm.", friendly: "Be friendly." },
34577
+ length: { brief: "Keep answers short.", thorough: "Give the full picture when it matters." },
34578
+ formality: { casual: "Write casually.", formal: "Write formally." },
34579
+ humor: { none: "Do not use humor.", light: "Use light humor when it fits.", playful: "Be playful." }
34580
+ });
34581
+ var HANDOFF_TEXT = Object.freeze({
34582
+ money: "money is moving",
34583
+ legal: "someone wants a legal commitment",
34584
+ upset: "the person is upset"
34585
+ });
34586
+ var TOOL_PARAGRAPH = "Use a tool when the answer depends on something you cannot know. Which tools you have depends on the channel, so rely only on the tools offered in this turn, not on anything said earlier in a conversation. When a request needs a tool you were not offered, say so plainly and never claim you looked something up. When you searched or fetched a page, answer from what it says and name the source; never reply with only a link, and never read a URL aloud.";
34587
+ var PY_SPACE = /* @__PURE__ */ new Set([
34588
+ 9,
34589
+ 10,
34590
+ 11,
34591
+ 12,
34592
+ 13,
34593
+ 28,
34594
+ 29,
34595
+ 30,
34596
+ 31,
34597
+ 32,
34598
+ 133,
34599
+ 160,
34600
+ 5760,
34601
+ 8192,
34602
+ 8193,
34603
+ 8194,
34604
+ 8195,
34605
+ 8196,
34606
+ 8197,
34607
+ 8198,
34608
+ 8199,
34609
+ 8200,
34610
+ 8201,
34611
+ 8202,
34612
+ 8232,
34613
+ 8233,
34614
+ 8239,
34615
+ 8287,
34616
+ 12288
34617
+ ]);
34618
+ function pyStrip(s) {
34619
+ let a = 0;
34620
+ let b = s.length;
34621
+ while (a < b && PY_SPACE.has(s.charCodeAt(a))) a++;
34622
+ while (b > a && PY_SPACE.has(s.charCodeAt(b - 1))) b--;
34623
+ return s.slice(a, b);
34624
+ }
34625
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34626
+ var optString = (v) => v === void 0 || v === null || typeof v === "string";
34627
+ var optStrings = (v) => v === void 0 || v === null || Array.isArray(v) && v.every((x) => typeof x === "string");
34628
+ function lines(items) {
34629
+ return (items ?? []).map(pyStrip).filter((x) => x !== "").map((x) => `- ${x}`).join("\n");
34630
+ }
34631
+ function renderPersonaFiles(persona) {
34632
+ if (!isObj(persona)) return { ok: false, reason: "shape" };
34633
+ const p = persona;
34634
+ for (const k of ["name", "business", "role", "manner"]) if (!optString(p[k])) return { ok: false, reason: "shape" };
34635
+ for (const k of ["can", "cannot", "handoff"]) if (!optStrings(p[k])) return { ok: false, reason: "shape" };
34636
+ const toneRaw = p.tone;
34637
+ if (!(toneRaw === void 0 || toneRaw === null || isObj(toneRaw) && Object.values(toneRaw).every((v) => typeof v === "string"))) {
34638
+ return { ok: false, reason: "shape" };
34639
+ }
34640
+ const name = pyStrip(p.name || "");
34641
+ const business = pyStrip(p.business || "");
34642
+ const role = pyStrip(p.role || "");
34643
+ const identity = [];
34644
+ if (name && business) identity.push(`You are ${name}, an agent for ${business}.`);
34645
+ else if (name) identity.push(`You are ${name}.`);
34646
+ if (role) identity.push(role);
34647
+ const identityMd = identity.join("\n\n");
34648
+ const tone = toneRaw ?? {};
34649
+ const soul = [];
34650
+ for (const k of TONE_FIELD_ORDER) {
34651
+ if (!Object.prototype.hasOwnProperty.call(tone, k)) continue;
34652
+ const words = TONE_WORDS[k];
34653
+ const v = tone[k];
34654
+ if (Object.prototype.hasOwnProperty.call(words, v)) soul.push(words[v]);
34655
+ }
34656
+ const manner = pyStrip(p.manner || "");
34657
+ let soulMd = soul.join(" ");
34658
+ if (manner) soulMd = pyStrip(`${soulMd}
34659
+
34660
+ ${manner}`);
34661
+ const agents = [];
34662
+ const can = lines(p.can);
34663
+ if (can) agents.push(`What you can do:
34664
+ ${can}`);
34665
+ const cannot = lines(p.cannot);
34666
+ if (cannot) agents.push(`What you cannot do:
34667
+ ${cannot}`);
34668
+ const handoff = (p.handoff ?? []).filter((h) => pyStrip(h) !== "").map((h) => Object.prototype.hasOwnProperty.call(HANDOFF_TEXT, h) ? HANDOFF_TEXT[h] : pyStrip(h));
34669
+ if (handoff.length > 0) agents.push("Hand off to a person when " + handoff.join("; when ") + ". Say plainly that you are doing so.");
34670
+ agents.push(TOOL_PARAGRAPH);
34671
+ const agentsMd = agents.join("\n\n");
34672
+ const files = { "IDENTITY.md": identityMd, "SOUL.md": soulMd, "AGENTS.md": agentsMd };
34673
+ for (const body of Object.values(files)) {
34674
+ if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) return { ok: false, reason: "over_cap" };
34675
+ }
34676
+ return { ok: true, files };
34677
+ }
34678
+
34679
+ // src/grants/binding.ts
34680
+ var CONFIG_BINDING_FIELDS = ["persona", "memory", "search", "tools_allow", "flags", "llm", "files", "legacy_assign"];
34681
+ var ASSIGN_PREFIX = "legacy-fact-assign:";
34682
+ function legacyAssignSha256(prunes) {
34683
+ const set = [...new Set(prunes.filter((p) => typeof p === "string" && p.startsWith(ASSIGN_PREFIX)))].sort();
34684
+ return sha256Hex(canonicalize(set));
34685
+ }
34686
+ function legacyAssignToken(d) {
34687
+ return `${ASSIGN_PREFIX}${d.source_id}:${d.fact_hash}:${d.channel_id}`;
34688
+ }
34689
+ var PERSONA_FILES = ["IDENTITY.md", "SOUL.md", "AGENTS.md"];
34690
+ var LLM_SOURCE = "llm-source.json";
34691
+ var FAIL = Symbol("bind_fail");
34692
+ function guarded(f) {
34693
+ try {
34694
+ return f();
34695
+ } catch {
34696
+ return FAIL;
34697
+ }
34698
+ }
34699
+ var isObj2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
34700
+ var strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string") ? [...v] : void 0;
34701
+ var sameStrings = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((x, i) => x === b[i]);
34702
+ var sha256Hex = (bytes) => Buffer.from(sha25611(bytes)).toString("hex");
34703
+ function endpointEquals(raw, signed) {
34704
+ if (!isObj2(raw)) return false;
34705
+ const e = { ...raw };
34706
+ if (e.headersFile === "") delete e.headersFile;
34707
+ const keys = Object.keys(e).sort().join(",");
34708
+ const want = "headersFile" in signed ? "baseUrl,headersFile" : "baseUrl";
34709
+ return keys === want && e.baseUrl === signed.baseUrl && e.headersFile === signed.headersFile;
34710
+ }
34711
+ function databaseEquals(raw, signed) {
34712
+ if (!isObj2(raw)) return false;
34713
+ const keys = Object.keys(raw);
34714
+ if (keys.length !== 1) return false;
34715
+ return "sqlite" in signed ? raw.sqlite === signed.sqlite : raw.postgresUrlFile === signed.postgresUrlFile;
34716
+ }
34717
+ function bindTargets(raw, signed, equals) {
34718
+ if (raw === void 0) return { written: {}, mismatches: 0 };
34719
+ if (!isObj2(raw)) return { written: {}, mismatches: 1 };
34720
+ const written = {};
34721
+ let mismatches = 0;
34722
+ for (const [name, entry] of Object.entries(raw)) {
34723
+ const s = Object.prototype.hasOwnProperty.call(signed, name) ? signed[name] : void 0;
34724
+ if (s !== void 0 && equals(entry, s)) written[name] = s;
34725
+ else mismatches++;
34726
+ }
34727
+ return { written, mismatches };
34728
+ }
34729
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }) {
34730
+ const mismatches = /* @__PURE__ */ new Set();
34731
+ const cfg = manifest.config;
34732
+ const out = {};
34733
+ const files = isObj2(payload.files) ? payload.files : {};
34734
+ const personaOk = guarded(() => sha256Hex(canonicalize(payload.persona)) === cfg.persona_sha256);
34735
+ const render = guarded(() => renderPersonaFiles(payload.persona));
34736
+ if (personaOk === true && render !== FAIL && render.ok) {
34737
+ for (const f of PERSONA_FILES) out[f] = render.files[f];
34738
+ } else {
34739
+ mismatches.add("persona");
34740
+ }
34741
+ const delivered = files[LLM_SOURCE];
34742
+ const llmSame = guarded(() => {
34743
+ if (delivered === void 0) return cfg.llm === void 0;
34744
+ if (typeof delivered !== "string") return false;
34745
+ const parsed = JSON.parse(delivered);
34746
+ if (!isObj2(parsed)) return false;
34747
+ if (cfg.llm === void 0) return parsed.provider === void 0 && parsed.model === void 0;
34748
+ return parsed.provider === cfg.llm.provider && parsed.model === cfg.llm.model;
34749
+ });
34750
+ if (llmSame !== true) mismatches.add("llm");
34751
+ if (cfg.llm !== void 0) {
34752
+ out[LLM_SOURCE] = JSON.stringify({ provider: cfg.llm.provider, model: cfg.llm.model, version: payload.version }) + "\n";
34753
+ }
34754
+ if (!isObj2(payload.files) && payload.files !== void 0) mismatches.add("files");
34755
+ for (const name of Object.keys(files)) {
34756
+ if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
34757
+ }
34758
+ const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
34759
+ const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
34760
+ const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
34761
+ const payloadFlags = strings(sj?.flags);
34762
+ if (!sameStrings(payloadFlags, cfg.flags)) mismatches.add("flags");
34763
+ const tools = isObj2(sj?.tools) ? sj.tools : void 0;
34764
+ if (!sameStrings(strings(tools?.allow), cfg.tools_allow)) mismatches.add("tools_allow");
34765
+ const deny = strings(tools?.deny);
34766
+ const ps = isObj2(sj?.search) ? sj.search : void 0;
34767
+ const payloadProvider = ps?.provider;
34768
+ let searchBlock;
34769
+ let appliedSearch;
34770
+ if (payloadProvider === cfg.search?.provider && ps === void 0 === (cfg.search === void 0)) {
34771
+ searchBlock = ps === void 0 ? null : Object.freeze({ provider: ps.provider, ...typeof ps.keyFile === "string" ? { keyFile: ps.keyFile } : {} });
34772
+ appliedSearch = searchBlock;
34773
+ } else {
34774
+ mismatches.add("search");
34775
+ searchBlock = (prior.search?.provider ?? void 0) === cfg.search?.provider ? prior.search : void 0;
34776
+ }
34777
+ const skillsDoc = {
34778
+ endpoints: eps.written,
34779
+ databases: dbs.written,
34780
+ flags: [...cfg.flags],
34781
+ ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
34782
+ ...searchBlock ? { search: searchBlock } : {}
34783
+ };
34784
+ out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
34785
+ const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
34786
+ let memory;
34787
+ if (isObj2(payload.memory)) {
34788
+ const pm = payload.memory;
34789
+ const sameMode = pm.mode === cfg.memory.mode;
34790
+ const sameDays = cfg.memory.mode === "semantic" ? pm.recall_days === cfg.memory.recall_days : !("recall_days" in pm);
34791
+ if (!sameMode || !sameDays) mismatches.add("memory");
34792
+ const { recall_days: _r, mode: _m, ...rest } = pm;
34793
+ memory = { ...signedMemory, ...rest };
34794
+ } else {
34795
+ mismatches.add("memory");
34796
+ memory = { ...signedMemory };
34797
+ }
34798
+ const prunes = payload.memory_prunes === void 0 ? [] : payload.memory_prunes;
34799
+ const decisions = Array.isArray(payload.legacy_memory_fact_decisions) ? payload.legacy_memory_fact_decisions : [];
34800
+ const assignMatch = guarded(() => Array.isArray(prunes) && legacyAssignSha256(prunes) === cfg.legacy_assign_sha256);
34801
+ let legacyDecisions;
34802
+ if (assignMatch === true) {
34803
+ const verified = new Set(prunes.filter((p) => typeof p === "string" && p.startsWith(ASSIGN_PREFIX)));
34804
+ legacyDecisions = decisions.filter((d) => {
34805
+ if (!isObj2(d) || d.action !== "assign") return true;
34806
+ if (typeof d.source_id !== "string" || typeof d.fact_hash !== "string" || typeof d.channel_id !== "string") return false;
34807
+ return verified.has(legacyAssignToken({ source_id: d.source_id, fact_hash: d.fact_hash, channel_id: d.channel_id }));
34808
+ });
34809
+ } else {
34810
+ mismatches.add("legacy_assign");
34811
+ legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
34812
+ }
34813
+ const signedTools = (a) => MANIFEST_KNOWN_TOOLS[a].filter((t) => (manifest.grants[a] ?? []).includes(t));
34814
+ const policy = {
34815
+ v: 1,
34816
+ manifest_tools: [.../* @__PURE__ */ new Set([...switches.ownerDm ? signedTools("owner_dm") : [], ...switches.room ? signedTools("team_room") : []])].sort(),
34817
+ secret_path_mode: "enforce"
34818
+ };
34819
+ return {
34820
+ mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
34821
+ targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
34822
+ files: out,
34823
+ memory,
34824
+ signedTargets: { endpoints: Object.keys(eps.written).length, databases: Object.keys(dbs.written).length },
34825
+ policy,
34826
+ appliedSearch,
34827
+ legacyDecisions
34828
+ };
34829
+ }
34830
+
34831
+ // src/grants/room-search-cap.ts
34832
+ var ROOM_SEARCH_DAILY_CAP = 50;
34833
+ var ROOM_SEARCH_CAP_ENV = "SECURECOMS_ROOM_SEARCH_DAILY_CAP";
34834
+ function parseRoomSearchCap(v, warn) {
34835
+ if (v === void 0) return ROOM_SEARCH_DAILY_CAP;
34836
+ if (/^\d+$/.test(v)) {
34837
+ const n = Number(v);
34838
+ if (n <= ROOM_SEARCH_DAILY_CAP) return n;
34839
+ }
34840
+ warn?.();
34841
+ return ROOM_SEARCH_DAILY_CAP;
34842
+ }
34843
+ var AGENT_ROOM_SEARCH_DAILY_CAP = 200;
34844
+ var AGENT_ROOM_SEARCH_CAP_ENV = "SECURECOMS_AGENT_ROOM_SEARCH_DAILY_CAP";
34845
+ function parseAgentRoomSearchCap(v, warn) {
34846
+ if (v === void 0) return AGENT_ROOM_SEARCH_DAILY_CAP;
34847
+ if (/^\d+$/.test(v)) {
34848
+ const n = Number(v);
34849
+ if (n <= AGENT_ROOM_SEARCH_DAILY_CAP) return n;
34850
+ }
34851
+ warn?.();
34852
+ return AGENT_ROOM_SEARCH_DAILY_CAP;
34853
+ }
34854
+ var AGENT_TOTAL_KEY = "agent-total";
34855
+
34856
+ // src/grants/effective.ts
34857
+ var TOOL_GRANT_CODE_CEILING = MANIFEST_KNOWN_TOOLS;
34858
+ var CURRENT_TIME = Object.freeze({ tool: "current_time", mode: "read" });
34859
+ function effectiveToolGrants(i) {
34860
+ if (i.base.length === 0) return { grants: i.base, source: "none", withheldBySearchCap: false };
34861
+ if (i.manifest === null) return { grants: i.base, source: "constant", withheldBySearchCap: false };
34862
+ const ceiling = TOOL_GRANT_CODE_CEILING[i.audience];
34863
+ const listed = new Set(i.manifest.grants[i.audience] ?? []);
34864
+ const signed = ceiling.filter((t) => listed.has(t));
34865
+ if (!i.eligible || !i.manifestSwitchOn) {
34866
+ const fallback = i.base.filter((g) => g.tool !== "current_time" && signed.includes(g.tool));
34867
+ return { grants: Object.freeze([CURRENT_TIME, ...fallback]), source: "fallback", withheldBySearchCap: false };
34868
+ }
34869
+ let withheld = false;
34870
+ const tools = signed.filter((t) => {
34871
+ if (i.disabled.has(t)) return false;
34872
+ if (t === "http_request" && i.signedTargets.endpoints === 0) return false;
34873
+ if (t === "sql_query" && i.signedTargets.databases === 0) return false;
34874
+ if (i.audience === "team_room" && t === "web_search" && i.roomSearchCapped) {
34875
+ withheld = true;
34876
+ return false;
34877
+ }
34878
+ return true;
34879
+ });
34880
+ return {
34881
+ grants: Object.freeze([CURRENT_TIME, ...tools.map((tool) => Object.freeze({ tool, mode: "read", source: "manifest" }))]),
34882
+ source: "manifest",
34883
+ withheldBySearchCap: withheld
34884
+ };
34885
+ }
34886
+
33822
34887
  // src/bridge-agent.ts
33823
34888
  import { homedir as homedir4 } from "node:os";
33824
34889
 
@@ -33907,6 +34972,50 @@ function roomIsCrossOrg(rows) {
33907
34972
  return unknown ? null : false;
33908
34973
  }
33909
34974
 
34975
+ // src/grants/owner-turn.ts
34976
+ import { ed25519 as ed255196 } from "@noble/curves/ed25519";
34977
+ var HEX = /^(?:[0-9a-fA-F]{2})+$/;
34978
+ function verifyOwnerSignature(entry, pin) {
34979
+ if (!entry.identitySignature || !entry.payloadHex) return { state: "no_signature" };
34980
+ if (!HEX.test(entry.payloadHex)) return { state: "malformed" };
34981
+ const sig = canonicalBase64Bytes(entry.identitySignature, 64);
34982
+ if (!sig) return { state: "malformed" };
34983
+ const bytes = new Uint8Array(Buffer.from(entry.payloadHex, "hex"));
34984
+ let envelope;
34985
+ try {
34986
+ envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
34987
+ } catch {
34988
+ return { state: "malformed" };
34989
+ }
34990
+ const algo = envelope?.identity_proof?.algo;
34991
+ if (typeof envelope !== "object" || envelope === null || algo === void 0 || algo === null) return { state: "malformed" };
34992
+ if (algo !== "ed25519") return { state: "unknown_algo" };
34993
+ let good;
34994
+ try {
34995
+ good = ed255196.verify(sig, bytes, pin.publicKey, { zip215: false });
34996
+ } catch {
34997
+ return { state: "malformed" };
34998
+ }
34999
+ return good ? { state: "ok", envelope } : { state: "bad_signature" };
35000
+ }
35001
+ function classifyOwnerDmLeaves(leaves, allowed) {
35002
+ if (leaves.some((l) => !l.basic || l.identity === null)) return "foreign";
35003
+ if (!leaves.some((l) => l.identity === allowed.ownerIdentityId)) return "foreign";
35004
+ const unexpected = /* @__PURE__ */ new Set();
35005
+ for (const l of leaves) {
35006
+ const id = l.identity;
35007
+ if (id === allowed.ownerIdentityId || id === allowed.agentIdentityId) continue;
35008
+ if (allowed.relayIdentityId !== void 0 && id === allowed.relayIdentityId) continue;
35009
+ unexpected.add(id);
35010
+ }
35011
+ if (unexpected.size === 0) return "ok";
35012
+ if (unexpected.size === 1) {
35013
+ const [only] = unexpected;
35014
+ if (allowed.isServerRelay(only)) return "unsigned_relay";
35015
+ }
35016
+ return "foreign";
35017
+ }
35018
+
33910
35019
  // src/bridge-agent.ts
33911
35020
  function logSendSupersededAdoption(kind2) {
33912
35021
  switch (kind2) {
@@ -33959,6 +35068,9 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
33959
35068
  Object.freeze({ tool: "current_time", mode: "read" })
33960
35069
  ]);
33961
35070
  var NO_TOOL_GRANTS = Object.freeze([]);
35071
+ var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
35072
+ var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
35073
+ var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
33962
35074
  function ownerDmDefaultGrantsEnabled(env = process.env) {
33963
35075
  const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
33964
35076
  return v === void 0 || v === "on";
@@ -33967,6 +35079,14 @@ function roomDefaultGrantsEnabled(env = process.env) {
33967
35079
  const v = env.SECURECOMS_ROOM_DEFAULT_GRANTS;
33968
35080
  return v === void 0 || v === "on";
33969
35081
  }
35082
+ function ownerDmManifestGrantsEnabled(env = process.env) {
35083
+ const v = env.SECURECOMS_OWNER_DM_MANIFEST_GRANTS;
35084
+ return v === void 0 || v === "on";
35085
+ }
35086
+ function roomManifestGrantsEnabled(env = process.env) {
35087
+ const v = env.SECURECOMS_ROOM_MANIFEST_GRANTS;
35088
+ return v === void 0 || v === "on";
35089
+ }
33970
35090
  var MAX_PENDING_NOTE_REQUESTS = 8;
33971
35091
  var INGEST_STAMP_KEY = "_sc_notes_ingest_generation";
33972
35092
  function validIngestStamp(v) {
@@ -34053,6 +35173,17 @@ function redeliveryDelayMs(attempts) {
34053
35173
  return 2 ** Math.min(Math.max(attempts, 0), 16) * REDELIVERY_BACKOFF_BASE_MS;
34054
35174
  }
34055
35175
  var DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
35176
+ function holdsManifestGrant(a) {
35177
+ return a?.tool_grants?.some((g) => g.mode === "read" && g.source === "manifest") ?? false;
35178
+ }
35179
+ function greetingAuthorization(a) {
35180
+ const { memory_write: _w, ...rest } = a;
35181
+ return Object.freeze({ ...rest, tool_grants: NO_TOOL_GRANTS });
35182
+ }
35183
+ function utcDayOf(ms) {
35184
+ return new Date(ms).toISOString().slice(0, 10);
35185
+ }
35186
+ var ROOM_SEARCH_CAP_NOTICE = "I've used today's web searches for this room. I can still help without searching. Searches are available again tomorrow (UTC).";
34056
35187
  var DEAD_LETTER_NOTICE_INTERVAL_MS = 60 * 6e4;
34057
35188
  var PAUSE_RECHECK_MS = 3e4;
34058
35189
  var SUPERSEDE_RECHECK_MS = 3e4;
@@ -34181,7 +35312,7 @@ var FileOwnerAuthorityStore = class {
34181
35312
  async save(enclaveId, pin) {
34182
35313
  const all = await this.readAll();
34183
35314
  all[enclaveId] = pin;
34184
- await fsp.mkdir(dirname4(this.path), { recursive: true });
35315
+ await fsp.mkdir(dirname5(this.path), { recursive: true });
34185
35316
  await fsp.writeFile(this.path, JSON.stringify(all), "utf8");
34186
35317
  }
34187
35318
  };
@@ -34212,6 +35343,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
34212
35343
  var RoomGrantEpochMovedError = class extends Error {
34213
35344
  };
34214
35345
  var ROOM_GRANT_TRACK_MAX = 4096;
35346
+ var SessionSupersededError = class extends Error {
35347
+ constructor() {
35348
+ super("session_superseded: another copy of this agent took its session during start()");
35349
+ this.name = "SessionSupersededError";
35350
+ }
35351
+ };
34215
35352
  var GroupSupersededSendError = class extends Error {
34216
35353
  constructor(channelId, activeGroupId) {
34217
35354
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -34251,9 +35388,18 @@ var BridgeAgent = class _BridgeAgent {
34251
35388
  const seed = hexToBytes4(cfg.signingPrivateKey);
34252
35389
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
34253
35390
  this.replyIdKey = deriveReplyIdKey(seed);
35391
+ const base = deps.fetchImpl ?? fetch;
35392
+ this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
34254
35393
  this.transport = deps.transport ?? new RestTransport({
34255
35394
  baseUrl: cfg.apiBase,
34256
35395
  bearerToken: cfg.apiKey,
35396
+ fetchImpl: this.fetchImpl,
35397
+ wsImpl: deps.wsImpl,
35398
+ // A 4409 close means another copy of this agent took the session.
35399
+ // Every other terminal close keeps today's behaviour.
35400
+ onTerminalClose: (close) => {
35401
+ if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
35402
+ },
34257
35403
  connectIdentityId: cfg.identityId,
34258
35404
  // P1b: hardened delivery-ping reconnect (verify-before-terminal +
34259
35405
  // half-open + wake + auth war-cap). On by default for the daemon;
@@ -34267,7 +35413,7 @@ var BridgeAgent = class _BridgeAgent {
34267
35413
  else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
34268
35414
  }
34269
35415
  });
34270
- this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
35416
+ this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
34271
35417
  this.store = new SqliteMlsStateStore({
34272
35418
  dbPath: cfg.storePath,
34273
35419
  identityId: cfg.identityId,
@@ -34338,8 +35484,13 @@ var BridgeAgent = class _BridgeAgent {
34338
35484
  // throws (mirrors the divergence-heal signal path).
34339
35485
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
34340
35486
  });
34341
- this.fetchImpl = deps.fetchImpl ?? fetch;
34342
35487
  this.home = deps.home ?? homedir4();
35488
+ this.grantWriters = {
35489
+ writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
35490
+ writeManagedFiles: deps.grantWriters?.writeManagedFiles ?? writeManagedFiles,
35491
+ bindRuntimeConfig: deps.grantWriters?.bindRuntimeConfig ?? bindRuntimeConfig,
35492
+ removeManagedLlmSource: deps.grantWriters?.removeManagedLlmSource ?? removeManagedLlmSource
35493
+ };
34343
35494
  this.platform = deps.platform ?? process.platform;
34344
35495
  this.machinePaths = deps.hostMachine === true || deps.home !== void 0;
34345
35496
  this.unifiedOn = unifiedPart1On(deps.unifiedMemoryPart1);
@@ -34354,7 +35505,7 @@ var BridgeAgent = class _BridgeAgent {
34354
35505
  threshold: EMBEDDING_MANIFEST.similarityThreshold,
34355
35506
  warm: windowsSemanticOn(this.platform)
34356
35507
  };
34357
- return deps.rankingTransport ? deps.rankingTransport(init) : workerTransport(init, dirname4(fileURLToPath2(import.meta.url)));
35508
+ return deps.rankingTransport ? deps.rankingTransport(init) : workerTransport(init, dirname5(fileURLToPath2(import.meta.url)));
34358
35509
  }
34359
35510
  });
34360
35511
  this.embedder = deps.embedder ?? (deps.hostMachine === true && deps.home === void 0 ? new EmbedderClient({ identityId: cfg.identityId, home: homedir4(), source: cfg.modelSource, fetchImpl: this.fetchImpl, platform: this.platform }) : INERT_EMBEDDER);
@@ -34365,7 +35516,7 @@ var BridgeAgent = class _BridgeAgent {
34365
35516
  modelId: EMBEDDING_MANIFEST.modelId,
34366
35517
  threshold: EMBEDDING_MANIFEST.similarityThreshold
34367
35518
  });
34368
- this.heartbeat = new CallHeartbeat(this.machinePaths ? join16(this.home, ".securecoms", "run", "voice-call") : null, cfg.identityId, VOICE_LIVE_WINDOW_MS);
35519
+ this.heartbeat = new CallHeartbeat(this.machinePaths ? join17(this.home, ".securecoms", "run", "voice-call") : null, cfg.identityId, VOICE_LIVE_WINDOW_MS);
34369
35520
  this.reembed = new ReembedQueue({
34370
35521
  store: this.store,
34371
35522
  embedder: this.embedder,
@@ -34447,8 +35598,8 @@ var BridgeAgent = class _BridgeAgent {
34447
35598
  const j = await res.json();
34448
35599
  return String(j.attachment_id);
34449
35600
  };
34450
- this.attachmentsDirImpl = deps.attachmentsDir ?? join16(dirname4(cfg.storePath), "attachments");
34451
- this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join16(dirname4(cfg.storePath), "vault-owner-pins.json"));
35601
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join17(dirname5(cfg.storePath), "attachments");
35602
+ this.vaultOwnerStoreImpl = deps.vaultOwnerStore ?? new FileOwnerAuthorityStore(join17(dirname5(cfg.storePath), "vault-owner-pins.json"));
34452
35603
  this.action = {
34453
35604
  begin: (channelId) => this.actionBegin(channelId),
34454
35605
  start: (opts) => this.actionStart(opts),
@@ -34464,8 +35615,39 @@ var BridgeAgent = class _BridgeAgent {
34464
35615
  // stored for peek-then-join in ingestOnce
34465
35616
  custody;
34466
35617
  fetchImpl;
35618
+ /** P-1: set once a 4409 close says another copy took this agent's session.
35619
+ * The gated fetch built in the constructor refuses every request after it. */
35620
+ sessionSuperseded = false;
35621
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
35622
+ sessionSupersededListeners = /* @__PURE__ */ new Set();
35623
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
35624
+ closing = null;
34467
35625
  /** Round 5: home for the managed runtime-config directory (test seam). */
34468
35626
  home;
35627
+ grantWriters;
35628
+ /** Owner grants (spec §7.5, OQ-2): the operator cap, read once; the same variable the runtime reads. 0 = no room search. */
35629
+ roomSearchDailyCap = parseRoomSearchCap(process.env[ROOM_SEARCH_CAP_ENV], () => writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("cap_invalid", ["cap_invalid"]) }));
35630
+ /** Review M-3 (ERIC APPROVALS 3): the per-agent total across rooms, read once; the same variable the runtime reads. */
35631
+ agentRoomSearchDailyCap = parseAgentRoomSearchCap(process.env[AGENT_ROOM_SEARCH_CAP_ENV], () => writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("agent_cap_invalid", ["agent_cap_invalid"]) }));
35632
+ /** `${day}\n${channel}` keys already logged reached today (in memory; the store holds the durable flag). */
35633
+ roomCapLogged = /* @__PURE__ */ new Set();
35634
+ /** Rooms whose K-14 send is in flight: a second frame posts nothing more. */
35635
+ roomCapNoticePending = /* @__PURE__ */ new Set();
35636
+ /** Held entries whose staged copy lost web_search to the backstop; applied again at release. */
35637
+ backstopStripped = /* @__PURE__ */ new Set();
35638
+ /**
35639
+ * Owner grants plan 4 Task 11 (P-4, P-6): what the managed directory holds under the current manifest.
35640
+ * Manifest grants require state "bound" at the applied counter (Task 13). null at startup, while a
35641
+ * refetch narrows it, and after any throw (the fallback).
35642
+ */
35643
+ manifestBinding = null;
35644
+ /** skills.json tools.deny from the latest fetch, manifest or not (spec §5.2 `disabled`). */
35645
+ payloadToolDeny = /* @__PURE__ */ new Set();
35646
+ /** Spec §7.7 (OQ-5): the latest fetch's binding outcome, reported in tool_grant_status (Task 15). */
35647
+ configBindingMismatches = [];
35648
+ targetMismatches = { endpoint: 0, database: 0 };
35649
+ /** P-5 (Task 14): the legacy fact decisions the latest bound fetch may apply (assigns verified against the signed set). */
35650
+ boundLegacyDecisions = [];
34469
35651
  joinState = /* @__PURE__ */ new Map();
34470
35652
  /**
34471
35653
  * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
@@ -34769,11 +35951,29 @@ var BridgeAgent = class _BridgeAgent {
34769
35951
  ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
34770
35952
  /** Read once at startup; see roomDefaultGrantsEnabled. */
34771
35953
  roomDefaultGrants = roomDefaultGrantsEnabled();
35954
+ /** Owner grants (spec §7.4): the two manifest switches, read once at construction. */
35955
+ ownerDmManifestGrants = ownerDmManifestGrantsEnabled();
35956
+ roomManifestGrants = roomManifestGrantsEnabled();
34772
35957
  /**
34773
35958
  * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
34774
35959
  * cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
34775
35960
  */
34776
35961
  ownerDmCycleFresh = /* @__PURE__ */ new Set();
35962
+ // Owner-signed tool grants, step 1 (plan 1 task 7). The verified manifest is
35963
+ // RECORDED here and in the sealed store; no grant code reads it in step 1.
35964
+ toolGrantManifest = null;
35965
+ toolGrantCounter = null;
35966
+ manifestState = "no_manifest";
35967
+ /** Failed owner-turn checks since the last successful runtime-config report (§7.7). */
35968
+ ownerVerifyFailures = 0;
35969
+ /** Verified owner turns of this ingest cycle, recorded in owner_replay when staged (P-3). */
35970
+ ownerReplayPending = /* @__PURE__ */ new Map();
35971
+ /** D-9: ids of owner turns that failed verification (never `replay`, audit E-3), id -> channel. Persisted too. */
35972
+ ownerVerifyFailedIds = /* @__PURE__ */ new Map();
35973
+ serverOwnerMismatchLogged = false;
35974
+ /** Audit L-2: the replay prune runs at most once per interval, on a monotonic clock. */
35975
+ monoNow = () => performance.now();
35976
+ lastOwnerReplayPruneAt = -Infinity;
34777
35977
  /**
34778
35978
  * Audit M-B1: room-granted turns, keyed by the inbound message id, with the
34779
35979
  * channel, MLS group and epoch the room was classified at (review I-1: an
@@ -34897,6 +36097,12 @@ var BridgeAgent = class _BridgeAgent {
34897
36097
  action;
34898
36098
  waiters = [];
34899
36099
  pingHandle;
36100
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
36101
+ * held unconsumed until startReceivePump() takes it. */
36102
+ earlyPings = null;
36103
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
36104
+ * awaits it, bounded, so the store never closes under a start() step. */
36105
+ startInFlight = null;
34900
36106
  safetyTimer;
34901
36107
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
34902
36108
  ingestInFlight = null;
@@ -35216,14 +36422,38 @@ var BridgeAgent = class _BridgeAgent {
35216
36422
  return;
35217
36423
  }
35218
36424
  const version = payload.version;
35219
- await this.applyPayloadMemory(payload.memory);
36425
+ const skillsForDeny = payload.skills_json;
36426
+ const denyRaw = skillsForDeny?.tools?.deny;
36427
+ this.payloadToolDeny = new Set(Array.isArray(denyRaw) ? denyRaw.filter((t) => typeof t === "string") : []);
36428
+ let manifestStepThrew = false;
36429
+ try {
36430
+ await this.applyToolGrantManifest(payload.tool_grant_manifest);
36431
+ } catch (e) {
36432
+ manifestStepThrew = true;
36433
+ writeServiceLog2("warn", "authz.config_binding", { state: ServiceLogEnum.of("bind_failed", ["bind_failed"]) }, e);
36434
+ }
36435
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
36436
+ const inForce = pin !== null ? this.toolGrantManifest : null;
36437
+ if (inForce !== null && pin !== null) {
36438
+ const applied = manifestStepThrew ? await this.bindFailed(inForce, payload.memory, void 0) : await this.applyUnderManifest(inForce, pin.publicKeyB64, payload, version);
36439
+ if (!applied) {
36440
+ await this.postRuntimeConfigReport(base, version, payload.skills_json ?? {});
36441
+ return;
36442
+ }
36443
+ } else {
36444
+ this.manifestBinding = null;
36445
+ this.configBindingMismatches = [];
36446
+ this.targetMismatches = { endpoint: 0, database: 0 };
36447
+ await this.applyPayloadMemory(payload.memory);
36448
+ this.overwriteStalePolicy();
36449
+ }
35220
36450
  if (!version) {
35221
36451
  writeManagedFiles(this.home, this.cfg.identityId, {});
35222
36452
  writeServiceLog2("info", "runtime_config.not_saved");
35223
36453
  }
35224
36454
  const skillsJson = payload.skills_json ?? {};
35225
- if (version) {
35226
- writeManagedFiles(this.home, this.cfg.identityId, {
36455
+ if (version && inForce === null) {
36456
+ this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, {
35227
36457
  ...payload.files ?? {},
35228
36458
  "skills.json": `${JSON.stringify(skillsJson, null, 2)}
35229
36459
  `
@@ -35235,12 +36465,13 @@ var BridgeAgent = class _BridgeAgent {
35235
36465
  if (payload.legacy_memory_deletes?.length) {
35236
36466
  deleteLegacyMemorySources(this.home, this.cfg.identityId, payload.legacy_memory_deletes);
35237
36467
  }
35238
- if (payload.legacy_memory_fact_decisions?.length) {
36468
+ const legacyDecisions = inForce !== null ? this.boundLegacyDecisions : payload.legacy_memory_fact_decisions;
36469
+ if (legacyDecisions?.length) {
35239
36470
  const provider = this.cfg.memoryProvider ?? "securecoms";
35240
36471
  const result = await applyLegacyMemoryFactDecisions(
35241
36472
  this.home,
35242
36473
  this.cfg.identityId,
35243
- payload.legacy_memory_fact_decisions,
36474
+ legacyDecisions,
35244
36475
  async (channelId, fact, assignmentKey) => {
35245
36476
  if (provider === "none" || provider === "hermes") {
35246
36477
  throw new Error("configured memory provider has no protected channel-fact import path");
@@ -35259,72 +36490,177 @@ var BridgeAgent = class _BridgeAgent {
35259
36490
  });
35260
36491
  }
35261
36492
  }
35262
- const local = readLocalReport(this.home, this.cfg.identityId);
35263
- const report = {
35264
- applied_version: version,
35265
- paths: checkConfiguredPaths(skillsJson),
35266
- // Read AFTER the prunes above, so the owner's next view of memory is
35267
- // the file as it now stands, not the one they just pruned from.
35268
- memory: readManagedMemory(this.home, this.cfg.identityId),
35269
- // E13: pass through what the runtime reported. The daemon does not
35270
- // interpret any of it.
35271
- local_persona: local?.persona ?? null,
35272
- // Only the runtime knows whether a search key file is configured — it
35273
- // is an environment value on that process, invisible to the daemon and
35274
- // to the backend. The backend needs it to generate a truthful tool
35275
- // paragraph, so it rides along here.
35276
- has_search_key: local?.has_search_key ?? false,
35277
- // Same idea, for the BYOK LLM key file: only the runtime can see
35278
- // whether one is configured in its own environment.
35279
- has_llm_key: local?.has_llm_key ?? false,
35280
- // The backend uses this only to expose target channels compatible with
35281
- // the actual protected-memory adapter. It is an enum, never a path or
35282
- // provider credential.
35283
- memory_provider: this.cfg.memoryProvider ?? "securecoms",
35284
- // Spec §11.1-11.2 (OQ-E): flat keys; the string `memory` above is
35285
- // unchanged. memory_notes is true only when notes are actually on (the
35286
- // provider is securecoms and the parsed payload.memory keeps them on);
35287
- // the generation is the store's applied value, never the payload's.
35288
- // Final review I-1: and only once a notes-reading runtime said hello.
35289
- memory_notes: this.notesProviderOn() && this.notesConsumerSeen,
35290
- // Unified memory (plan Task 31): only while the compiled part 1 switch is on, so a
35291
- // 0.28-equivalent bridge never makes the web show the unified panel. Numbers and
35292
- // booleans only (U1).
35293
- ...this.reportedUnified(),
35294
- ...this.reportedClearGeneration(),
35295
- // 7b spec §9.2 (Task 19): capability versus state. Read-only: the report never starts the embedder.
35296
- ...this.reportedRecall(),
35297
- // Inert legacy files only. `readLocalReport` revalidates the current
35298
- // regular-file bytes and strips every local path before this leaves the
35299
- // machine.
35300
- legacy_memory: local?.legacy_memory ?? [],
35301
- // §B.4: counts of server-named secret paths inside / allowlisted /
35302
- // outside the secrets directory, and the runtime's mode. Numbers and
35303
- // one enum only; readLocalReport rebuilds it so no path rides along.
35304
- secret_paths: local?.secret_paths ?? null
35305
- };
35306
- const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
35307
- method: "POST",
35308
- headers: {
35309
- authorization: `Bearer ${this.cfg.apiKey}`,
35310
- "content-type": "application/json"
35311
- },
35312
- body: JSON.stringify(report)
35313
- });
35314
- if (!posted.ok) {
35315
- if (version) {
35316
- writeServiceLog2("warn", "runtime_config.report_failed", { status: posted.status, version });
35317
- } else {
35318
- writeServiceLog2("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
35319
- }
35320
- return;
35321
- }
35322
- if (version) writeServiceLog2("info", "runtime_config.applied", { version });
35323
- else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
36493
+ await this.postRuntimeConfigReport(base, version, skillsJson);
35324
36494
  } catch (e) {
35325
36495
  writeServiceLog2("warn", "runtime_config.refetch_failed", {}, e);
35326
36496
  }
35327
36497
  }
36498
+ /**
36499
+ * Plan 4 Task 11: the refetch under a manifest in force. Returns false when the binding failed (H-4),
36500
+ * after the memory was applied; the caller then writes nothing else. Throws only from a file write, with
36501
+ * the binding left null.
36502
+ */
36503
+ async applyUnderManifest(manifest, pinKeyB64, payload, version) {
36504
+ let prior;
36505
+ try {
36506
+ prior = (await this.store.loadToolGrantApplied(pinKeyB64))?.search;
36507
+ } catch (e) {
36508
+ prior = void 0;
36509
+ writeServiceLog2("warn", "authz.config_binding", { state: ServiceLogEnum.of("applied_read_failed", ["applied_read_failed"]) }, e);
36510
+ }
36511
+ let bound;
36512
+ try {
36513
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants });
36514
+ } catch (e) {
36515
+ bound = null;
36516
+ return this.bindFailed(manifest, payload.memory, e);
36517
+ }
36518
+ const counter = manifest.counter;
36519
+ const state = bound.mismatches.length === 0 ? "bound" : "mismatch";
36520
+ const old = this.manifestBinding;
36521
+ if (!(old !== null && old.state === "bound" && old.counter === counter && state === "bound")) this.manifestBinding = null;
36522
+ this.configBindingMismatches = bound.mismatches;
36523
+ this.boundLegacyDecisions = bound.legacyDecisions;
36524
+ this.logConfigBinding(bound);
36525
+ this.targetMismatches = { endpoint: bound.targetMismatches.endpoint, database: bound.targetMismatches.database };
36526
+ await this.applyPayloadMemory(bound.memory);
36527
+ if (!version) {
36528
+ this.manifestBinding = null;
36529
+ return true;
36530
+ }
36531
+ try {
36532
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, bound.policy);
36533
+ this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
36534
+ if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
36535
+ } catch (e) {
36536
+ this.manifestBinding = null;
36537
+ throw e;
36538
+ }
36539
+ this.manifestBinding = { counter, state, signedTargets: { ...bound.signedTargets } };
36540
+ if (bound.appliedSearch !== void 0) {
36541
+ try {
36542
+ await this.store.saveToolGrantApplied(pinKeyB64, { counter, search: bound.appliedSearch });
36543
+ } catch (e) {
36544
+ writeServiceLog2("warn", "authz.config_binding", { state: ServiceLogEnum.of("applied_save_failed", ["applied_save_failed"]) }, e);
36545
+ }
36546
+ }
36547
+ return true;
36548
+ }
36549
+ /** Spec §7.6 (plan 4 Task 15): content-free. One line per mismatched field, one per dropped target. */
36550
+ logConfigBinding(bound) {
36551
+ if (bound.mismatches.length === 0) {
36552
+ writeServiceLog2("info", "authz.config_binding", { state: ServiceLogEnum.of("ok", ["ok"]) });
36553
+ }
36554
+ for (const field of bound.mismatches) {
36555
+ writeServiceLog2("warn", "authz.config_binding", {
36556
+ state: ServiceLogEnum.of("mismatch", ["mismatch"]),
36557
+ field: ServiceLogEnum.of(field, ["persona", "memory", "search", "tools_allow", "flags", "llm", "files", "legacy_assign"])
36558
+ });
36559
+ }
36560
+ for (const kind2 of ["endpoint", "database"]) {
36561
+ for (let i = 0; i < bound.targetMismatches[kind2]; i++) {
36562
+ writeServiceLog2("warn", "authz.target_mismatch", { kind: ServiceLogEnum.of(kind2, ["endpoint", "database"]) });
36563
+ }
36564
+ }
36565
+ }
36566
+ /** H-4: the binding could not be computed. The memory still applies, with the signed mode; nothing else is written. */
36567
+ async bindFailed(manifest, rawMemory, e) {
36568
+ this.manifestBinding = null;
36569
+ writeServiceLog2("warn", "authz.config_binding", { state: ServiceLogEnum.of("bind_failed", ["bind_failed"]) }, e);
36570
+ const signed = { mode: manifest.config.memory.mode, ...manifest.config.memory.recall_days !== void 0 ? { recall_days: manifest.config.memory.recall_days } : {} };
36571
+ let memory = signed;
36572
+ if (rawMemory !== null && typeof rawMemory === "object" && !Array.isArray(rawMemory)) {
36573
+ const { mode: _m, recall_days: _r, ...rest } = rawMemory;
36574
+ memory = { ...signed, ...rest };
36575
+ }
36576
+ await this.applyPayloadMemory(memory);
36577
+ return false;
36578
+ }
36579
+ /** No manifest in force: a manifest-policy.json left by an earlier one is replaced by an empty tool list. */
36580
+ overwriteStalePolicy() {
36581
+ if (!existsSync5(join17(runtimeConfigDir(this.home, this.cfg.identityId), "manifest-policy.json"))) return;
36582
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, { v: 1, manifest_tools: [], secret_path_mode: "enforce" });
36583
+ }
36584
+ /** The report half of refetchRuntimeConfigOnce (split out for the plan 4 Task 11 bind_failed path). */
36585
+ async postRuntimeConfigReport(base, version, skillsJson) {
36586
+ const local = readLocalReport(this.home, this.cfg.identityId);
36587
+ const report = {
36588
+ applied_version: version,
36589
+ paths: checkConfiguredPaths(skillsJson),
36590
+ // Read AFTER the prunes above, so the owner's next view of memory is
36591
+ // the file as it now stands, not the one they just pruned from.
36592
+ memory: readManagedMemory(this.home, this.cfg.identityId),
36593
+ // E13: pass through what the runtime reported. The daemon does not
36594
+ // interpret any of it.
36595
+ local_persona: local?.persona ?? null,
36596
+ // Only the runtime knows whether a search key file is configured — it
36597
+ // is an environment value on that process, invisible to the daemon and
36598
+ // to the backend. The backend needs it to generate a truthful tool
36599
+ // paragraph, so it rides along here.
36600
+ has_search_key: local?.has_search_key ?? false,
36601
+ // Same idea, for the BYOK LLM key file: only the runtime can see
36602
+ // whether one is configured in its own environment.
36603
+ has_llm_key: local?.has_llm_key ?? false,
36604
+ // The backend uses this only to expose target channels compatible with
36605
+ // the actual protected-memory adapter. It is an enum, never a path or
36606
+ // provider credential.
36607
+ memory_provider: this.cfg.memoryProvider ?? "securecoms",
36608
+ // Spec §11.1-11.2 (OQ-E): flat keys; the string `memory` above is
36609
+ // unchanged. memory_notes is true only when notes are actually on (the
36610
+ // provider is securecoms and the parsed payload.memory keeps them on);
36611
+ // the generation is the store's applied value, never the payload's.
36612
+ // Final review I-1: and only once a notes-reading runtime said hello.
36613
+ memory_notes: this.notesProviderOn() && this.notesConsumerSeen,
36614
+ // Unified memory (plan Task 31): only while the compiled part 1 switch is on, so a
36615
+ // 0.28-equivalent bridge never makes the web show the unified panel. Numbers and
36616
+ // booleans only (U1).
36617
+ ...this.reportedUnified(),
36618
+ ...this.reportedClearGeneration(),
36619
+ // 7b spec §9.2 (Task 19): capability versus state. Read-only: the report never starts the embedder.
36620
+ ...this.reportedRecall(),
36621
+ // Inert legacy files only. `readLocalReport` revalidates the current
36622
+ // regular-file bytes and strips every local path before this leaves the
36623
+ // machine.
36624
+ legacy_memory: local?.legacy_memory ?? [],
36625
+ // §B.4: counts of server-named secret paths inside / allowlisted /
36626
+ // outside the secrets directory, and the runtime's mode. Numbers and
36627
+ // one enum only; readLocalReport rebuilds it so no path rides along.
36628
+ secret_paths: local?.secret_paths ?? null,
36629
+ // Owner-signed tool grants (spec §7.7, D-6): enums and numbers only. It
36630
+ // authorizes nothing and a compromised backend can forge what it shows
36631
+ // (residual 9). The backend drops it until Plan 2 or 3 stores it.
36632
+ tool_grant_status: {
36633
+ pin: this.cfg.ownerGrantPin?.state ?? "no_pin",
36634
+ manifest: this.manifestState,
36635
+ applied_counter: this.toolGrantCounter,
36636
+ owner_dm_verify_failures: this.ownerVerifyFailures,
36637
+ // Plan 4 Task 15 (OQ-5): sorted, unique enums; capability is the latest hello's class.
36638
+ config_binding_mismatches: [...this.configBindingMismatches],
36639
+ target_mismatches: ["database", "endpoint"].filter((k) => this.targetMismatches[k] > 0),
36640
+ capability: this.lastConsumerCapability
36641
+ }
36642
+ };
36643
+ const reportedVerifyFailures = this.ownerVerifyFailures;
36644
+ const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
36645
+ method: "POST",
36646
+ headers: {
36647
+ authorization: `Bearer ${this.cfg.apiKey}`,
36648
+ "content-type": "application/json"
36649
+ },
36650
+ body: JSON.stringify(report)
36651
+ });
36652
+ if (!posted.ok) {
36653
+ if (version) {
36654
+ writeServiceLog2("warn", "runtime_config.report_failed", { status: posted.status, version });
36655
+ } else {
36656
+ writeServiceLog2("warn", "runtime_config.legacy_memory_report_failed", { status: posted.status });
36657
+ }
36658
+ return;
36659
+ }
36660
+ this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
36661
+ if (version) writeServiceLog2("info", "runtime_config.applied", { version });
36662
+ else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
36663
+ }
35328
36664
  /**
35329
36665
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
35330
36666
  *
@@ -35344,9 +36680,58 @@ var BridgeAgent = class _BridgeAgent {
35344
36680
  this.droppedMessages += overflow;
35345
36681
  }
35346
36682
  }
36683
+ /**
36684
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
36685
+ * delivery-ping socket opens here, before anything else, so this copy claims
36686
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
36687
+ * boot drain runs. The pings it carries are NOT consumed until
36688
+ * startReceivePump(), the same place as before: starting the pump first is
36689
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
36690
+ *
36691
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
36692
+ * 0). start() then arms nothing more (supersededDuringStart before each
36693
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
36694
+ * with SessionSupersededError. A step that FAILED because of the supersede
36695
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
36696
+ */
35347
36697
  async start() {
36698
+ const early = this.transport.subscribeDeliveryPings("");
36699
+ this.earlyPings = early;
36700
+ this.pingHandle = { close: early.close };
36701
+ const body = this.startBody();
36702
+ this.startInFlight = body.then(() => {
36703
+ }, () => {
36704
+ });
36705
+ try {
36706
+ await body;
36707
+ } catch (e) {
36708
+ if (!this.sessionSuperseded) {
36709
+ if (this.earlyPings) {
36710
+ this.earlyPings.close();
36711
+ this.earlyPings = null;
36712
+ this.pingHandle = void 0;
36713
+ }
36714
+ throw e;
36715
+ }
36716
+ if (!(e instanceof SessionSupersededError)) writeServiceLog2("warn", "startup.superseded_start_error", {}, e);
36717
+ await this.close().catch(() => {
36718
+ });
36719
+ throw new SessionSupersededError();
36720
+ }
36721
+ }
36722
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
36723
+ supersededDuringStart() {
36724
+ return this.sessionSuperseded;
36725
+ }
36726
+ async startBody() {
35348
36727
  this.startedAt = Date.now();
35349
36728
  await this.restoreRuntimeInbox();
36729
+ await this.loadToolGrantState();
36730
+ try {
36731
+ this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
36732
+ } catch (e) {
36733
+ writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("store_failed", ["store_failed"]) }, e);
36734
+ }
35350
36735
  try {
35351
36736
  this.store.pruneRecall();
35352
36737
  this.store.reconcileRecallClearGeneration();
@@ -35398,10 +36783,12 @@ var BridgeAgent = class _BridgeAgent {
35398
36783
  await this.warmRestoredNotes();
35399
36784
  await this.reindexRestoredTurns();
35400
36785
  if (this.unifiedOn) await this.reindexUnifiedRestored();
36786
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
35401
36787
  this.runtimeConfigTimer = setInterval(() => {
35402
36788
  void this.refetchRuntimeConfig();
35403
36789
  }, 6 * 60 * 60 * 1e3);
35404
36790
  this.runtimeConfigTimer.unref?.();
36791
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
35405
36792
  this.membershipSweepTimer = setInterval(() => {
35406
36793
  if (this.closingDown || this.membershipSweepWork) return;
35407
36794
  this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
@@ -35409,6 +36796,7 @@ var BridgeAgent = class _BridgeAgent {
35409
36796
  });
35410
36797
  }, PURGE_SWEEP_INTERVAL_MS);
35411
36798
  this.membershipSweepTimer.unref?.();
36799
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
35412
36800
  this.recallPruneTimer = setInterval(() => {
35413
36801
  if (this.closingDown || this.recallPruneWork) return;
35414
36802
  this.recallPruneWork = this.runRecallPruneTick().finally(() => {
@@ -35416,6 +36804,7 @@ var BridgeAgent = class _BridgeAgent {
35416
36804
  });
35417
36805
  }, 60 * 60 * 1e3);
35418
36806
  this.recallPruneTimer.unref?.();
36807
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
35419
36808
  if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
35420
36809
  if (this.closingDown) return;
35421
36810
  const n = ++this.maintenanceTickN;
@@ -35426,6 +36815,7 @@ var BridgeAgent = class _BridgeAgent {
35426
36815
  });
35427
36816
  }, 1e3);
35428
36817
  this.recallMaintenanceTimer?.unref?.();
36818
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
35429
36819
  this.startReceivePump();
35430
36820
  if (drainedInTime) {
35431
36821
  this.armSelfHealSweeps();
@@ -36480,8 +37870,10 @@ var BridgeAgent = class _BridgeAgent {
36480
37870
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
36481
37871
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
36482
37872
  const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
36483
- if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch);
36484
- if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null);
37873
+ if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
37874
+ 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";
37875
+ if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
37876
+ if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
36485
37877
  }
36486
37878
  /**
36487
37879
  * Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
@@ -36496,8 +37888,9 @@ var BridgeAgent = class _BridgeAgent {
36496
37888
  return { ...this.store.itemCapture(channelId), clearGeneration: stamp };
36497
37889
  }
36498
37890
  /** Section 7.1 agent reply, section 24.2: the owner-DM predicate for the channel at write (no sender), else nothing. Reference trust always (U18). OQ-P2: bridge-authored Pinned:/Forgot: lines are filtered by the caller. */
36499
- spawnUnifiedReply(channelId, text, messageId, expect) {
37891
+ spawnUnifiedReply(channelId, text, messageId, expect, origin) {
36500
37892
  if (expect === null) return;
37893
+ if (origin === "fast_path") return;
36501
37894
  const work = (async () => {
36502
37895
  try {
36503
37896
  if (!this.unifiedProviderOn()) return;
@@ -36513,7 +37906,9 @@ var BridgeAgent = class _BridgeAgent {
36513
37906
  source: "agent_reply",
36514
37907
  trust: "reference",
36515
37908
  text,
36516
- expect
37909
+ expect,
37910
+ role: "agent",
37911
+ origin
36517
37912
  });
36518
37913
  } catch {
36519
37914
  writeServiceLog2("warn", "memory.unified_reply_failed", {});
@@ -37239,6 +38634,13 @@ var BridgeAgent = class _BridgeAgent {
37239
38634
  );
37240
38635
  return humanCounterparts.length === 1 && humanCounterparts[0][1].is_agent_owner;
37241
38636
  }
38637
+ /**
38638
+ * Decision D-9 (audit H-3): a turn that failed owner verification gets only
38639
+ * its own text, never the prior-turn history snapshot, for every provider.
38640
+ */
38641
+ mayReadHistoryForTurn(messageId, authorization) {
38642
+ return !this.isOwnerVerifyFailed(messageId) && this.mayReadOpenClawMemoryForTurn(authorization);
38643
+ }
37242
38644
  /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
37243
38645
  mayReadOpenClawMemoryForTurn(authorization) {
37244
38646
  return this.cfg.memoryProvider !== "openclaw" || authorization.sender_membership === "active" && authorization.audience === "owner_dm";
@@ -37248,16 +38650,119 @@ var BridgeAgent = class _BridgeAgent {
37248
38650
  * stays owner-DM-only; every provider failure degrades to empty context and
37249
38651
  * never falls back to the runtime's retired plaintext JSON files.
37250
38652
  */
37251
- async memoryForTurn(channelId, authorization, limit = 50) {
37252
- if (!this.mayReadOpenClawMemoryForTurn(authorization)) {
38653
+ async memoryForTurn(channelId, messageId, authorization, limit = 50) {
38654
+ if (!this.mayReadHistoryForTurn(messageId, authorization)) {
37253
38655
  return { recent: [], facts: [] };
37254
38656
  }
37255
38657
  try {
37256
- return await this.memory.context(channelId, limit);
38658
+ return this.fenceMemory(channelId, await this.memory.context(channelId, limit), holdsManifestGrant(authorization));
37257
38659
  } catch {
37258
38660
  return { recent: [], facts: [] };
37259
38661
  }
37260
38662
  }
38663
+ /**
38664
+ * Owner grants plan 4 Task 16 (T-30, audit r2 H-1): the voice fence is on whenever a pin is loaded, on EVERY runtime
38665
+ * turn (manifest, fallback, greeting, relayed, constants-only alike). It never depends on a turn's grants.
38666
+ */
38667
+ voiceFenceOn() {
38668
+ return this.cfg.ownerGrantPin?.pin != null;
38669
+ }
38670
+ /** The keyed and legacy fast-reply ids of every peer row of the conversation (audit r2 M-1; never the 50-row window). */
38671
+ fenceContext(channelId) {
38672
+ let ids;
38673
+ const build = () => {
38674
+ const out = /* @__PURE__ */ new Set();
38675
+ for (const p of this.store.memoryPeerMessageIds(channelId)) {
38676
+ out.add(fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, p));
38677
+ out.add(legacyFastReplyId(p));
38678
+ }
38679
+ return out;
38680
+ };
38681
+ return { get fastIds() {
38682
+ return ids ??= build();
38683
+ } };
38684
+ }
38685
+ /**
38686
+ * Drop agent lines classified fast_path, untrusted or unknown from a turn's memory (every turn of a pinned agent).
38687
+ * `strict` (review H-3, a turn that holds a manifest grant): relay-class peer lines go too (sender not the pinned
38688
+ * owner, or voice modality). Otherwise peer lines are never dropped.
38689
+ */
38690
+ fenceMemory(channelId, ctx, manifestTurn = false) {
38691
+ if (!this.voiceFenceOn()) return ctx;
38692
+ const scoped = this.taintScope(channelId);
38693
+ const strict = manifestTurn && scoped;
38694
+ if (!strict && !ctx.recent.some((t) => t.role === "agent")) return ctx;
38695
+ const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38696
+ const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38697
+ const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => strict || t.role === "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38698
+ const fence = this.fenceContext(channelId);
38699
+ const recent = ctx.recent.filter((t) => {
38700
+ const m = meta.get(t.seq);
38701
+ if (t.role !== "agent") return !(strict && isRelayClassPeer({ peerId: t.peerId, modality: m?.modality }, ownerId));
38702
+ const origin = classifyAgentLine({ ...m ?? {}, ts: t.ts }, fence);
38703
+ return origin === "reply" || origin === "untrusted" && !scoped;
38704
+ });
38705
+ return recent.length === ctx.recent.length ? ctx : { ...ctx, recent };
38706
+ }
38707
+ /**
38708
+ * Review H-3: true when a delivered frame holds a line a manifest turn would exclude (a relay-class peer line; agent
38709
+ * lines in a frame are already fenced). Such a turn's reply is recorded "untrusted".
38710
+ */
38711
+ frameHoldsRelayClass(m) {
38712
+ const recent = m.memory?.recent ?? [];
38713
+ if (recent.length === 0) return false;
38714
+ const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38715
+ const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38716
+ const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38717
+ return recent.some((t) => t.role !== "agent" && isRelayClassPeer({ peerId: t.peerId, modality: meta.get(t.seq)?.modality }, ownerId));
38718
+ }
38719
+ /**
38720
+ * Review H-3 attribution: message ids of turns delivered on a pinned agent whose frame held no relay-class line.
38721
+ * A reply is recorded "reply" only when its in-reply-to is here; anything else (tainted, unknown after a restart,
38722
+ * no in-reply-to) is "untrusted". Bounded; the oldest are forgotten first (forgetting only makes a reply untrusted).
38723
+ */
38724
+ cleanTurns = /* @__PURE__ */ new Set();
38725
+ noteDeliveredFrame(m, unifiedTainted = false) {
38726
+ if (!this.taintScope(m.channel_id)) return;
38727
+ const tainted = unifiedTainted || this.turnIsRelayClass(m) || !holdsManifestGrant(m.authorization) && this.frameHoldsRelayClass(m);
38728
+ if (tainted) {
38729
+ this.cleanTurns.delete(m.message_id);
38730
+ this.taintedTurns.add(m.message_id);
38731
+ if (this.taintedTurns.size > 5e3) this.taintedTurns.delete(this.taintedTurns.values().next().value);
38732
+ return;
38733
+ }
38734
+ if (this.taintedTurns.has(m.message_id)) return;
38735
+ this.cleanTurns.add(m.message_id);
38736
+ if (this.cleanTurns.size > 5e3) this.cleanTurns.delete(this.cleanTurns.values().next().value);
38737
+ }
38738
+ taintedTurns = /* @__PURE__ */ new Set();
38739
+ /**
38740
+ * Review R2-H2: on a pinned agent a 7a note or a unified memory_note is written only from a turn delivered with a clean
38741
+ * frame in the owner DM (cleanTurns, not taintedTurns), so a tainted fallback turn cannot plant a note that a later
38742
+ * manifest turn reads. Taint is noted at delivery, before any note frame for the turn can arrive.
38743
+ */
38744
+ noteTurnClean(messageId) {
38745
+ return !this.voiceFenceOn() || this.cleanTurns.has(messageId) && !this.taintedTurns.has(messageId);
38746
+ }
38747
+ /**
38748
+ * Review R2-H1: the turn itself is relay-class (its frame memory is prior-turn only, so its own sender is never in it):
38749
+ * a sender that is not the pinned owner, a relay speaker claim, or a voice turn.
38750
+ */
38751
+ turnIsRelayClass(m) {
38752
+ const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId;
38753
+ return m.sender_id !== ownerId || m.authorization?.relay_speaker !== void 0 || m.voice === true || m.transcribed === true || m.speaker !== void 0;
38754
+ }
38755
+ /**
38756
+ * R2-M1 ruling: the channel where the H-3 taint and the `untrusted` exclusion apply, on a pinned agent: the signed
38757
+ * owner_dm_channel_id of the manifest in force. Before any manifest is held there is no signed DM, so every channel
38758
+ * not known to be a room counts (fail closed: a reply recorded there before the first manifest must not reach it).
38759
+ */
38760
+ taintScope(channelId) {
38761
+ if (!this.voiceFenceOn()) return false;
38762
+ const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
38763
+ if (signedDm !== void 0) return signedDm === channelId;
38764
+ return (this.freshMeta(channelId)?.is_dm ?? this.store.getChannelMeta(channelId)?.isDm) !== false;
38765
+ }
37261
38766
  /**
37262
38767
  * Spec §11.2 (OQ-L), §6.5: apply one parsed `payload.memory`. An unreadable
37263
38768
  * value turns notes off and deletes nothing. A readable one goes through
@@ -37713,10 +39218,16 @@ var BridgeAgent = class _BridgeAgent {
37713
39218
  if (isDm !== true || ownerId === void 0) return { kind: "unknown" };
37714
39219
  return this.store.recordDmOwner(c, ownerId) ? { kind: "owner", ownerId } : { kind: "unknown" };
37715
39220
  };
39221
+ const fences = /* @__PURE__ */ new Map();
39222
+ const classifyHistoryOrigin = (c, r) => {
39223
+ const f = fences.get(c) ?? (fences.set(c, this.fenceContext(c)), fences.get(c));
39224
+ return classifyAgentLine({ origin: r.origin, modality: r.modality, ...r.messageId ? { messageId: r.messageId } : {}, ts: r.recordedAt }, f);
39225
+ };
37716
39226
  const out = await runMigration({
37717
39227
  store: this.store,
37718
39228
  items: this.items,
37719
39229
  classify,
39230
+ classifyHistoryOrigin,
37720
39231
  stopping: () => this.closingDown,
37721
39232
  pinsChanged: () => this.notes.exclusive(async () => {
37722
39233
  this.unifiedReads?.bumpGeneration();
@@ -37736,7 +39247,24 @@ var BridgeAgent = class _BridgeAgent {
37736
39247
  const a = m.authorization;
37737
39248
  if (!(a?.memory_write === true || a?.relay_speaker === "owner")) return void 0;
37738
39249
  const scopeId = this.ownerScopeNow(m.channel_id);
37739
- return scopeId === null ? void 0 : this.unifiedReads.deliverable(m.message_id, m.channel_id, scopeId);
39250
+ if (scopeId === null) return void 0;
39251
+ if (!this.voiceFenceOn()) return this.unifiedReads.deliverable(m.message_id, m.channel_id, scopeId);
39252
+ const contexts = /* @__PURE__ */ new Map();
39253
+ const ctxFor = (c) => contexts.get(c) ?? (contexts.set(c, this.fenceContext(c)), contexts.get(c));
39254
+ const trace = (c, at) => this.store.memoryRowForTrace(c, at);
39255
+ const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
39256
+ const scoped = this.taintScope(m.channel_id);
39257
+ const strict = scoped && holdsManifestGrant(m.authorization);
39258
+ const strictRule = { ownerId, turnChannelId: m.channel_id };
39259
+ let tainted = false;
39260
+ const out = this.unifiedReads.deliverable(m.message_id, m.channel_id, scopeId, (i) => {
39261
+ const facts = { channelId: i.channelId, source: i.source, originId: i.originId, role: i.role, origin: i.origin, sourceRef: i.sourceRef };
39262
+ const keep = classifyUnifiedItem(facts, trace, ctxFor(i.channelId), strict ? strictRule : void 0, !scoped) === "keep";
39263
+ if (keep && scoped && !strict && classifyUnifiedItem(facts, trace, ctxFor(i.channelId), strictRule) === "exclude") tainted = true;
39264
+ return keep;
39265
+ });
39266
+ if (tainted) this.noteDeliveredFrame(m, true);
39267
+ return out;
37740
39268
  }
37741
39269
  /** ws-server: the hello echoes memory_unified only with the part 1 switch compiled on. */
37742
39270
  unifiedFeatureOn() {
@@ -38038,6 +39566,7 @@ var BridgeAgent = class _BridgeAgent {
38038
39566
  if (rec === void 0) return refuse("message");
38039
39567
  const channelId = rec.channel_id;
38040
39568
  if (rec.authorization?.memory_write !== true) return refuse("audience");
39569
+ if (!this.noteTurnClean(rec.message_id)) return refuse("audience");
38041
39570
  const held = await this.heldPosition(channelId);
38042
39571
  const now = this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false });
38043
39572
  if (now.memory_write !== true) return refuse("audience");
@@ -38141,6 +39670,7 @@ var BridgeAgent = class _BridgeAgent {
38141
39670
  if (rec === void 0) return refuse("message");
38142
39671
  const channelId = rec.channel_id;
38143
39672
  if (rec.authorization?.memory_write !== true) return refuse("audience");
39673
+ if (!this.noteTurnClean(rec.message_id)) return refuse("audience");
38144
39674
  const held = await this.heldPosition(channelId);
38145
39675
  if (this.authorizationFor(channelId, rec.sender_id, void 0, held.epoch, held.groupId, { log: false }).memory_write !== true) return refuse("audience");
38146
39676
  const scopeId = this.ownerScopeNow(channelId);
@@ -38224,6 +39754,9 @@ var BridgeAgent = class _BridgeAgent {
38224
39754
  return { type: "memory_note_result", request_id: f.request_id, message_id: f.message_id, ok: true };
38225
39755
  }
38226
39756
  async memoryContext(conversationId, limit) {
39757
+ for (const [id, channelId] of this.ownerVerifyFailedIds) {
39758
+ if (channelId === conversationId && this.runtimePendingIds.has(id)) return { recent: [], facts: [] };
39759
+ }
38227
39760
  if (!await this.mayReadOpenClawMemory(conversationId)) {
38228
39761
  return { recent: [], facts: [] };
38229
39762
  }
@@ -38371,7 +39904,7 @@ var BridgeAgent = class _BridgeAgent {
38371
39904
  const fastLlm = this.fastLlm;
38372
39905
  if (!fastLlm || !this.cfg.voice) return "failed";
38373
39906
  let memory;
38374
- if (!this.mayReadOpenClawMemoryForTurn(m.authorization)) {
39907
+ if (!this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
38375
39908
  memory = { recent: [], facts: [] };
38376
39909
  } else {
38377
39910
  try {
@@ -38419,15 +39952,17 @@ var BridgeAgent = class _BridgeAgent {
38419
39952
  * frame (the "fast path failed" semantics), so a crash mid-call never loses
38420
39953
  * the turn.
38421
39954
  */
38422
- async stageHeldRuntimeMessage(message) {
38423
- if (this.runtimePendingIds.has(message.message_id)) return true;
39955
+ async stageHeldRuntimeMessage(held, opts = {}) {
39956
+ if (this.runtimePendingIds.has(held.message_id)) return true;
38424
39957
  if (this.runtimePendingIds.size >= this.maxBuffer) {
38425
39958
  writeServiceLog2("warn", "runtime_inbox.full");
38426
39959
  return false;
38427
39960
  }
39961
+ const message = opts.absorbed ? held : this.applyRoomSearchBackstop(held);
38428
39962
  try {
38429
39963
  await this.stageSealed(message);
38430
39964
  this.runtimePendingIds.add(message.message_id);
39965
+ if (message !== held) this.backstopStripped.add(message.message_id);
38431
39966
  return true;
38432
39967
  } catch (error) {
38433
39968
  writeServiceLog2("error", "runtime_inbox.stage_failed", {}, error);
@@ -38443,7 +39978,8 @@ var BridgeAgent = class _BridgeAgent {
38443
39978
  }
38444
39979
  const memory = await this.memoryAtRelease(message);
38445
39980
  if (this.closingDown || !this.runtimePendingIds.has(message.message_id)) return;
38446
- this.buffer.push(memory === message.memory ? message : { ...message, memory });
39981
+ const out = this.backstopStripped.delete(message.message_id) ? this.withoutWebSearch(message) : message;
39982
+ this.buffer.push(memory === message.memory ? out : { ...out, memory });
38447
39983
  this.wakeListeners();
38448
39984
  }
38449
39985
  /**
@@ -38461,7 +39997,7 @@ var BridgeAgent = class _BridgeAgent {
38461
39997
  */
38462
39998
  async memoryAtRelease(message) {
38463
39999
  const prior = message.memory;
38464
- if (!prior || !this.mayReadOpenClawMemoryForTurn(message.authorization)) return prior;
40000
+ if (!prior || !this.mayReadHistoryForTurn(message.message_id, message.authorization)) return prior;
38465
40001
  let fresh;
38466
40002
  try {
38467
40003
  fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
@@ -38469,7 +40005,7 @@ var BridgeAgent = class _BridgeAgent {
38469
40005
  return prior;
38470
40006
  }
38471
40007
  const after = prior.recent.reduce((max, turn) => Math.max(max, turn.ts ?? 0), message.ts);
38472
- const spoken = fresh.recent.filter((turn) => turn.role === "agent" && turn.ts !== void 0 && turn.ts > after);
40008
+ const spoken = this.fenceMemory(message.channel_id, fresh, holdsManifestGrant(message.authorization)).recent.filter((turn) => turn.role === "agent" && turn.ts !== void 0 && turn.ts > after);
38473
40009
  if (spoken.length === 0) return prior;
38474
40010
  return {
38475
40011
  recent: [...prior.recent, ...spoken].slice(-_BridgeAgent.TURN_MEMORY_LIMIT),
@@ -38752,6 +40288,11 @@ var BridgeAgent = class _BridgeAgent {
38752
40288
  });
38753
40289
  }
38754
40290
  this.channelRoster.set(channelId, { members, at: Date.now(), epoch, groupId, crossOrg: roomIsCrossOrg(rows) });
40291
+ const grantPin = this.cfg.ownerGrantPin?.pin;
40292
+ if (grantPin && !this.serverOwnerMismatchLogged && liveRows.some((r) => r.is_agent_owner === true && !r.is_agent && r.identity_id !== grantPin.ownerIdentityId)) {
40293
+ this.serverOwnerMismatchLogged = true;
40294
+ writeServiceLog2("warn", "authz.pin", { state: ServiceLogEnum.of("server_owner_mismatch", ["server_owner_mismatch"]) });
40295
+ }
38755
40296
  const self = members.get(this.cfg.identityId);
38756
40297
  if (self?.name) this.lastOwnDisplayName = self.name;
38757
40298
  await this.resolveAndPinOwner(channelId, liveRows);
@@ -38778,11 +40319,200 @@ var BridgeAgent = class _BridgeAgent {
38778
40319
  }
38779
40320
  }
38780
40321
  }
40322
+ /**
40323
+ * Owner-signed tool grants, step 1 (spec §7.2; decisions D-1, D-2, D-4, D-8,
40324
+ * D-9; audits H-1, M-2, M-4, E-1..E-3). The verdict for one delivered turn.
40325
+ *
40326
+ * `unchecked` when no pin is loaded or the sender is not the pinned owner.
40327
+ * Otherwise, in order, the first failure wins: the identity signature over the
40328
+ * raw payload bytes with the pinned key; the verified bytes must be the
40329
+ * decrypted envelope; in a DM only (D-1), the signed DM channel and the MLS
40330
+ * leaf set; then the replay set and closed epochs, keyed by the agent's OWN
40331
+ * held group (never the backend's row label, M-2). NEVER throws (M-4): any
40332
+ * exception is `failed verify_error`, and the row is acked like any other turn.
40333
+ */
40334
+ async verifyOwnerTurn(m) {
40335
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
40336
+ if (!pin || m.envelope.sender_id !== pin.ownerIdentityId) return OWNER_TURN_UNCHECKED;
40337
+ try {
40338
+ const sig = verifyOwnerSignature({ payloadHex: m.payloadHex, identitySignature: m.identitySignature }, pin);
40339
+ if (sig.state !== "ok") return this.ownerTurnFailed(m, this.isAckedOwnerOriginal(m) ? "replay" : sig.state);
40340
+ const v = sig.envelope;
40341
+ if (v.message_id !== m.envelope.message_id || v.channel_id !== m.envelope.channel_id || v.sender_id !== m.envelope.sender_id || v.mls_group_epoch !== m.envelope.mls_group_epoch || v.payload_encrypted !== m.envelope.payload_encrypted) {
40342
+ return this.ownerTurnFailed(m, "malformed");
40343
+ }
40344
+ if (v.sender_id !== pin.ownerIdentityId) return this.ownerTurnFailed(m, "malformed");
40345
+ let leafClass = "ok";
40346
+ if (this.freshMeta(m.channelId)?.is_dm === true) {
40347
+ const signedDm = this.toolGrantManifest?.owner_dm_channel_id;
40348
+ if (signedDm !== void 0 && signedDm !== m.channelId) return this.ownerTurnFailed(m, "wrong_dm_channel");
40349
+ leafClass = classifyOwnerDmLeaves((await this.provider.leafTreeSummary(m.channelId)).leaves, {
40350
+ ownerIdentityId: pin.ownerIdentityId,
40351
+ agentIdentityId: this.cfg.identityId,
40352
+ relayIdentityId: this.toolGrantManifest?.owner_dm_relay_identity_id,
40353
+ isServerRelay: (id) => this.freshRoster(m.channelId)?.get(id)?.is_relay === true
40354
+ });
40355
+ if (leafClass === "foreign") return this.ownerTurnFailed(m, "not_owner_dm_leaves");
40356
+ }
40357
+ const held = await this.heldPosition(m.channelId);
40358
+ if (held.groupId === null) return this.ownerTurnFailed(m, "verify_error");
40359
+ if (!this.runtimePendingIds.has(m.messageId) && (this.store.hasOwnerMessage(m.channelId, m.messageId) || this.store.isOwnerEpochClosed(m.channelId, held.groupId, v.mls_group_epoch))) {
40360
+ return this.ownerTurnFailed(m, "replay");
40361
+ }
40362
+ const replay = Object.freeze({ channelId: m.channelId, groupId: held.groupId, epoch: v.mls_group_epoch });
40363
+ this.ownerReplayPending.set(m.messageId, replay);
40364
+ return Object.freeze({ kind: leafClass === "unsigned_relay" ? "constants_only" : "verified", replay });
40365
+ } catch {
40366
+ return this.ownerTurnFailed(m, "verify_error");
40367
+ }
40368
+ }
40369
+ /** Log, count and (except a replay, audit E-3) record one failed owner-turn check. Never throws (audit E-2). */
40370
+ ownerTurnFailed(m, reason) {
40371
+ writeServiceLog2("warn", "authz.owner_dm_verify", {
40372
+ state: ServiceLogEnum.of(reason, [
40373
+ "bad_signature",
40374
+ "malformed",
40375
+ "unknown_algo",
40376
+ "no_signature",
40377
+ "not_owner_dm_leaves",
40378
+ "wrong_dm_channel",
40379
+ "replay",
40380
+ "verify_error"
40381
+ ])
40382
+ });
40383
+ this.ownerVerifyFailures += 1;
40384
+ if (reason !== "replay" && !this.isVerifiedOwnerOriginal(m)) {
40385
+ this.ownerVerifyFailedIds.set(m.messageId, m.channelId);
40386
+ try {
40387
+ this.store.recordOwnerVerifyFailure(m.channelId, m.messageId, Date.now());
40388
+ } catch {
40389
+ writeServiceLog2("warn", "authz.owner_verify_record_failed");
40390
+ }
40391
+ }
40392
+ return Object.freeze({ kind: "failed", reason });
40393
+ }
40394
+ /** Review M-A: a verified original of this id was staged and is still pending, or was recorded in the replay set. */
40395
+ isVerifiedOwnerOriginal(m) {
40396
+ return this.runtimePendingIds.has(m.messageId) || this.isAckedOwnerOriginal(m);
40397
+ }
40398
+ /** Review M-A: a verified original of this id was staged and acked (it is in the replay set, no longer pending). */
40399
+ isAckedOwnerOriginal(m) {
40400
+ if (this.runtimePendingIds.has(m.messageId)) return false;
40401
+ try {
40402
+ return this.store.hasOwnerMessage(m.channelId, m.messageId);
40403
+ } catch {
40404
+ return false;
40405
+ }
40406
+ }
40407
+ /** D-9: memory first, then the store (a restart before the reply). Unpinned: never. A store read that throws withholds (fail closed). */
40408
+ isOwnerVerifyFailed(messageId) {
40409
+ if (this.ownerVerifyFailedIds.has(messageId)) return true;
40410
+ if (!this.cfg.ownerGrantPin?.pin) return false;
40411
+ try {
40412
+ return this.store.isOwnerVerifyFailed(messageId);
40413
+ } catch {
40414
+ return true;
40415
+ }
40416
+ }
40417
+ /**
40418
+ * Decision D-2, audit L-2: prune the owner replay set at most once per
40419
+ * OWNER_REPLAY_PRUNE_INTERVAL_MS, keeping each channel's current (group,
40420
+ * epoch). A channel whose held position is unknown is left out (not pruned).
40421
+ */
40422
+ async maybePruneOwnerReplay() {
40423
+ if (!this.cfg.ownerGrantPin?.pin) return;
40424
+ const now = this.monoNow();
40425
+ if (now - this.lastOwnerReplayPruneAt < OWNER_REPLAY_PRUNE_INTERVAL_MS) return;
40426
+ this.lastOwnerReplayPruneAt = now;
40427
+ try {
40428
+ const currentOf = /* @__PURE__ */ new Map();
40429
+ for (const channelId of this.store.listOwnerReplayChannels()) {
40430
+ const held = await this.heldPosition(channelId);
40431
+ if (held.groupId !== null && held.epoch !== null) currentOf.set(channelId, { groupId: held.groupId, epoch: held.epoch });
40432
+ }
40433
+ this.store.pruneOwnerReplay(Date.now(), currentOf);
40434
+ } catch (e) {
40435
+ writeServiceLog2("warn", "authz.replay_prune_failed", {}, e);
40436
+ }
40437
+ }
40438
+ /** start(): drop other keys' manifest state, re-verify the stored one for the pinned key, and prune. */
40439
+ async loadToolGrantState() {
40440
+ const pin = this.cfg.ownerGrantPin?.pin;
40441
+ if (!pin) return;
40442
+ try {
40443
+ this.store.dropToolGrantManifestsExcept(pin.publicKeyB64);
40444
+ this.store.dropToolGrantAppliedExcept(pin.publicKeyB64);
40445
+ const saved = await this.store.loadToolGrantManifest(pin.publicKeyB64);
40446
+ if (saved) {
40447
+ const v = verifyToolGrantManifest(saved.wrapper, { pin, agentIdentityId: this.cfg.identityId, storedCounter: null });
40448
+ if (v.state === "ok") {
40449
+ this.toolGrantManifest = v.manifest;
40450
+ this.toolGrantCounter = v.manifest.counter;
40451
+ } else {
40452
+ this.logManifest("malformed");
40453
+ }
40454
+ }
40455
+ } catch (e) {
40456
+ writeServiceLog2("warn", "authz.manifest_load_failed", {}, e);
40457
+ }
40458
+ await this.maybePruneOwnerReplay();
40459
+ }
40460
+ /**
40461
+ * Spec §7.1: verify and record a served manifest. Never writes a file and
40462
+ * never changes a grant in step 1. A save that throws keeps the old state.
40463
+ */
40464
+ async applyToolGrantManifest(raw) {
40465
+ const pin = this.cfg.ownerGrantPin?.pin;
40466
+ if (!pin) {
40467
+ this.logManifest("no_pin");
40468
+ return;
40469
+ }
40470
+ if (raw === void 0 || raw === null) {
40471
+ this.logManifest("no_manifest");
40472
+ return;
40473
+ }
40474
+ const v = verifyToolGrantManifest(raw, { pin, agentIdentityId: this.cfg.identityId, storedCounter: this.toolGrantCounter });
40475
+ if (v.state === "ok") {
40476
+ try {
40477
+ await this.store.saveToolGrantManifest(pin.publicKeyB64, v.manifest.counter, v.wrapper);
40478
+ } catch {
40479
+ this.logManifest("kept_last_good");
40480
+ return;
40481
+ }
40482
+ this.toolGrantManifest = v.manifest;
40483
+ this.toolGrantCounter = v.manifest.counter;
40484
+ this.logManifest("ok");
40485
+ if (v.droppedUnknown) this.logManifest("unknown_entry");
40486
+ return;
40487
+ }
40488
+ this.logManifest(v.state);
40489
+ if (v.state !== "unchanged" && this.toolGrantManifest) this.logManifest("kept_last_good");
40490
+ }
40491
+ logManifest(state) {
40492
+ this.manifestState = state;
40493
+ const quiet = state === "ok" || state === "unchanged" || state === "no_pin" || state === "no_manifest";
40494
+ writeServiceLog2(quiet ? "info" : "warn", "authz.manifest", {
40495
+ state: ServiceLogEnum.of(state, [
40496
+ "ok",
40497
+ "unchanged",
40498
+ "no_pin",
40499
+ "no_manifest",
40500
+ "malformed",
40501
+ "unsupported_algo",
40502
+ "bad_signature",
40503
+ "wrong_agent",
40504
+ "wrong_owner",
40505
+ "rollback",
40506
+ "unknown_entry",
40507
+ "kept_last_good"
40508
+ ])
40509
+ });
40510
+ }
38781
40511
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
38782
- async authorizeTurn(channelId, messageId, senderId, relaySpeakerId) {
40512
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId) {
38783
40513
  const held = await this.heldPosition(channelId);
38784
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId);
38785
- if (auth.tool_grants !== ROOM_DEFAULT_READ_GRANTS) return auth;
40514
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict });
40515
+ if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
38786
40516
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
38787
40517
  writeServiceLog2("warn", "room_grant.untracked");
38788
40518
  return Object.freeze({ ...auth, tool_grants: NO_TOOL_GRANTS });
@@ -38864,9 +40594,48 @@ var BridgeAgent = class _BridgeAgent {
38864
40594
  if (this.roomDefaultGrants && this.perToolConsumer !== null && epochFresh && meta?.is_dm === false && audience === "team_room" && entry.crossOrg === false) {
38865
40595
  tool_grants = ROOM_DEFAULT_READ_GRANTS;
38866
40596
  }
40597
+ let grantSource = tool_grants.length ? "constant" : "none";
40598
+ const manifest = this.cfg.ownerGrantPin?.pin ? this.toolGrantManifest : null;
40599
+ const binding = this.manifestBinding;
40600
+ const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
40601
+ const signedTargets = boundNow ? binding.signedTargets : { endpoints: 0, databases: 0 };
40602
+ if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS) {
40603
+ const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null;
40604
+ const r = effectiveToolGrants({
40605
+ audience: "owner_dm",
40606
+ base: OWNER_DM_DEFAULT_READ_GRANTS,
40607
+ manifest,
40608
+ eligible,
40609
+ manifestSwitchOn: this.ownerDmManifestGrants,
40610
+ disabled: this.payloadToolDeny,
40611
+ signedTargets,
40612
+ roomSearchCapped: false
40613
+ });
40614
+ tool_grants = r.grants;
40615
+ grantSource = r.source;
40616
+ } else if (tool_grants === ROOM_DEFAULT_READ_GRANTS) {
40617
+ const r = effectiveToolGrants({
40618
+ audience: "team_room",
40619
+ base: ROOM_DEFAULT_READ_GRANTS,
40620
+ manifest,
40621
+ eligible: boundNow && this.manifestConsumer !== null,
40622
+ manifestSwitchOn: this.roomManifestGrants,
40623
+ disabled: this.payloadToolDeny,
40624
+ signedTargets,
40625
+ roomSearchCapped: manifest !== null && this.roomSearchCapped(channelId)
40626
+ });
40627
+ tool_grants = r.grants;
40628
+ grantSource = r.source;
40629
+ }
40630
+ if (opts.ownerVerdict?.kind === "failed") {
40631
+ tool_grants = NO_TOOL_GRANTS;
40632
+ memoryWrite = false;
40633
+ relaySpeaker = void 0;
40634
+ grantSource = "none";
40635
+ }
38867
40636
  const roomClass = meta?.is_dm !== false ? "na" : entry?.crossOrg === false ? "internal" : entry?.crossOrg === true ? "cross_org" : "unknown";
38868
40637
  if (opts.log !== false) {
38869
- this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh, memoryWrite);
40638
+ this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length, roomClass, epochFresh, memoryWrite, grantSource);
38870
40639
  }
38871
40640
  return Object.freeze({
38872
40641
  version: 1,
@@ -38902,8 +40671,10 @@ var BridgeAgent = class _BridgeAgent {
38902
40671
  * enforcement is attached
38903
40672
  * memory_write the turn carries memory_write (Markdown memory
38904
40673
  * 7a, spec §8.3): a boolean, never content
40674
+ * grant_source where the grants came from: constant | none in
40675
+ * step 1 (owner-signed tool grants §7.6)
38905
40676
  */
38906
- logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh, memoryWrite) {
40677
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants, roomClass, epochFresh, memoryWrite, grantSource) {
38907
40678
  const meta = this.freshMeta(channelId);
38908
40679
  const roster = this.freshRoster(channelId);
38909
40680
  const sender = roster?.get(senderId);
@@ -38936,7 +40707,8 @@ var BridgeAgent = class _BridgeAgent {
38936
40707
  epoch_fresh: epochFresh,
38937
40708
  room_kill_switch: ServiceLogEnum.of(this.roomDefaultGrants ? "on" : "off", ["on", "off"]),
38938
40709
  per_tool_consumer: this.perToolConsumer !== null,
38939
- memory_write: memoryWrite
40710
+ memory_write: memoryWrite,
40711
+ grant_source: ServiceLogEnum.of(grantSource, ["constant", "manifest", "fallback", "none"])
38940
40712
  });
38941
40713
  }
38942
40714
  /**
@@ -39307,7 +41079,8 @@ var BridgeAgent = class _BridgeAgent {
39307
41079
  }
39308
41080
  }
39309
41081
  startReceivePump() {
39310
- const sub = this.transport.subscribeDeliveryPings("");
41082
+ const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
41083
+ this.earlyPings = null;
39311
41084
  this.pingHandle = { close: sub.close };
39312
41085
  void (async () => {
39313
41086
  try {
@@ -39529,6 +41302,7 @@ var BridgeAgent = class _BridgeAgent {
39529
41302
  this.joinState.set(cid, "JOINED");
39530
41303
  }
39531
41304
  if (!entries.length) return;
41305
+ await this.maybePruneOwnerReplay();
39532
41306
  const receivedAt = Date.now();
39533
41307
  const purgeEpochAtReceipt = /* @__PURE__ */ new Map();
39534
41308
  const itemCaptureAtReceipt = /* @__PURE__ */ new Map();
@@ -39621,7 +41395,7 @@ var BridgeAgent = class _BridgeAgent {
39621
41395
  acked.push(m.queueId);
39622
41396
  continue;
39623
41397
  }
39624
- const refAuth = await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id);
41398
+ const refAuth = await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id, await this.verifyOwnerTurn(m));
39625
41399
  await this.prepareNotesForTurn(m.channelId, refAuth);
39626
41400
  const vaultRefMessage = {
39627
41401
  channel_id: m.channelId,
@@ -39681,7 +41455,7 @@ var BridgeAgent = class _BridgeAgent {
39681
41455
  { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
39682
41456
  this.receiveDeps()
39683
41457
  );
39684
- const shareAuth = await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id);
41458
+ const shareAuth = await this.authorizeTurn(m.channelId, m.messageId, m.envelope.sender_id, await this.verifyOwnerTurn(m));
39685
41459
  await this.prepareNotesForTurn(m.channelId, shareAuth);
39686
41460
  const runtimeMessage = {
39687
41461
  channel_id: m.channelId,
@@ -39749,14 +41523,16 @@ var BridgeAgent = class _BridgeAgent {
39749
41523
  const senderName = this.freshRoster(m.channelId)?.get(
39750
41524
  m.envelope.sender_id
39751
41525
  )?.name;
41526
+ const ownerVerdict = await this.verifyOwnerTurn(m);
39752
41527
  const turnAuthorization = await this.authorizeTurn(
39753
41528
  m.channelId,
39754
41529
  m.messageId,
39755
41530
  m.envelope.sender_id,
41531
+ ownerVerdict,
39756
41532
  speaker?.identityId
39757
41533
  );
39758
41534
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
39759
- const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
41535
+ const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization);
39760
41536
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
39761
41537
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
39762
41538
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
@@ -39787,7 +41563,9 @@ var BridgeAgent = class _BridgeAgent {
39787
41563
  sender_id: m.envelope.sender_id,
39788
41564
  text: turnText,
39789
41565
  ts: m.receivedAt,
39790
- authorization: turnAuthorization,
41566
+ // Owner grants plan 4 Task 16 (M-6): under a manifest a call-start greeting entry carries no grants and no
41567
+ // memory_write, on every branch (skip, held, heldAfterAck spreads held). Unpinned agents unchanged (G-4).
41568
+ authorization: fastGreeting && this.toolGrantManifest !== null ? greetingAuthorization(turnAuthorization) : turnAuthorization,
39791
41569
  memory,
39792
41570
  // A relay-attributed turn is the SPEAKER's message to the runtime:
39793
41571
  // name it after them and never flag it as agent-authored.
@@ -39811,7 +41589,7 @@ var BridgeAgent = class _BridgeAgent {
39811
41589
  open2.ids.push(m.messageId);
39812
41590
  open2.parts.push(turnText);
39813
41591
  const staging = (async () => {
39814
- const ok = await this.stageHeldRuntimeMessage(runtimeEntry(false, await runtimeMemory));
41592
+ const ok = await this.stageHeldRuntimeMessage(runtimeEntry(false, await runtimeMemory), { absorbed: true });
39815
41593
  if (!ok) {
39816
41594
  const i = open2.ids.indexOf(m.messageId);
39817
41595
  if (i > 0) {
@@ -39950,11 +41728,11 @@ var BridgeAgent = class _BridgeAgent {
39950
41728
  void work.finally(() => this.unifiedWork.delete(work));
39951
41729
  }
39952
41730
  }
39953
- } else if (this.recallProviderOn()) {
41731
+ } else if (this.recallProviderOn() && ownerVerdict.kind !== "failed") {
39954
41732
  const recallTap = this.captureRecallTap(m.messageId, m.channelId);
39955
41733
  this.spawnRecallTap({ message: runtimeEntry(false, { recent: [], facts: [] }), captured: recallTap });
39956
41734
  }
39957
- void recordInboundMemory(
41735
+ if (ownerVerdict.kind !== "failed") void recordInboundMemory(
39958
41736
  this.memory,
39959
41737
  m.channelId,
39960
41738
  m.envelope.sender_id,
@@ -39977,6 +41755,7 @@ var BridgeAgent = class _BridgeAgent {
39977
41755
  } catch {
39978
41756
  }
39979
41757
  }
41758
+ this.ownerReplayPending.clear();
39980
41759
  this.latency.record("recv.cycle", performance.now() - cycleStart);
39981
41760
  if (this.buffer.length) {
39982
41761
  for (const l of this.listeners) l();
@@ -40015,7 +41794,46 @@ var BridgeAgent = class _BridgeAgent {
40015
41794
  return () => this.listeners.delete(listener);
40016
41795
  }
40017
41796
  /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
41797
+ /** Review H-1: every buffer exit narrows manifest grants again to the state at delivery. */
40018
41798
  takeBuffered(channelId) {
41799
+ return this.takeBufferedRaw(channelId).map((m) => this.delivering(m));
41800
+ }
41801
+ takeDeliverable(busyChannels) {
41802
+ return this.takeDeliverableRaw(busyChannels).map((m) => this.delivering(m));
41803
+ }
41804
+ /** Every buffer exit: re-narrow manifest grants (review H-1), then note the frame's attribution class (review H-3). */
41805
+ delivering(m) {
41806
+ const out = this.renarrowManifestGrants(m);
41807
+ this.noteDeliveredFrame(out);
41808
+ return out;
41809
+ }
41810
+ /**
41811
+ * Review H-1 (kill switches reach staged and restored turns): a grant marked source "manifest" survives delivery
41812
+ * only while its audience's manifest switch is on, the binding is bound at the applied counter, and the current
41813
+ * manifest still signs the tool. Otherwise it becomes the fallback: kept unmarked if it is a constant of the
41814
+ * audience that is still signed, else dropped. Only narrows; an unchanged message is returned by reference.
41815
+ */
41816
+ renarrowManifestGrants(m) {
41817
+ const a = m.authorization;
41818
+ if (!a || !a.tool_grants.some((g) => g.mode === "read" && g.source === "manifest")) return m;
41819
+ const audience = a.audience === "owner_dm" || a.audience === "team_room" ? a.audience : null;
41820
+ const manifest = this.cfg.ownerGrantPin?.pin ? this.toolGrantManifest : null;
41821
+ const b = this.manifestBinding;
41822
+ const bound = b !== null && b.state === "bound" && b.counter === this.toolGrantCounter;
41823
+ const switchOn = audience === "owner_dm" ? this.ownerDmManifestGrants : audience === "team_room" ? this.roomManifestGrants : false;
41824
+ const signedNow = new Set(audience && manifest ? manifest.grants[audience] ?? [] : []);
41825
+ const constants4 = new Set(audience === "owner_dm" ? OWNER_DM_DEFAULT_READ_GRANTS.map((g) => g.tool) : audience === "team_room" ? ROOM_DEFAULT_READ_GRANTS.map((g) => g.tool) : []);
41826
+ let changed = false;
41827
+ const out = a.tool_grants.flatMap((g) => {
41828
+ if (!(g.mode === "read" && g.source === "manifest")) return [g];
41829
+ if (switchOn && bound && signedNow.has(g.tool)) return [g];
41830
+ changed = true;
41831
+ return constants4.has(g.tool) && signedNow.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
41832
+ });
41833
+ if (!changed) return m;
41834
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(out) }) };
41835
+ }
41836
+ takeBufferedRaw(channelId) {
40019
41837
  if (channelId === void 0 && this.pausedChannels.size === 0 && this.supersededChannels.size === 0) {
40020
41838
  return this.buffer.splice(0);
40021
41839
  }
@@ -40038,7 +41856,7 @@ var BridgeAgent = class _BridgeAgent {
40038
41856
  * working on — which it used to refuse with a "busy" reply and ack — and a
40039
41857
  * backlog replays one message per channel at a time after a restart.
40040
41858
  */
40041
- takeDeliverable(busyChannels) {
41859
+ takeDeliverableRaw(busyChannels) {
40042
41860
  const picked = /* @__PURE__ */ new Set();
40043
41861
  const out = [];
40044
41862
  const keep = [];
@@ -40198,6 +42016,107 @@ var BridgeAgent = class _BridgeAgent {
40198
42016
  }
40199
42017
  this.noticeDeadLetter(message.channel_id);
40200
42018
  }
42019
+ /** Spec §7.5: the room is capped today: cap 0, reached (by the runtime or the backstop), or at the cap. Fails closed. */
42020
+ roomSearchCapped(channelId) {
42021
+ if (this.roomSearchDailyCap === 0 || this.agentRoomSearchDailyCap === 0) return true;
42022
+ try {
42023
+ const day = utcDayOf(Date.now());
42024
+ const st = this.store.roomSearchCapState(channelId, day);
42025
+ if (this.store.roomSearchCapState(AGENT_TOTAL_KEY, day).grantedTurns >= this.agentRoomSearchDailyCap) return true;
42026
+ return st.reachedBy !== null || st.grantedTurns >= this.roomSearchDailyCap;
42027
+ } catch {
42028
+ return true;
42029
+ }
42030
+ }
42031
+ /** Spec §7.5 (T-14c, M-4): the runtime reports an executed-call cap for a room. Never throws. */
42032
+ noteRoomCapReached(channelId) {
42033
+ if (typeof channelId !== "string" || this.closingDown) return;
42034
+ if (this.roomSearchDailyCap === 0) return;
42035
+ if (this.freshMeta(channelId)?.is_dm !== false || this.channelRoster.get(channelId)?.crossOrg !== false) return;
42036
+ const day = utcDayOf(Date.now());
42037
+ try {
42038
+ if (this.store.markRoomSearchCapReached(channelId, day, "runtime")) this.logRoomCap(day, channelId, "runtime");
42039
+ if (this.store.roomSearchCapState(channelId, day).noticePosted) return;
42040
+ } catch (e) {
42041
+ writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("store_failed", ["store_failed"]) }, e);
42042
+ return;
42043
+ }
42044
+ this.noticeRoomSearchCap(channelId, day);
42045
+ }
42046
+ /** K-14, once per room per UTC day; the flag is persisted only after the send resolves. Called only from noteRoomCapReached. */
42047
+ noticeRoomSearchCap(channelId, day) {
42048
+ const key = `${day}
42049
+ ${channelId}`;
42050
+ if (this.roomCapNoticePending.has(key)) return;
42051
+ this.roomCapNoticePending.add(key);
42052
+ let pending;
42053
+ try {
42054
+ pending = this.send(channelId, ROOM_SEARCH_CAP_NOTICE, void 0, { agentTurn: false });
42055
+ } catch (e) {
42056
+ pending = Promise.reject(e);
42057
+ }
42058
+ void pending.then(
42059
+ () => {
42060
+ try {
42061
+ this.store.markRoomSearchCapNoticePosted(channelId, day);
42062
+ } catch (e) {
42063
+ writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("store_failed", ["store_failed"]) }, e);
42064
+ }
42065
+ writeServiceLog2("info", "authz.room_cap", { state: ServiceLogEnum.of("notice_sent", ["notice_sent"]) });
42066
+ },
42067
+ (e) => writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("notice_failed", ["notice_failed"]) }, e)
42068
+ ).finally(() => this.roomCapNoticePending.delete(key));
42069
+ }
42070
+ logRoomCap(day, channelId, by) {
42071
+ const key = `${day}
42072
+ ${channelId}`;
42073
+ if (this.roomCapLogged.has(key)) return;
42074
+ if (this.roomCapLogged.size > 5e3) this.roomCapLogged.clear();
42075
+ this.roomCapLogged.add(key);
42076
+ writeServiceLog2("info", "authz.room_cap", {
42077
+ state: ServiceLogEnum.of("reached", ["reached"]),
42078
+ by: ServiceLogEnum.of(by, ["runtime", "bridge", "operator"])
42079
+ });
42080
+ }
42081
+ /**
42082
+ * Spec §7.5 (T-14b, audit r1 H-3): the silent bridge backstop. Counts a team_room turn that actually goes out
42083
+ * to the runtime with web_search, once per message id (callers run it after their runtimePendingIds early
42084
+ * return). Past the cap, and on a store failure (fail closed), the entry loses web_search. Never posts K-14.
42085
+ */
42086
+ applyRoomSearchBackstop(entry) {
42087
+ const a = entry.authorization;
42088
+ if (!a || a.audience !== "team_room" || !a.tool_grants.some((g) => g.tool === "web_search")) return entry;
42089
+ const day = utcDayOf(Date.now());
42090
+ if (this.roomSearchDailyCap === 0) {
42091
+ this.logRoomCap(day, entry.channel_id, "operator");
42092
+ return this.withoutWebSearch(entry);
42093
+ }
42094
+ if (this.agentRoomSearchDailyCap === 0) {
42095
+ this.logRoomCap(day, AGENT_TOTAL_KEY, "operator");
42096
+ return this.withoutWebSearch(entry);
42097
+ }
42098
+ try {
42099
+ const n = this.store.countRoomSearchGrant(entry.channel_id, day);
42100
+ if (n === this.roomSearchDailyCap && this.store.markRoomSearchCapReached(entry.channel_id, day, "bridge")) {
42101
+ this.logRoomCap(day, entry.channel_id, "bridge");
42102
+ }
42103
+ if (n > this.roomSearchDailyCap) return this.withoutWebSearch(entry);
42104
+ const total = this.store.countRoomSearchGrant(AGENT_TOTAL_KEY, day);
42105
+ if (total === this.agentRoomSearchDailyCap) this.logRoomCap(day, AGENT_TOTAL_KEY, "bridge");
42106
+ if (total > this.agentRoomSearchDailyCap) return this.withoutWebSearch(entry);
42107
+ return entry;
42108
+ } catch (e) {
42109
+ writeServiceLog2("warn", "authz.room_cap", { state: ServiceLogEnum.of("count_failed", ["count_failed"]) }, e);
42110
+ return this.withoutWebSearch(entry);
42111
+ }
42112
+ }
42113
+ withoutWebSearch(entry) {
42114
+ const a = entry.authorization;
42115
+ return {
42116
+ ...entry,
42117
+ authorization: Object.freeze({ ...a, tool_grants: Object.freeze(a.tool_grants.filter((g) => g.tool !== "web_search")) })
42118
+ };
42119
+ }
40201
42120
  noticeDeadLetter(channelId) {
40202
42121
  if (this.closingDown) return;
40203
42122
  const now = Date.now();
@@ -40419,13 +42338,18 @@ var BridgeAgent = class _BridgeAgent {
40419
42338
  modeEpoch: this.store.recallState().modeEpoch
40420
42339
  };
40421
42340
  let persisted = { stamp, recallPurgeEpoch: recall.purgeEpoch, recallModeEpoch: recall.modeEpoch };
40422
- await this.store.stageRuntimeInbox(message.message_id, this.stagedPayload(message, stamp, recall), message.ts, (prior, next) => {
40423
- const a = splitIngestStamp(prior);
40424
- const b = splitIngestStamp(next);
40425
- if (a === null || b === null || a.message !== b.message) return false;
40426
- persisted = a.marks;
40427
- return true;
40428
- });
42341
+ const replay = this.ownerReplayPending.get(message.message_id);
42342
+ try {
42343
+ await this.store.stageRuntimeInbox(message.message_id, this.stagedPayload(message, stamp, recall), message.ts, (prior, next) => {
42344
+ const a = splitIngestStamp(prior);
42345
+ const b = splitIngestStamp(next);
42346
+ if (a === null || b === null || a.message !== b.message) return false;
42347
+ persisted = a.marks;
42348
+ return true;
42349
+ }, replay ? { ...replay, processedAt: Date.now() } : void 0);
42350
+ } finally {
42351
+ this.ownerReplayPending.delete(message.message_id);
42352
+ }
40429
42353
  this.applyStagedMarks(message.message_id, persisted);
40430
42354
  }
40431
42355
  /** In-memory mirror of the persisted marks (staging and restore). */
@@ -40445,6 +42369,9 @@ var BridgeAgent = class _BridgeAgent {
40445
42369
  const message = rest;
40446
42370
  this.applyStagedMarks(message.message_id, marks);
40447
42371
  this.runtimePendingIds.add(message.message_id);
42372
+ if (this.cfg.ownerGrantPin?.pin && this.isOwnerVerifyFailed(message.message_id)) {
42373
+ this.ownerVerifyFailedIds.set(message.message_id, message.channel_id);
42374
+ }
40448
42375
  if (this.unifiedOn) this.deferredUnifiedIds.add(message.message_id);
40449
42376
  else this.deferredRecallIds.add(message.message_id);
40450
42377
  if (row.deliveryAttempts > 0) {
@@ -40461,12 +42388,13 @@ var BridgeAgent = class _BridgeAgent {
40461
42388
  * Returning false is backpressure: the caller leaves the backend row unacked
40462
42389
  * so it remains the durable queue rather than shedding accepted work.
40463
42390
  */
40464
- async stageRuntimeMessage(message) {
40465
- if (this.runtimePendingIds.has(message.message_id)) return true;
42391
+ async stageRuntimeMessage(staging, opts = {}) {
42392
+ if (this.runtimePendingIds.has(staging.message_id)) return true;
40466
42393
  if (this.runtimePendingIds.size >= this.maxBuffer) {
40467
42394
  writeServiceLog2("warn", "runtime_inbox.full");
40468
42395
  return false;
40469
42396
  }
42397
+ const message = opts.absorbed ? staging : this.applyRoomSearchBackstop(staging);
40470
42398
  try {
40471
42399
  await this.stageSealed(message);
40472
42400
  this.runtimePendingIds.add(message.message_id);
@@ -40572,6 +42500,37 @@ var BridgeAgent = class _BridgeAgent {
40572
42500
  perToolConsumerAttached() {
40573
42501
  return this.perToolConsumer !== null;
40574
42502
  }
42503
+ /**
42504
+ * Owner grants (spec §7.9, plan 4 Task 5): the attached per-tool runtime socket whose hello
42505
+ * declared manifest_grants_v1, or null. A manifest grant is issued only while one is attached
42506
+ * (Task 13); every other socket receives manifest grants narrowed to the constants.
42507
+ * Newest wins, like perToolConsumer.
42508
+ */
42509
+ manifestConsumer = null;
42510
+ /** OQ-5: the latest hello's class; "missing" until any hello. Reported in tool_grant_status (Task 15). */
42511
+ lastConsumerCapability = "missing";
42512
+ noteManifestConsumer(token) {
42513
+ this.manifestConsumer = token;
42514
+ }
42515
+ clearManifestConsumer(token) {
42516
+ if (this.manifestConsumer === token) this.manifestConsumer = null;
42517
+ }
42518
+ manifestConsumerAttached() {
42519
+ return this.manifestConsumer !== null;
42520
+ }
42521
+ noteConsumerCapability(state) {
42522
+ this.lastConsumerCapability = state;
42523
+ if (state !== "ok") {
42524
+ writeServiceLog2("info", "authz.capability", { state: ServiceLogEnum.of(state, ["missing", "harness_consumer"]) });
42525
+ }
42526
+ }
42527
+ /**
42528
+ * Harness re-rule (2026-10-12, audit r2 H-3): true exactly when this agent has a pin loaded and a good
42529
+ * manifest. A harness consumer then receives [] in every audience (consumer-grants.ts withHarnessGrants).
42530
+ */
42531
+ harnessGrantsWithheld() {
42532
+ return this.cfg.ownerGrantPin?.pin != null && this.toolGrantManifest !== null;
42533
+ }
40575
42534
  health() {
40576
42535
  let joined = 0;
40577
42536
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -40706,7 +42665,42 @@ var BridgeAgent = class _BridgeAgent {
40706
42665
  build: BRIDGE_VERSION
40707
42666
  };
40708
42667
  }
40709
- async close() {
42668
+ /** C9: `session_superseded` once a 4409 said another copy took this
42669
+ * agent's session (the gated fetch already refuses every request). The
42670
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
42671
+ get sessionState() {
42672
+ return this.sessionSuperseded ? "session_superseded" : "live";
42673
+ }
42674
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
42675
+ * construction, before start(), so a 4409 during start() is heard too. */
42676
+ on(event, listener) {
42677
+ this.sessionSupersededListeners.add(listener);
42678
+ return () => this.sessionSupersededListeners.delete(listener);
42679
+ }
42680
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
42681
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
42682
+ * request leaves the process after this, whatever a listener does. */
42683
+ onSessionSuperseded() {
42684
+ this.sessionSuperseded = true;
42685
+ for (const listener of [...this.sessionSupersededListeners]) {
42686
+ try {
42687
+ listener();
42688
+ } catch (e) {
42689
+ writeServiceLog2("error", "transport.session_superseded_listener_failed", {}, e);
42690
+ }
42691
+ }
42692
+ }
42693
+ /**
42694
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
42695
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
42696
+ * store.close() twice. A rejected close stays rejected: a retry would close
42697
+ * the store a second time.
42698
+ */
42699
+ close() {
42700
+ this.closing ??= this.closeOnce();
42701
+ return this.closing;
42702
+ }
42703
+ async closeOnce() {
40710
42704
  this.closingDown = true;
40711
42705
  this.statusGate.close();
40712
42706
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
@@ -40719,6 +42713,28 @@ var BridgeAgent = class _BridgeAgent {
40719
42713
  if (this.safetyTimer) clearInterval(this.safetyTimer);
40720
42714
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
40721
42715
  if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
42716
+ const ingest = this.ingestInFlight;
42717
+ if (ingest) {
42718
+ let timer;
42719
+ await Promise.race([
42720
+ ingest.released,
42721
+ new Promise((resolve) => {
42722
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42723
+ })
42724
+ ]);
42725
+ if (timer) clearTimeout(timer);
42726
+ }
42727
+ const starting = this.startInFlight;
42728
+ if (starting) {
42729
+ let timer;
42730
+ await Promise.race([
42731
+ starting,
42732
+ new Promise((resolve) => {
42733
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42734
+ })
42735
+ ]);
42736
+ if (timer) clearTimeout(timer);
42737
+ }
40722
42738
  if (this.membershipSweepWork) {
40723
42739
  let timer;
40724
42740
  await Promise.race([
@@ -40802,11 +42818,11 @@ var BridgeAgent = class _BridgeAgent {
40802
42818
  };
40803
42819
 
40804
42820
  // src/config.ts
40805
- import { readFileSync as readFileSync9, statSync as statSync5 } from "node:fs";
40806
- import { dirname as dirname5, join as join17 } from "node:path";
42821
+ import { readFileSync as readFileSync10, statSync as statSync5 } from "node:fs";
42822
+ import { dirname as dirname6, join as join18 } from "node:path";
40807
42823
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
40808
42824
  function loadConfig(opts) {
40809
- const raw = readFileSync9(opts.credentialPath, "utf8");
42825
+ const raw = readFileSync10(opts.credentialPath, "utf8");
40810
42826
  try {
40811
42827
  const mode = statSync5(opts.credentialPath).mode & 511;
40812
42828
  if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
@@ -40819,7 +42835,11 @@ function loadConfig(opts) {
40819
42835
  throw new Error("credential file is not valid JSON");
40820
42836
  }
40821
42837
  for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
40822
- const storePath = opts.storePath ?? join17(dirname5(opts.credentialPath), "bridge-state.db");
42838
+ const storePath = opts.storePath ?? join18(dirname6(opts.credentialPath), "bridge-state.db");
42839
+ const ownerGrantPin = loadOwnerGrantPin({ credentialPath: opts.credentialPath, credentialJson: json2 });
42840
+ writeServiceLog2(ownerGrantPin.state === "ok" || ownerGrantPin.state === "no_pin" ? "info" : "warn", "authz.pin", {
42841
+ state: ServiceLogEnum.of(ownerGrantPin.state, ["ok", "no_pin", "pin_conflict", "malformed", "unsupported_algo", "symlink_refused", "unreadable"])
42842
+ });
40823
42843
  const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
40824
42844
  const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
40825
42845
  const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
@@ -40877,7 +42897,8 @@ function loadConfig(opts) {
40877
42897
  voice,
40878
42898
  voiceFastPathMode: parseFastPathMode(json2.voice_fast_path_mode),
40879
42899
  modelSource,
40880
- memoryEnabled
42900
+ memoryEnabled,
42901
+ ownerGrantPin
40881
42902
  };
40882
42903
  }
40883
42904
  function arg(argv, flag) {
@@ -40921,11 +42942,11 @@ import { z as z3 } from "zod";
40921
42942
 
40922
42943
  // src/auth.ts
40923
42944
  import { randomBytes as randomBytes11, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
40924
- import { existsSync as existsSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync2 } from "node:fs";
42945
+ import { existsSync as existsSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync2 } from "node:fs";
40925
42946
  function resolveToken(opts) {
40926
42947
  if (opts.token) return opts.token;
40927
42948
  const f = opts.tokenFile;
40928
- if (f && existsSync5(f)) return readFileSync10(f, "utf8").trim();
42949
+ if (f && existsSync6(f)) return readFileSync11(f, "utf8").trim();
40929
42950
  const tok = randomBytes11(32).toString("base64url");
40930
42951
  if (f) writeFileSync2(f, tok, { mode: 384 });
40931
42952
  return tok;
@@ -40990,6 +43011,9 @@ var BRIDGE_ERRORS = {
40990
43011
  // longer holds the shared bytes or key. Terminal for that link: never fall
40991
43012
  // back to the current version. `detail.pin` names the refused pin.
40992
43013
  vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
43014
+ // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
43015
+ // session (WS 4409), so this copy stopped. Every local route but /healthz.
43016
+ session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
40993
43017
  internal: { http: 500, message: "internal error" }
40994
43018
  };
40995
43019
  function errorBody(code, detail) {
@@ -41017,6 +43041,29 @@ function withConsumerGrants(m, perToolConsumer) {
41017
43041
  const { memory_write: _dropped, ...rest } = a;
41018
43042
  return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
41019
43043
  }
43044
+ var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
43045
+ var CONSTANT_TOOL_NAMES = Object.freeze({
43046
+ owner_dm: Object.freeze(["current_time", "web_search"]),
43047
+ team_room: Object.freeze(["current_time"])
43048
+ });
43049
+ var isManifestGrant = (g) => g.mode === "read" && g.source === "manifest";
43050
+ function withManifestCapability(m, manifestConsumer) {
43051
+ if (manifestConsumer) return m;
43052
+ const a = m.authorization;
43053
+ if (!a || !a.tool_grants.some(isManifestGrant)) return m;
43054
+ const constants4 = new Set(a.audience === "owner_dm" || a.audience === "team_room" ? CONSTANT_TOOL_NAMES[a.audience] : []);
43055
+ const narrowed = a.tool_grants.flatMap((g) => {
43056
+ if (!isManifestGrant(g)) return [g];
43057
+ return constants4.has(g.tool) ? [Object.freeze({ tool: g.tool, mode: "read" })] : [];
43058
+ });
43059
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: Object.freeze(narrowed) }) };
43060
+ }
43061
+ function withHarnessGrants(m, withheld) {
43062
+ if (!withheld) return m;
43063
+ const a = m.authorization;
43064
+ if (!a || a.tool_grants.length === 0) return m;
43065
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
43066
+ }
41020
43067
 
41021
43068
  // src/http-server.ts
41022
43069
  function writeSse(res, msgs, onWriteError) {
@@ -41038,7 +43085,8 @@ data: ${JSON.stringify(msgs[i])}
41038
43085
  return [];
41039
43086
  }
41040
43087
  function deliverSseBatch(res, batch, agent, onWriteError) {
41041
- const left3 = writeSse(res, batch.map((m) => withConsumerGrants(m, false)), onWriteError);
43088
+ const withheld = agent.harnessGrantsWithheld();
43089
+ const left3 = writeSse(res, batch.map((m) => withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false)), onWriteError);
41042
43090
  const leftIds = new Set(left3.map((message) => message.message_id));
41043
43091
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
41044
43092
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -41196,6 +43244,10 @@ function createHttpServer(agent, opts) {
41196
43244
  const url = new URL(req.url ?? "/", "http://localhost");
41197
43245
  const path = url.pathname;
41198
43246
  const method = req.method ?? "GET";
43247
+ if (agent.sessionState === "session_superseded") {
43248
+ if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
43249
+ return jsonError(res, "session_superseded");
43250
+ }
41199
43251
  if (method === "GET" && path === "/healthz") {
41200
43252
  return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
41201
43253
  }
@@ -41305,7 +43357,7 @@ function createHttpServer(agent, opts) {
41305
43357
  if (method === "GET" && path === "/v1/messages") {
41306
43358
  const channel = url.searchParams.get("channel_id") ?? void 0;
41307
43359
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
41308
- return json(res, 200, (await agent.drain(channel, wait)).map((m) => withConsumerGrants(m, false)));
43360
+ return json(res, 200, (await agent.drain(channel, wait)).map((m) => withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), agent.harnessGrantsWithheld()), false)));
41309
43361
  }
41310
43362
  if (method === "GET" && path === "/v1/events") {
41311
43363
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -41407,6 +43459,7 @@ var PING_INTERVAL_MS = 3e4;
41407
43459
  var INFLIGHT_STALE_SWEEP_MS = 6e4;
41408
43460
  var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
41409
43461
  var HELLO_WAIT_MS = 2e3;
43462
+ var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
41410
43463
  var MEMORY_NOTES_FEATURE = "memory_notes";
41411
43464
  var MEMORY_RECALL_FEATURE = "memory_recall";
41412
43465
  var MEMORY_UNIFIED_FEATURE = "memory_unified";
@@ -41427,7 +43480,12 @@ function authedUpgrade(req, opts) {
41427
43480
  return true;
41428
43481
  }
41429
43482
  function attachAgentWebSocket(server, agent, opts) {
41430
- const wss = new WebSocketServer({ server, path: "/v1/ws" });
43483
+ let sessionSuperseded = false;
43484
+ const wss = new WebSocketServer({
43485
+ server,
43486
+ path: "/v1/ws",
43487
+ verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
43488
+ });
41431
43489
  const keepalive = setInterval(() => {
41432
43490
  for (const client of wss.clients) {
41433
43491
  const sock = client;
@@ -41472,6 +43530,7 @@ function attachAgentWebSocket(server, agent, opts) {
41472
43530
  const session = {
41473
43531
  consumer: "unknown",
41474
43532
  perToolGrants: false,
43533
+ manifestGrants: false,
41475
43534
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
41476
43535
  deliveryOpen: helloWaitMs <= 0,
41477
43536
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -41524,7 +43583,15 @@ function attachAgentWebSocket(server, agent, opts) {
41524
43583
  retired = true;
41525
43584
  continue;
41526
43585
  }
41527
- const out = withConsumerNotes(withConsumerGrants(batch[i], session.perToolGrants), batch[i], session, agent);
43586
+ const out = withConsumerNotes(
43587
+ withManifestCapability(
43588
+ withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
43589
+ session.manifestGrants
43590
+ ),
43591
+ batch[i],
43592
+ session,
43593
+ agent
43594
+ );
41528
43595
  const recallOut = withConsumerRecall(out.message, batch[i], session, agent);
41529
43596
  const unifiedOut = withConsumerUnified(recallOut.message, batch[i], session, agent);
41530
43597
  const frame = { type: "message", ...unifiedOut.message };
@@ -41558,6 +43625,7 @@ function attachAgentWebSocket(server, agent, opts) {
41558
43625
  if (helloTimer) clearTimeout(helloTimer);
41559
43626
  agent.noteSessionClose();
41560
43627
  unsubscribe();
43628
+ if (sessionSuperseded) inflight.clear();
41561
43629
  if (inflight.size) {
41562
43630
  agent.requeueFailedRuntimeDeliveries(
41563
43631
  [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
@@ -41573,12 +43641,16 @@ function attachAgentWebSocket(server, agent, opts) {
41573
43641
  if (current?.sock === sock) current = null;
41574
43642
  if (session.harness) agent.clearHarnessReport(session.harness);
41575
43643
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
43644
+ if (session.manifestGrants) agent.clearManifestConsumer(session);
41576
43645
  };
41577
43646
  current = { sock, cleanup, sweepStale };
41578
43647
  sock.on("close", cleanup);
41579
43648
  sock.on("error", cleanup);
41580
43649
  sock.on("message", (data) => {
41581
- void handleInbound(sock, agent, data, inflight, flush, session);
43650
+ if (sessionSuperseded) return;
43651
+ handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
43652
+ writeServiceLog2("error", "ws.inbound_failed", {}, e);
43653
+ });
41582
43654
  });
41583
43655
  flush();
41584
43656
  });
@@ -41593,6 +43665,13 @@ function attachAgentWebSocket(server, agent, opts) {
41593
43665
  }
41594
43666
  }
41595
43667
  wss.close();
43668
+ },
43669
+ /** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
43670
+ * `session_superseded`, and answer every later upgrade with 503. The
43671
+ * runtime's reconnect backoff does the rest; idempotent. */
43672
+ enterSessionSuperseded() {
43673
+ sessionSuperseded = true;
43674
+ for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
41596
43675
  }
41597
43676
  };
41598
43677
  }
@@ -41652,6 +43731,15 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
41652
43731
  session.perToolGrants = false;
41653
43732
  agent.clearPerToolConsumer(session);
41654
43733
  }
43734
+ const manifest = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(MANIFEST_GRANTS_FEATURE);
43735
+ if (manifest && !session.manifestGrants) {
43736
+ session.manifestGrants = true;
43737
+ agent.noteManifestConsumer(session);
43738
+ } else if (!manifest && session.manifestGrants) {
43739
+ session.manifestGrants = false;
43740
+ agent.clearManifestConsumer(session);
43741
+ }
43742
+ agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
41655
43743
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
41656
43744
  agent.noteNotesConsumer();
41657
43745
  }
@@ -41659,7 +43747,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
41659
43747
  if (session.recallConsumer) agent.noteRecallConsumer();
41660
43748
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
41661
43749
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
41662
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : []] });
43750
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE] });
41663
43751
  if (!session.deliveryOpen) {
41664
43752
  session.deliveryOpen = true;
41665
43753
  flush();
@@ -41872,8 +43960,17 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
41872
43960
  }
41873
43961
  return;
41874
43962
  }
43963
+ if (m.type === "room_cap_reached") {
43964
+ if (!session.manifestGrants || typeof m.channel_id !== "string" || !LOWER_UUID.test(m.channel_id)) {
43965
+ sendJson(sock, wsErrorFrame("invalid_request"));
43966
+ return;
43967
+ }
43968
+ agent.noteRoomCapReached(m.channel_id);
43969
+ return;
43970
+ }
41875
43971
  sendJson(sock, wsErrorFrame("invalid_request"));
41876
43972
  }
43973
+ var LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
41877
43974
  export {
41878
43975
  BRIDGE_ERRORS,
41879
43976
  BRIDGE_VERSION,