@securecoms/agent-bridge 0.30.0 → 0.30.2

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
@@ -17457,7 +17457,13 @@ var CODES = [
17457
17457
  // lookup missed and before any write, so a retry can never land: the
17458
17458
  // pending commit is dropped instead of being resent forever. The same pair
17459
17459
  // answers an application send into a deleted channel (#325 F5).
17460
- { status: 409, code: "channel_unavailable" }
17460
+ { status: 409, code: "channel_unavailable" },
17461
+ // ADR-0023 B.1: an Add or Welcome into a humans-only enclave's group naming
17462
+ // a device that is not provably a person's. Refused after the replay lookup
17463
+ // missed and before any write, so nothing landed. Definite: the committer's
17464
+ // work cannot name an agent in a humans-only enclave (admission refuses
17465
+ // one), so dropping the commit starts no resend loop.
17466
+ { status: 403, code: "humans_only_enclave" }
17461
17467
  ];
17462
17468
  var DISTRIBUTE_DEFINITE_CODES = Object.freeze(CODES.map((c) => Object.freeze({ ...c })));
17463
17469
  var DEFINITE_KEYS = new Set(DISTRIBUTE_DEFINITE_CODES.map((c) => `${c.status} ${c.code}`));
@@ -24091,6 +24097,7 @@ function base64UrlNoPad(s) {
24091
24097
  }
24092
24098
  var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
24093
24099
  var WS_CLOSE_IDENTITY_REVOKED = 4006;
24100
+ var WS_CLOSE_SESSION_SUPERSEDED = 4409;
24094
24101
  function isIdentityRevokedClose(code, reason) {
24095
24102
  return code === WS_CLOSE_IDENTITY_REVOKED || reason === "identity_revoked";
24096
24103
  }
@@ -24306,7 +24313,7 @@ function openDeliveryPings(o) {
24306
24313
  end();
24307
24314
  };
24308
24315
  if (!hardened) {
24309
- if (code === 4001 || isIdentityRevokedClose(code, reason)) {
24316
+ if (code === 4001 || code === WS_CLOSE_SESSION_SUPERSEDED || isIdentityRevokedClose(code, reason)) {
24310
24317
  terminalClose();
24311
24318
  return;
24312
24319
  }
@@ -24315,7 +24322,7 @@ function openDeliveryPings(o) {
24315
24322
  }
24316
24323
  const tr = terminalReason;
24317
24324
  terminalReason = void 0;
24318
- if (code === 4006 || tr && code === 4001) {
24325
+ if (code === 4006 || code === WS_CLOSE_SESSION_SUPERSEDED || tr && code === 4001) {
24319
24326
  lifecycle("terminal", tr ?? `code ${code}`);
24320
24327
  terminalClose();
24321
24328
  return;
@@ -24472,10 +24479,14 @@ var RestTransport = class {
24472
24479
  f;
24473
24480
  ws;
24474
24481
  tokenProvider;
24475
- /** The most recent device-wide WS send handle, captured on subscribe and
24476
- * cleared on its close. Control frames (status keepalive) route through it;
24477
- * a no-op when no subscription is active. */
24482
+ /** The shared delivery-ping socket's send, set while that socket is open and
24483
+ * cleared when it closes (last subscriber gone, or terminal). Control frames
24484
+ * (status keepalive) route through it; a no-op when no subscription is active. */
24478
24485
  _controlSend = null;
24486
+ /** P-5: the one delivery-ping stream this transport holds, shared by every subscriber. */
24487
+ pingHub = null;
24488
+ /** Set once the shared stream ends on a terminal close; no socket is ever reopened. */
24489
+ pingsTerminal = false;
24479
24490
  constructor(opts) {
24480
24491
  this.opts = opts;
24481
24492
  this.f = opts.fetchImpl ?? fetch;
@@ -24577,25 +24588,52 @@ var RestTransport = class {
24577
24588
  idempotentReplay: j.idempotent_replay === true
24578
24589
  };
24579
24590
  }
24591
+ /** One device-wide delivery-ping socket per transport (P-5): the backend
24592
+ * supersedes a second socket for the same device, so every subscriber shares
24593
+ * one `openDeliveryPings` handle. Each gets its own bounded queue; its close()
24594
+ * detaches only itself; the socket closes with the last subscriber and a later
24595
+ * subscribe opens a fresh one. After a terminal close the transport is
24596
+ * terminal: later calls open no socket and get an already-ended iterator. */
24580
24597
  subscribeDeliveryPings(_channelId) {
24598
+ if (this.pingsTerminal) {
24599
+ const ended = new PingSubscriber();
24600
+ ended.end();
24601
+ return { pings: ended.pings, close: () => {
24602
+ }, send: () => {
24603
+ } };
24604
+ }
24605
+ const hub = this.pingHub ??= this.openPingHub();
24606
+ const sub = hub.attach();
24607
+ return { pings: sub.pings, close: () => hub.detach(sub), send: hub.send };
24608
+ }
24609
+ openPingHub() {
24581
24610
  let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
24582
24611
  if (this.opts.connectIdentityId) {
24583
24612
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
24584
24613
  }
24585
- const handle = openDeliveryPings({
24614
+ const hub = new PingHub(openDeliveryPings({
24586
24615
  url: wsUrl,
24587
24616
  tokenProvider: this.tokenProvider,
24588
24617
  wsImpl: this.ws,
24589
24618
  hardened: this.opts.hardenedReconnect ?? false,
24590
- onLifecycle: this.opts.onWsLifecycle
24591
- });
24592
- this._controlSend = handle.send;
24593
- const close = () => {
24594
- if (this._controlSend === handle.send)
24595
- this._controlSend = null;
24596
- handle.close();
24597
- };
24598
- return { pings: handle.pings, close, send: handle.send };
24619
+ onLifecycle: this.opts.onWsLifecycle,
24620
+ // Any terminal close (4001, 4006, 4409, hardened war-cap) of the shared
24621
+ // socket makes this transport terminal. The shared iterator ends right
24622
+ // after this call, so the pump exit tears down every subscriber.
24623
+ onTerminalClose: (close) => {
24624
+ this.pingsTerminal = true;
24625
+ this.opts.onTerminalClose?.(close);
24626
+ }
24627
+ }), () => this.dropPingHub(hub));
24628
+ this._controlSend = hub.send;
24629
+ return hub;
24630
+ }
24631
+ dropPingHub(hub) {
24632
+ if (this.pingHub === hub) {
24633
+ this.pingHub = null;
24634
+ this._controlSend = null;
24635
+ }
24636
+ hub.shutdown();
24599
24637
  }
24600
24638
  sendControlFrame(_channelId, frame) {
24601
24639
  try {
@@ -24623,6 +24661,90 @@ var RestTransport = class {
24623
24661
  }));
24624
24662
  }
24625
24663
  };
24664
+ var SUBSCRIBER_QUEUE_MAX = 8;
24665
+ var PingSubscriber = class {
24666
+ queue = [];
24667
+ waiter;
24668
+ ended = false;
24669
+ started = false;
24670
+ pings = {
24671
+ [Symbol.asyncIterator]: () => {
24672
+ if (this.started)
24673
+ throw new Error("subscribeDeliveryPings: single-use async iterable");
24674
+ this.started = true;
24675
+ return {
24676
+ next: () => {
24677
+ if (this.ended)
24678
+ return Promise.resolve({ value: void 0, done: true });
24679
+ if (this.queue.length)
24680
+ return Promise.resolve({ value: this.queue.shift(), done: false });
24681
+ return new Promise((resolve) => {
24682
+ this.waiter = resolve;
24683
+ });
24684
+ }
24685
+ };
24686
+ }
24687
+ };
24688
+ push(p) {
24689
+ if (this.ended)
24690
+ return;
24691
+ if (this.waiter) {
24692
+ const w = this.waiter;
24693
+ this.waiter = void 0;
24694
+ w({ value: p, done: false });
24695
+ } else if (this.queue.length < SUBSCRIBER_QUEUE_MAX) {
24696
+ this.queue.push(p);
24697
+ }
24698
+ }
24699
+ end() {
24700
+ this.ended = true;
24701
+ this.queue.length = 0;
24702
+ if (this.waiter) {
24703
+ const w = this.waiter;
24704
+ this.waiter = void 0;
24705
+ w({ value: void 0, done: true });
24706
+ }
24707
+ }
24708
+ };
24709
+ var PingHub = class {
24710
+ handle;
24711
+ onGone;
24712
+ subs = /* @__PURE__ */ new Set();
24713
+ send;
24714
+ constructor(handle, onGone) {
24715
+ this.handle = handle;
24716
+ this.onGone = onGone;
24717
+ this.send = handle.send;
24718
+ void this.pump();
24719
+ }
24720
+ /** `openDeliveryPings` iterables never reject and push() never throws, so
24721
+ * this loop only ends when the shared stream ends (our close, or terminal). */
24722
+ async pump() {
24723
+ for await (const p of this.handle.pings)
24724
+ for (const s of this.subs)
24725
+ s.push(p);
24726
+ this.onGone();
24727
+ }
24728
+ attach() {
24729
+ const s = new PingSubscriber();
24730
+ this.subs.add(s);
24731
+ return s;
24732
+ }
24733
+ /** Idempotent: detaches and ends only `s`; the last one out closes the socket. */
24734
+ detach(s) {
24735
+ this.subs.delete(s);
24736
+ s.end();
24737
+ if (this.subs.size === 0)
24738
+ this.onGone();
24739
+ }
24740
+ /** Idempotent: `openDeliveryPings().close()` is, and the set is empty after. */
24741
+ shutdown() {
24742
+ for (const s of this.subs)
24743
+ s.end();
24744
+ this.subs.clear();
24745
+ this.handle.close();
24746
+ }
24747
+ };
24626
24748
 
24627
24749
  // ../connect-sdk-ts/dist/mls/rest-backend.js
24628
24750
  function describeType(v) {
@@ -34952,6 +35074,7 @@ var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
34952
35074
  Object.freeze({ tool: "current_time", mode: "read" })
34953
35075
  ]);
34954
35076
  var NO_TOOL_GRANTS = Object.freeze([]);
35077
+ var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
34955
35078
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
34956
35079
  var OWNER_TURN_UNCHECKED = Object.freeze({ kind: "unchecked" });
34957
35080
  function ownerDmDefaultGrantsEnabled(env = process.env) {
@@ -35226,6 +35349,12 @@ function rosterFreshAt(entry, heldEpoch, heldGroupId) {
35226
35349
  var RoomGrantEpochMovedError = class extends Error {
35227
35350
  };
35228
35351
  var ROOM_GRANT_TRACK_MAX = 4096;
35352
+ var SessionSupersededError = class extends Error {
35353
+ constructor() {
35354
+ super("session_superseded: another copy of this agent took its session during start()");
35355
+ this.name = "SessionSupersededError";
35356
+ }
35357
+ };
35229
35358
  var GroupSupersededSendError = class extends Error {
35230
35359
  constructor(channelId, activeGroupId) {
35231
35360
  super(`send refused for channel ${channelId}: the channel moved to a new secure group; not sent`);
@@ -35265,9 +35394,18 @@ var BridgeAgent = class _BridgeAgent {
35265
35394
  const seed = hexToBytes4(cfg.signingPrivateKey);
35266
35395
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
35267
35396
  this.replyIdKey = deriveReplyIdKey(seed);
35397
+ const base = deps.fetchImpl ?? fetch;
35398
+ this.fetchImpl = (input, init) => this.sessionSuperseded ? Promise.reject(new Error("session_superseded")) : base(input, init);
35268
35399
  this.transport = deps.transport ?? new RestTransport({
35269
35400
  baseUrl: cfg.apiBase,
35270
35401
  bearerToken: cfg.apiKey,
35402
+ fetchImpl: this.fetchImpl,
35403
+ wsImpl: deps.wsImpl,
35404
+ // A 4409 close means another copy of this agent took the session.
35405
+ // Every other terminal close keeps today's behaviour.
35406
+ onTerminalClose: (close) => {
35407
+ if (close.code === WS_CLOSE_SESSION_SUPERSEDED2) this.onSessionSuperseded();
35408
+ },
35271
35409
  connectIdentityId: cfg.identityId,
35272
35410
  // P1b: hardened delivery-ping reconnect (verify-before-terminal +
35273
35411
  // half-open + wake + auth war-cap). On by default for the daemon;
@@ -35281,7 +35419,7 @@ var BridgeAgent = class _BridgeAgent {
35281
35419
  else writeServiceLog2("warn", "transport.delivery_ping_lifecycle_unknown");
35282
35420
  }
35283
35421
  });
35284
- this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
35422
+ this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, fetchImpl: this.fetchImpl });
35285
35423
  this.store = new SqliteMlsStateStore({
35286
35424
  dbPath: cfg.storePath,
35287
35425
  identityId: cfg.identityId,
@@ -35352,7 +35490,6 @@ var BridgeAgent = class _BridgeAgent {
35352
35490
  // throws (mirrors the divergence-heal signal path).
35353
35491
  onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId, this.drainExemptionAtCallIn())
35354
35492
  });
35355
- this.fetchImpl = deps.fetchImpl ?? fetch;
35356
35493
  this.home = deps.home ?? homedir4();
35357
35494
  this.grantWriters = {
35358
35495
  writeManifestPolicy: deps.grantWriters?.writeManifestPolicy ?? writeManifestPolicy,
@@ -35484,6 +35621,13 @@ var BridgeAgent = class _BridgeAgent {
35484
35621
  // stored for peek-then-join in ingestOnce
35485
35622
  custody;
35486
35623
  fetchImpl;
35624
+ /** P-1: set once a 4409 close says another copy took this agent's session.
35625
+ * The gated fetch built in the constructor refuses every request after it. */
35626
+ sessionSuperseded = false;
35627
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
35628
+ sessionSupersededListeners = /* @__PURE__ */ new Set();
35629
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
35630
+ closing = null;
35487
35631
  /** Round 5: home for the managed runtime-config directory (test seam). */
35488
35632
  home;
35489
35633
  grantWriters;
@@ -35959,6 +36103,12 @@ var BridgeAgent = class _BridgeAgent {
35959
36103
  action;
35960
36104
  waiters = [];
35961
36105
  pingHandle;
36106
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
36107
+ * held unconsumed until startReceivePump() takes it. */
36108
+ earlyPings = null;
36109
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
36110
+ * awaits it, bounded, so the store never closes under a start() step. */
36111
+ startInFlight = null;
35962
36112
  safetyTimer;
35963
36113
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
35964
36114
  ingestInFlight = null;
@@ -36536,7 +36686,50 @@ var BridgeAgent = class _BridgeAgent {
36536
36686
  this.droppedMessages += overflow;
36537
36687
  }
36538
36688
  }
36689
+ /**
36690
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
36691
+ * delivery-ping socket opens here, before anything else, so this copy claims
36692
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
36693
+ * boot drain runs. The pings it carries are NOT consumed until
36694
+ * startReceivePump(), the same place as before: starting the pump first is
36695
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
36696
+ *
36697
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
36698
+ * 0). start() then arms nothing more (supersededDuringStart before each
36699
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
36700
+ * with SessionSupersededError. A step that FAILED because of the supersede
36701
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
36702
+ */
36539
36703
  async start() {
36704
+ const early = this.transport.subscribeDeliveryPings("");
36705
+ this.earlyPings = early;
36706
+ this.pingHandle = { close: early.close };
36707
+ const body = this.startBody();
36708
+ this.startInFlight = body.then(() => {
36709
+ }, () => {
36710
+ });
36711
+ try {
36712
+ await body;
36713
+ } catch (e) {
36714
+ if (!this.sessionSuperseded) {
36715
+ if (this.earlyPings) {
36716
+ this.earlyPings.close();
36717
+ this.earlyPings = null;
36718
+ this.pingHandle = void 0;
36719
+ }
36720
+ throw e;
36721
+ }
36722
+ if (!(e instanceof SessionSupersededError)) writeServiceLog2("warn", "startup.superseded_start_error", {}, e);
36723
+ await this.close().catch(() => {
36724
+ });
36725
+ throw new SessionSupersededError();
36726
+ }
36727
+ }
36728
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
36729
+ supersededDuringStart() {
36730
+ return this.sessionSuperseded;
36731
+ }
36732
+ async startBody() {
36540
36733
  this.startedAt = Date.now();
36541
36734
  await this.restoreRuntimeInbox();
36542
36735
  await this.loadToolGrantState();
@@ -36596,10 +36789,12 @@ var BridgeAgent = class _BridgeAgent {
36596
36789
  await this.warmRestoredNotes();
36597
36790
  await this.reindexRestoredTurns();
36598
36791
  if (this.unifiedOn) await this.reindexUnifiedRestored();
36792
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36599
36793
  this.runtimeConfigTimer = setInterval(() => {
36600
36794
  void this.refetchRuntimeConfig();
36601
36795
  }, 6 * 60 * 60 * 1e3);
36602
36796
  this.runtimeConfigTimer.unref?.();
36797
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36603
36798
  this.membershipSweepTimer = setInterval(() => {
36604
36799
  if (this.closingDown || this.membershipSweepWork) return;
36605
36800
  this.membershipSweepWork = this.runMembershipSweeps().finally(() => {
@@ -36607,6 +36802,7 @@ var BridgeAgent = class _BridgeAgent {
36607
36802
  });
36608
36803
  }, PURGE_SWEEP_INTERVAL_MS);
36609
36804
  this.membershipSweepTimer.unref?.();
36805
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36610
36806
  this.recallPruneTimer = setInterval(() => {
36611
36807
  if (this.closingDown || this.recallPruneWork) return;
36612
36808
  this.recallPruneWork = this.runRecallPruneTick().finally(() => {
@@ -36614,6 +36810,7 @@ var BridgeAgent = class _BridgeAgent {
36614
36810
  });
36615
36811
  }, 60 * 60 * 1e3);
36616
36812
  this.recallPruneTimer.unref?.();
36813
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36617
36814
  if (this.machinePaths) this.recallMaintenanceTimer = setInterval(() => {
36618
36815
  if (this.closingDown) return;
36619
36816
  const n = ++this.maintenanceTickN;
@@ -36624,6 +36821,7 @@ var BridgeAgent = class _BridgeAgent {
36624
36821
  });
36625
36822
  }, 1e3);
36626
36823
  this.recallMaintenanceTimer?.unref?.();
36824
+ if (this.supersededDuringStart()) throw new SessionSupersededError();
36627
36825
  this.startReceivePump();
36628
36826
  if (drainedInTime) {
36629
36827
  this.armSelfHealSweeps();
@@ -40887,7 +41085,8 @@ var BridgeAgent = class _BridgeAgent {
40887
41085
  }
40888
41086
  }
40889
41087
  startReceivePump() {
40890
- const sub = this.transport.subscribeDeliveryPings("");
41088
+ const sub = this.earlyPings ?? this.transport.subscribeDeliveryPings("");
41089
+ this.earlyPings = null;
40891
41090
  this.pingHandle = { close: sub.close };
40892
41091
  void (async () => {
40893
41092
  try {
@@ -42472,7 +42671,42 @@ ${channelId}`;
42472
42671
  build: BRIDGE_VERSION
42473
42672
  };
42474
42673
  }
42475
- async close() {
42674
+ /** C9: `session_superseded` once a 4409 said another copy took this
42675
+ * agent's session (the gated fetch already refuses every request). The
42676
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
42677
+ get sessionState() {
42678
+ return this.sessionSuperseded ? "session_superseded" : "live";
42679
+ }
42680
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
42681
+ * construction, before start(), so a 4409 during start() is heard too. */
42682
+ on(event, listener) {
42683
+ this.sessionSupersededListeners.add(listener);
42684
+ return () => this.sessionSupersededListeners.delete(listener);
42685
+ }
42686
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
42687
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
42688
+ * request leaves the process after this, whatever a listener does. */
42689
+ onSessionSuperseded() {
42690
+ this.sessionSuperseded = true;
42691
+ for (const listener of [...this.sessionSupersededListeners]) {
42692
+ try {
42693
+ listener();
42694
+ } catch (e) {
42695
+ writeServiceLog2("error", "transport.session_superseded_listener_failed", {}, e);
42696
+ }
42697
+ }
42698
+ }
42699
+ /**
42700
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
42701
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
42702
+ * store.close() twice. A rejected close stays rejected: a retry would close
42703
+ * the store a second time.
42704
+ */
42705
+ close() {
42706
+ this.closing ??= this.closeOnce();
42707
+ return this.closing;
42708
+ }
42709
+ async closeOnce() {
42476
42710
  this.closingDown = true;
42477
42711
  this.statusGate.close();
42478
42712
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
@@ -42485,6 +42719,28 @@ ${channelId}`;
42485
42719
  if (this.safetyTimer) clearInterval(this.safetyTimer);
42486
42720
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
42487
42721
  if (this.membershipSweepTimer) clearInterval(this.membershipSweepTimer);
42722
+ const ingest = this.ingestInFlight;
42723
+ if (ingest) {
42724
+ let timer;
42725
+ await Promise.race([
42726
+ ingest.released,
42727
+ new Promise((resolve) => {
42728
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42729
+ })
42730
+ ]);
42731
+ if (timer) clearTimeout(timer);
42732
+ }
42733
+ const starting = this.startInFlight;
42734
+ if (starting) {
42735
+ let timer;
42736
+ await Promise.race([
42737
+ starting,
42738
+ new Promise((resolve) => {
42739
+ timer = setTimeout(resolve, _BridgeAgent.GRACEFUL_DRAIN_MS);
42740
+ })
42741
+ ]);
42742
+ if (timer) clearTimeout(timer);
42743
+ }
42488
42744
  if (this.membershipSweepWork) {
42489
42745
  let timer;
42490
42746
  await Promise.race([
@@ -42571,8 +42827,30 @@ ${channelId}`;
42571
42827
  import { readFileSync as readFileSync10, statSync as statSync5 } from "node:fs";
42572
42828
  import { dirname as dirname6, join as join18 } from "node:path";
42573
42829
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
42830
+ var CONFIG_ERROR_MESSAGE = {
42831
+ credential_not_found: "credential file not found",
42832
+ credential_unreadable: "credential file could not be read",
42833
+ credential_not_json: "credential file is not valid JSON",
42834
+ credential_missing_field: "credential file missing required field",
42835
+ credential_voice_invalid: "credential file voice config invalid"
42836
+ };
42837
+ var ConfigError = class extends Error {
42838
+ constructor(reason, field, source) {
42839
+ super(CONFIG_ERROR_MESSAGE[reason] + (field ? `: ${field}` : ""));
42840
+ this.reason = reason;
42841
+ this.field = field;
42842
+ this.source = source;
42843
+ this.name = "ConfigError";
42844
+ }
42845
+ };
42574
42846
  function loadConfig(opts) {
42575
- const raw = readFileSync10(opts.credentialPath, "utf8");
42847
+ let raw;
42848
+ try {
42849
+ raw = readFileSync10(opts.credentialPath, "utf8");
42850
+ } catch (e) {
42851
+ const code = e?.code;
42852
+ throw new ConfigError(code === "ENOENT" ? "credential_not_found" : "credential_unreadable");
42853
+ }
42576
42854
  try {
42577
42855
  const mode = statSync5(opts.credentialPath).mode & 511;
42578
42856
  if (mode & 63) writeServiceLog2("warn", "config.credential_permissions_unsafe");
@@ -42582,9 +42860,9 @@ function loadConfig(opts) {
42582
42860
  try {
42583
42861
  json2 = JSON.parse(raw);
42584
42862
  } catch {
42585
- throw new Error("credential file is not valid JSON");
42863
+ throw new ConfigError("credential_not_json");
42586
42864
  }
42587
- for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
42865
+ for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new ConfigError("credential_missing_field", k);
42588
42866
  const storePath = opts.storePath ?? join18(dirname6(opts.credentialPath), "bridge-state.db");
42589
42867
  const ownerGrantPin = loadOwnerGrantPin({ credentialPath: opts.credentialPath, credentialJson: json2 });
42590
42868
  writeServiceLog2(ownerGrantPin.state === "ok" || ownerGrantPin.state === "no_pin" ? "info" : "warn", "authz.pin", {
@@ -42622,8 +42900,14 @@ function loadConfig(opts) {
42622
42900
  let voice;
42623
42901
  try {
42624
42902
  voice = parseVoiceConfig(json2.voice, process.env);
42625
- } catch (e) {
42626
- throw new Error(`credential file voice config invalid: ${e.message}`);
42903
+ } catch {
42904
+ let source = "env";
42905
+ try {
42906
+ parseVoiceConfig(json2.voice, {});
42907
+ } catch {
42908
+ source = "file";
42909
+ }
42910
+ throw new ConfigError("credential_voice_invalid", void 0, source);
42627
42911
  }
42628
42912
  const modelSource = json2.memory?.model_source === "import_only" ? "import_only" : "download";
42629
42913
  const enabledRaw = json2.memory?.enabled;
@@ -42761,6 +43045,9 @@ var BRIDGE_ERRORS = {
42761
43045
  // longer holds the shared bytes or key. Terminal for that link: never fall
42762
43046
  // back to the current version. `detail.pin` names the refused pin.
42763
43047
  vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
43048
+ // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
43049
+ // session (WS 4409), so this copy stopped. Every local route but /healthz.
43050
+ session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
42764
43051
  internal: { http: 500, message: "internal error" }
42765
43052
  };
42766
43053
  function errorBody(code, detail) {
@@ -42991,6 +43278,10 @@ function createHttpServer(agent, opts) {
42991
43278
  const url = new URL(req.url ?? "/", "http://localhost");
42992
43279
  const path = url.pathname;
42993
43280
  const method = req.method ?? "GET";
43281
+ if (agent.sessionState === "session_superseded") {
43282
+ if (method === "GET" && path === "/healthz") return json(res, 200, { state: "session_superseded" });
43283
+ return jsonError(res, "session_superseded");
43284
+ }
42994
43285
  if (method === "GET" && path === "/healthz") {
42995
43286
  return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
42996
43287
  }
@@ -43202,6 +43493,7 @@ var PING_INTERVAL_MS = 3e4;
43202
43493
  var INFLIGHT_STALE_SWEEP_MS = 6e4;
43203
43494
  var INFLIGHT_STALE_AFTER_MS = 15 * 6e4;
43204
43495
  var HELLO_WAIT_MS = 2e3;
43496
+ var WS_CLOSE_SESSION_SUPERSEDED3 = 4409;
43205
43497
  var MEMORY_NOTES_FEATURE = "memory_notes";
43206
43498
  var MEMORY_RECALL_FEATURE = "memory_recall";
43207
43499
  var MEMORY_UNIFIED_FEATURE = "memory_unified";
@@ -43222,7 +43514,12 @@ function authedUpgrade(req, opts) {
43222
43514
  return true;
43223
43515
  }
43224
43516
  function attachAgentWebSocket(server, agent, opts) {
43225
- const wss = new WebSocketServer({ server, path: "/v1/ws" });
43517
+ let sessionSuperseded = false;
43518
+ const wss = new WebSocketServer({
43519
+ server,
43520
+ path: "/v1/ws",
43521
+ verifyClient: (_info, done) => sessionSuperseded ? done(false, 503, "session_superseded") : done(true)
43522
+ });
43226
43523
  const keepalive = setInterval(() => {
43227
43524
  for (const client of wss.clients) {
43228
43525
  const sock = client;
@@ -43362,6 +43659,7 @@ function attachAgentWebSocket(server, agent, opts) {
43362
43659
  if (helloTimer) clearTimeout(helloTimer);
43363
43660
  agent.noteSessionClose();
43364
43661
  unsubscribe();
43662
+ if (sessionSuperseded) inflight.clear();
43365
43663
  if (inflight.size) {
43366
43664
  agent.requeueFailedRuntimeDeliveries(
43367
43665
  [...inflight.values()].filter((m) => !agent.isRuntimeMessageRetired(m.message_id))
@@ -43383,7 +43681,10 @@ function attachAgentWebSocket(server, agent, opts) {
43383
43681
  sock.on("close", cleanup);
43384
43682
  sock.on("error", cleanup);
43385
43683
  sock.on("message", (data) => {
43386
- void handleInbound(sock, agent, data, inflight, flush, session);
43684
+ if (sessionSuperseded) return;
43685
+ handleInbound(sock, agent, data, inflight, flush, session).catch((e) => {
43686
+ writeServiceLog2("error", "ws.inbound_failed", {}, e);
43687
+ });
43387
43688
  });
43388
43689
  flush();
43389
43690
  });
@@ -43398,6 +43699,13 @@ function attachAgentWebSocket(server, agent, opts) {
43398
43699
  }
43399
43700
  }
43400
43701
  wss.close();
43702
+ },
43703
+ /** C9 (spec §4.1.6 step 1, OQ-9): close every open socket with 4409 /
43704
+ * `session_superseded`, and answer every later upgrade with 503. The
43705
+ * runtime's reconnect backoff does the rest; idempotent. */
43706
+ enterSessionSuperseded() {
43707
+ sessionSuperseded = true;
43708
+ for (const client of wss.clients) client.close(WS_CLOSE_SESSION_SUPERSEDED3, "session_superseded");
43401
43709
  }
43402
43710
  };
43403
43711
  }
@@ -14,8 +14,6 @@ export interface UpgradeDeps {
14
14
  isTTY: boolean;
15
15
  stderr: (s: string) => void;
16
16
  ask: (q: string) => Promise<string>;
17
- hasBrew: () => boolean;
18
- runBrew: () => number;
19
17
  fetchText: (url: string) => Promise<string>;
20
18
  fetchBinary: (url: string, destPath: string) => Promise<void>;
21
19
  fileSha256: (p: string) => string;