@securecoms/agent-bridge 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,32 @@
1
1
  import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
+ /**
3
+ * M6 (ADR-0013 Decision H): what the sweep observed about one channel.
4
+ *
5
+ * `state` and `diverged` are deliberately separate. `diverged` is a VERDICT and
6
+ * is `null` whenever the sweep could not earn one — an unresolvable group or a
7
+ * failed probe are faults in their own right, and reporting either as
8
+ * `diverged: false` would repeat the exact mistake this whole track is about:
9
+ * an indicator asserting a condition it did not actually measure.
10
+ */
11
+ export type ChannelObservation = {
12
+ channel_id: string;
13
+ /** healthy — group matches AND held epoch is level with the server
14
+ * behind — group matches but held epoch trails the server (REPORTED
15
+ * divergence; the sweep's ACTION predicate is group-id only,
16
+ * so this state does not by itself cause a welcome-request)
17
+ * diverged — held group id does not match the server's live group
18
+ * no_local_state / group_unresolvable / probe_failed — see `diverged` */
19
+ state: "healthy" | "behind" | "stuck" | "diverged" | "no_local_state" | "group_unresolvable" | "probe_failed";
20
+ held_group_id: string | null;
21
+ server_group_id: string | null;
22
+ held_epoch: number | null;
23
+ server_epoch: number | null;
24
+ /** true/false only when actually determined; null means "not established". */
25
+ diverged: boolean | null;
26
+ /** Evidence, not assertion: wall-clock ms of the last successful decrypt. */
27
+ last_decrypt_at: number | null;
28
+ observed_at: number;
29
+ };
2
30
  import type { BridgeConfig } from "./config.js";
3
31
  import { LatencyRecorder } from "./latency.js";
4
32
  import type { AttachmentMeta } from "./attachments.js";
@@ -105,6 +133,16 @@ export declare class InvalidPathError extends Error {
105
133
  readonly filePath: string;
106
134
  constructor(filePath: string, cause?: unknown);
107
135
  }
136
+ /**
137
+ * The envelope message_id becomes `mls_messages.id` (a UUID primary key) on the
138
+ * backend, which parses it with `uuid.UUID()`. A non-UUID message_id is a
139
+ * client-triggerable 500. `send`/`sendA2A` fail closed rather than hand a
140
+ * malformed id to the wire.
141
+ */
142
+ export declare class InvalidMessageIdError extends Error {
143
+ readonly messageId: string;
144
+ constructor(messageId: string);
145
+ }
108
146
  /**
109
147
  * Per-room agent pause toggle (daemon side): the backend rejected a publish
110
148
  * with `403 {detail:{error:"agent_paused"}}`, which the SDK's `RestTransport`
@@ -236,9 +274,74 @@ export declare class BridgeAgent {
236
274
  * counter is incremented. Surfaced on health()/metrics().
237
275
  */
238
276
  private failedAttachments;
277
+ /**
278
+ * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
279
+ * verb can tell "bridge process up" (the app's green dot, which only
280
+ * proves bridge<->backend) apart from "a harness is actually attached"
281
+ * (a real consumer holding an authenticated session). Only ws-server.ts
282
+ * mutates this, and only after authedUpgrade() passes — an unauthenticated
283
+ * socket, or the HTTP /healthz probe, must never move this counter.
284
+ * Surfaced on health()/metrics().
285
+ */
286
+ private authSessions;
287
+ /**
288
+ * Task 3 (audit M3): count of newest-wins evictions of a prior /v1/ws
289
+ * session. The daemon's WS is single-consumer newest-wins, so
290
+ * `agent_sessions` alone can stay >=1 while two connectors flap the same
291
+ * identity in and out — this counter makes that split-brain visible.
292
+ * Only ws-server.ts mutates this, via noteSessionEvicted(). Surfaced on
293
+ * health()/status.
294
+ */
295
+ private evictions;
239
296
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
240
297
  private readonly lastWelcomeRequest;
241
298
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
299
+ /**
300
+ * M6 (ADR-0013 Decision H): the sweep's per-channel observation cache, and the
301
+ * only thing {@link health} reports divergence from.
302
+ *
303
+ * Written by `selfHealSweep` in a `finally`, read synchronously by `health()`.
304
+ * `health()` must NEVER fan out network calls of its own: a health endpoint
305
+ * that does I/O hangs under exactly the conditions it exists to report, and
306
+ * the conditions here are "the delivery service is unreachable or lying".
307
+ */
308
+ private readonly observations;
309
+ /**
310
+ * A3 (dr-evil must-fix #3): true when the sweep found the provider missing a
311
+ * required capability (stale SDK dist under a newer bridge dist) and ABORTED.
312
+ * Folded into `observations_stale` (and therefore `degraded`) on health():
313
+ * a bridge that cannot heal must never present trustworthy-looking data.
314
+ */
315
+ private providerCapabilityMissing;
316
+ /**
317
+ * ADR-0013 Decision D: the wipe's OWN anti-thrash bound, deliberately
318
+ * separate from {@link REQUEST_WELCOME_COOLDOWN_MS} and separately keyed.
319
+ *
320
+ * Two bounds exist because they protect different things and must never
321
+ * share a gate. The request cooldown protects the single committer from
322
+ * churn; deferring an ask costs a little latency. The wipe bound only stops
323
+ * the member thrashing its own session; skipping a wipe costs the member its
324
+ * ONLY route back, permanently. Sharing one map is what starved the repair.
325
+ */
326
+ private static readonly WIPE_ANTI_THRASH_MS;
327
+ /**
328
+ * ADR-0013 Decision A (wipe-first): the SHORTER re-wipe floor used only when
329
+ * the fork is PROVEN (the self-heal sweep's resyncByReplay returned false).
330
+ *
331
+ * The 600s WIPE_ANTI_THRASH_MS is the right duty cycle for the reactive
332
+ * onWedged path, but it is a TRAP for a proven-forked member: while the member
333
+ * holds forked state the owner's re-Welcome is stray-acked (mls.ts, rowGroup ==
334
+ * heldGroup) and destroyed, so the member never returns to no-state and the
335
+ * Welcome never lands via the first-join path. Suppressing the re-wipe for a
336
+ * full 10 minutes means every owner Welcome in that window is destroyed — the
337
+ * observed hermes flap. This floor lets a proven-forked member re-wipe promptly
338
+ * (return to no-state so the next Welcome opens) while still bounding the loop:
339
+ * ~one 25s member ingest poll + margin, which preserves Decision D ("cannot
340
+ * loop") because two wipes still cannot fire inside a single poll interval.
341
+ */
342
+ private static readonly PROVEN_FORK_REWIPE_FLOOR_MS;
343
+ /** Per-channel timestamp of the last state repudiation (Decision D). */
344
+ private readonly lastWipe;
242
345
  private static readonly REQUEST_WELCOME_COOLDOWN_MS;
243
346
  private static readonly GRACEFUL_DRAIN_MS;
244
347
  /**
@@ -430,6 +533,16 @@ export declare class BridgeAgent {
430
533
  * rather than dropping it with no replacement in flight. Never throws.
431
534
  */
432
535
  private healSupersedeUnopenable;
536
+ /**
537
+ * Task 2 (MLS convergence slice): true when we hold persisted state for
538
+ * `channelId` whose group id does NOT match `serverGroupId` — the group
539
+ * the server currently says is live for that channel. Holding SOME state
540
+ * is not proof of health; it is proof of health only for the group the
541
+ * server currently attests. A null held group id (state present but the
542
+ * manager can't be loaded from it) is treated as diverged too — we can't
543
+ * confirm health, so we ask rather than silently trusting stale state.
544
+ */
545
+ private isChannelDiverged;
433
546
  /**
434
547
  * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
435
548
  * branch only fires reactively — off a delivery ROW that already arrived
@@ -438,11 +551,22 @@ export declare class BridgeAgent {
438
551
  * first thing lost. This sweep walks every member channel — rooms AND DMs
439
552
  * (H2: a wiped, idle DM with no traffic since the wipe otherwise never
440
553
  * proactively re-Welcomes, so it stays dark until the owner happens to send)
441
- * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
442
- * persisted state even after a reconcile attempt, resolves group_id via
443
- * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
444
- * which would side-effect a member_device_ids union + commit) and applies
445
- * the same held-KP signal — respecting the existing per-channel cooldown.
554
+ * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS.
555
+ *
556
+ * Task 2: holding persisted state is NOT proof of health by itself — it is
557
+ * proof of health only for the group the server currently says is live for
558
+ * the channel. The prior rule ("I have persisted state ⇒ healthy ⇒ don't
559
+ * ask") silently stopped filing welcome-requests for any channel that had
560
+ * been superseded (e.g. a server-side reset/rotation) while the member
561
+ * still held state for the OLD group: it could hold state, hold the WRONG
562
+ * state, and never say anything. So the server's current group_id is
563
+ * fetched FIRST via the READ-ONLY getConnectChannelGroup probe (never
564
+ * initConnectChannelGroup, which would side-effect a member_device_ids
565
+ * union + commit), then the branch is: persisted state whose group id
566
+ * matches the server's current group ⇒ healthy, skip; persisted state for
567
+ * a STALE group id ⇒ diverged, signal; no persisted state at all ⇒
568
+ * reconcile-first (consume an already-queued Welcome) before concluding
569
+ * divergence. All three paths go through the same per-channel cooldown.
446
570
  *
447
571
  * Each channel is wrapped in its own try/catch: `listChannels` filters at
448
572
  * the identity level while `requestWelcome` (via request_welcome) gates at
@@ -643,6 +767,25 @@ export declare class BridgeAgent {
643
767
  * Mirrors the notify tail of ingestOnce.
644
768
  */
645
769
  wakeListeners(): void;
770
+ /**
771
+ * Task 8: record a newly authenticated /v1/ws session. Called by
772
+ * ws-server.ts exactly once per connection, AFTER authedUpgrade() passes
773
+ * (never for a socket closed 1008 for bad auth, and never for the HTTP
774
+ * /healthz probe, which is not a /v1/ws session at all).
775
+ */
776
+ noteSessionOpen(): void;
777
+ /**
778
+ * Task 8: record an authenticated /v1/ws session ending. Clamped at 0 so a
779
+ * stray extra close (e.g. a double-fired cleanup) can't go negative and
780
+ * poison the next open/close cycle.
781
+ */
782
+ noteSessionClose(): void;
783
+ /**
784
+ * Task 3 (audit M3): record a prior /v1/ws session being evicted by a
785
+ * newer connection for the same identity (newest-wins). Called by
786
+ * ws-server.ts right after prior.cleanup() in the eviction path.
787
+ */
788
+ noteSessionEvicted(): void;
646
789
  health(): {
647
790
  status: "ok";
648
791
  identity_id: string;
@@ -661,8 +804,38 @@ export declare class BridgeAgent {
661
804
  /** 2026-07-03 latency triage: delivery pings received on the WS stream.
662
805
  * 0 while messages flow = push is dead; inbound rides the 25s floor. */
663
806
  delivery_pings: number;
807
+ /** Task 8: count of live authenticated /v1/ws sessions (0 = bridge is up
808
+ * but no harness is attached — a true "no brain" signal for `status`). */
809
+ agent_sessions: number;
810
+ /** Task 3 (audit M3): cumulative newest-wins evictions of a prior /v1/ws
811
+ * session for this identity — a nonzero/climbing value while
812
+ * agent_sessions stays >=1 means two connectors are flapping the same
813
+ * identity (split-brain), not one stable harness. */
814
+ evictions: number;
664
815
  uptime_s: number;
665
816
  version: string;
817
+ /** M6: is the self-heal sweep running at all? It is the SOLE writer of the
818
+ * observation cache below, so `SECURECOMS_SELF_HEAL_SWEEP=false` blanks all
819
+ * of it. That switch was set on cortina as a 2026-07-04 perf mitigation and
820
+ * silently disabled repair on the one agent that then stayed permanently
821
+ * diverged, so it must be reported rather than inferred from empty data. */
822
+ sweep_enabled: boolean;
823
+ /** M6: true when the newest observation is older than one full sweep period
824
+ * (or there are none). Means the SWEEP is dead — a different fault from
825
+ * divergence, and it must not read as a clean fleet. */
826
+ observations_stale: boolean;
827
+ /** M6: channels whose divergence verdict is definitively true. Excludes
828
+ * unresolvable/failed probes, which are counted as neither. */
829
+ diverged_channels: number;
830
+ /** A2 (dr-evil F3, decision D-3): the truthful roll-up — true when any
831
+ * channel is definitively diverged OR the observations cannot be trusted
832
+ * (stale/absent). Additive: `status` stays "ok" until the flip ships as
833
+ * its own deploy (external monitors restart non-ok bridges, and a
834
+ * restart loop cannot fix divergence). Gates read THIS field, never
835
+ * `status`. */
836
+ degraded: boolean;
837
+ /** M6: per-channel observations, newest sweep. Read from cache; no I/O. */
838
+ channels: ChannelObservation[];
666
839
  /** Optional memory-adapter counters (e.g. Hermes provider); present only when the active provider exposes stats(). */
667
840
  hermes_no_session?: number;
668
841
  hermes_skipped?: number;
package/dist/config.d.ts CHANGED
@@ -24,6 +24,7 @@ export interface BridgeConfig {
24
24
  /** For memoryProvider="openclaw": the OpenClaw agent dir slug, e.g. "cortina" (→ <base_dir>/agents/<agent>/). */
25
25
  memoryAgent?: string;
26
26
  }
27
+ export declare const REQUIRED: readonly ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
27
28
  export declare function loadConfig(opts: {
28
29
  credentialPath: string;
29
30
  storePath?: string;