@securecoms/agent-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +378 -0
- package/dist/attachments.d.ts +48 -0
- package/dist/attachments.js +49 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +56 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +3963 -0
- package/dist/bridge-agent.d.ts +259 -0
- package/dist/bridge-agent.js +831 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +74 -0
- package/dist/coordination.d.ts +24 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3646 -0
- package/dist/latency.d.ts +26 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.d.ts +17 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.d.ts +23 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.d.ts +28 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.d.ts +4 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/resolve-bin.d.ts +35 -0
- package/dist/service/systemd.d.ts +4 -0
- package/dist/service/systemd.js +25 -0
- package/dist/ws-server.d.ts +42 -0
- package/dist/ws-server.js +254 -0
- package/package.json +48 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
|
|
2
|
+
import type { BridgeConfig } from "./config.js";
|
|
3
|
+
import { LatencyRecorder } from "./latency.js";
|
|
4
|
+
export interface BridgeMessage {
|
|
5
|
+
channel_id: string;
|
|
6
|
+
message_id: string;
|
|
7
|
+
sender_id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
ts: number;
|
|
10
|
+
/**
|
|
11
|
+
* Phase 2 (sender attribution): the sender's display name, resolved from the
|
|
12
|
+
* channel roster cache. Undefined when the roster hasn't resolved the
|
|
13
|
+
* sender_id (fetch failure, or a member not in the listing) — the consumer
|
|
14
|
+
* then falls back to sender_id. Never blocks delivery on resolution.
|
|
15
|
+
*/
|
|
16
|
+
sender_name?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Phase 2: true if the envelope's sender_type is "agent", false if "human",
|
|
19
|
+
* undefined if the envelope carried neither. Derived from the SAME field the
|
|
20
|
+
* @mention filter reads (not recomputed differently).
|
|
21
|
+
*/
|
|
22
|
+
sender_is_agent?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface BridgeAgentDeps {
|
|
25
|
+
transport?: any;
|
|
26
|
+
backend?: any;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
/** Injectable so tests can assert recorded stages; defaults to a fresh one. */
|
|
29
|
+
latency?: LatencyRecorder;
|
|
30
|
+
/**
|
|
31
|
+
* Phase 1 item 4 (ADR-0002): hard cap on the in-memory inbound buffer. A
|
|
32
|
+
* consumer that never drains (agent offline, no /v1/events poller) must not
|
|
33
|
+
* let a sustained inbound backlog grow unbounded and OOM the daemon. When the
|
|
34
|
+
* buffer would exceed this many messages we shed the OLDEST and keep the most
|
|
35
|
+
* recent (a real-time messaging consumer cares about the newest). Default 1000.
|
|
36
|
+
*/
|
|
37
|
+
maxBuffer?: number;
|
|
38
|
+
}
|
|
39
|
+
/** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
|
|
40
|
+
export declare const DEFAULT_MAX_BUFFER = 1000;
|
|
41
|
+
/**
|
|
42
|
+
* TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
|
|
43
|
+
* so this exists only so a cached `name` (the room display name used by the
|
|
44
|
+
* @mention filter) re-resolves after a channel rename. Lazy expiry on read.
|
|
45
|
+
*/
|
|
46
|
+
export declare const CHANNEL_META_TTL_MS: number;
|
|
47
|
+
/**
|
|
48
|
+
* TTL for the Phase 2 channel ROSTER cache (channel_id → identity_id → name /
|
|
49
|
+
* is_agent), used to attribute decrypted messages by sender. Parallels
|
|
50
|
+
* CHANNEL_META_TTL_MS: a member's display_name can change (rename) and the
|
|
51
|
+
* member set can change, so a stale roster re-resolves on a later cycle. Lazy
|
|
52
|
+
* expiry on read. Resolution is best-effort and fail-soft — a miss leaves
|
|
53
|
+
* sender_name undefined and NEVER blocks or drops a message.
|
|
54
|
+
*/
|
|
55
|
+
export declare const CHANNEL_ROSTER_TTL_MS: number;
|
|
56
|
+
export declare class ChannelPendingError extends Error {
|
|
57
|
+
readonly channelId: string;
|
|
58
|
+
constructor(channelId: string);
|
|
59
|
+
}
|
|
60
|
+
export declare class RateLimitedError extends Error {
|
|
61
|
+
readonly channelId: string;
|
|
62
|
+
readonly retryAfterMs: number;
|
|
63
|
+
constructor(channelId: string, retryAfterMs: number);
|
|
64
|
+
}
|
|
65
|
+
export declare const BRIDGE_VERSION = "0.0.0";
|
|
66
|
+
export declare class BridgeAgent {
|
|
67
|
+
private readonly cfg;
|
|
68
|
+
private readonly transport;
|
|
69
|
+
private readonly provider;
|
|
70
|
+
private readonly backend;
|
|
71
|
+
private readonly custody;
|
|
72
|
+
private readonly fetchImpl;
|
|
73
|
+
private readonly joinState;
|
|
74
|
+
/**
|
|
75
|
+
* Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
|
|
76
|
+
* by the multi-agent room @mention filter at ingest. Populated/refreshed from a
|
|
77
|
+
* SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
|
|
78
|
+
* be resolved (network error, or not present in the listing) stays UNKNOWN → the
|
|
79
|
+
* filter fails OPEN.
|
|
80
|
+
*
|
|
81
|
+
* `is_dm` is immutable for a given channel_id (no misclassification risk), so the
|
|
82
|
+
* TTL exists only to keep `name` fresh after a channel rename — an entry older
|
|
83
|
+
* than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
|
|
84
|
+
*/
|
|
85
|
+
private readonly channelMeta;
|
|
86
|
+
/**
|
|
87
|
+
* Phase 2 (sender attribution): per-channel roster cache
|
|
88
|
+
* (channel_id → identity_id → { name, is_agent }) resolved from the GET
|
|
89
|
+
* members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
|
|
90
|
+
* unresolved channel (network error, or no entry) leaves sender_name
|
|
91
|
+
* undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
|
|
92
|
+
* per channel on a miss.
|
|
93
|
+
*/
|
|
94
|
+
private readonly channelRoster;
|
|
95
|
+
private readonly buffer;
|
|
96
|
+
private readonly maxBuffer;
|
|
97
|
+
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
98
|
+
private droppedMessages;
|
|
99
|
+
/**
|
|
100
|
+
* Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
|
|
101
|
+
* ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
|
|
102
|
+
* most room traffic is distinguishable from an idle/wedged one.
|
|
103
|
+
*/
|
|
104
|
+
private filteredRoomMessages;
|
|
105
|
+
/** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
|
|
106
|
+
private rateLimitedSends;
|
|
107
|
+
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
108
|
+
private welcomeRequestsSent;
|
|
109
|
+
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
110
|
+
private readonly lastWelcomeRequest;
|
|
111
|
+
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
112
|
+
private static readonly REQUEST_WELCOME_COOLDOWN_MS;
|
|
113
|
+
private readonly rateFloor;
|
|
114
|
+
private readonly waiters;
|
|
115
|
+
private pingHandle?;
|
|
116
|
+
private safetyTimer?;
|
|
117
|
+
private readonly listeners;
|
|
118
|
+
private startedAt;
|
|
119
|
+
private readonly store;
|
|
120
|
+
private readonly latency;
|
|
121
|
+
constructor(cfg: BridgeConfig, deps?: BridgeAgentDeps);
|
|
122
|
+
/**
|
|
123
|
+
* Enforce the inbound-buffer cap with a DROP-OLDEST policy.
|
|
124
|
+
*
|
|
125
|
+
* The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
|
|
126
|
+
* decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
|
|
127
|
+
* messages at the FRONT — those re-buffered messages were consumed earlier, so
|
|
128
|
+
* they are by definition OLDER than whatever already sits in the buffer. In
|
|
129
|
+
* every push path the newest messages therefore live at the back. To honour
|
|
130
|
+
* "the most-recent survive" we always shed from the FRONT, then record the
|
|
131
|
+
* shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
|
|
132
|
+
*/
|
|
133
|
+
private capBuffer;
|
|
134
|
+
start(): Promise<void>;
|
|
135
|
+
private reconcileJoin;
|
|
136
|
+
/**
|
|
137
|
+
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
138
|
+
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
139
|
+
* room and a2a traffic on the same channel are tracked separately.
|
|
140
|
+
*/
|
|
141
|
+
private enforceRateFloor;
|
|
142
|
+
send(channelId: string, text: string, messageId?: string): Promise<{
|
|
143
|
+
message_id: string;
|
|
144
|
+
status: "sent";
|
|
145
|
+
}>;
|
|
146
|
+
sendA2A(channelId: string, a2a: {
|
|
147
|
+
a2a_version: string;
|
|
148
|
+
kind: "message" | "task" | "artifact";
|
|
149
|
+
object: Record<string, unknown>;
|
|
150
|
+
}, messageId?: string): Promise<{
|
|
151
|
+
message_id: string;
|
|
152
|
+
status: "sent";
|
|
153
|
+
}>;
|
|
154
|
+
listChannels(): Promise<Array<{
|
|
155
|
+
channel_id: string;
|
|
156
|
+
name: string | null;
|
|
157
|
+
is_dm: boolean;
|
|
158
|
+
joined: boolean;
|
|
159
|
+
}>>;
|
|
160
|
+
/** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
|
|
161
|
+
private freshMeta;
|
|
162
|
+
/**
|
|
163
|
+
* One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
|
|
164
|
+
* ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
|
|
165
|
+
* burst of K unknown channels fires a single listChannels() instead of K. Any
|
|
166
|
+
* listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
|
|
167
|
+
* blocks on the network.
|
|
168
|
+
*/
|
|
169
|
+
private refreshChannelMeta;
|
|
170
|
+
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
171
|
+
private freshRoster;
|
|
172
|
+
/**
|
|
173
|
+
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
174
|
+
* GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
|
|
175
|
+
* cycle per channel on a cache miss. Fail-soft: any error leaves the cache
|
|
176
|
+
* as-is so the affected senders stay unresolved (sender_name undefined) —
|
|
177
|
+
* attribution must NEVER block or drop a message.
|
|
178
|
+
*/
|
|
179
|
+
private refreshChannelRoster;
|
|
180
|
+
reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
|
|
181
|
+
protected startReceivePump(): void;
|
|
182
|
+
private ingestOnce;
|
|
183
|
+
drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
|
|
184
|
+
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
185
|
+
subscribe(listener: () => void): () => void;
|
|
186
|
+
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
187
|
+
takeBuffered(channelId?: string): BridgeMessage[];
|
|
188
|
+
/**
|
|
189
|
+
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
190
|
+
*
|
|
191
|
+
* The re-buffered messages are older un-acked deliveries, so they go at the
|
|
192
|
+
* FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
|
|
193
|
+
* the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
|
|
194
|
+
* sheds from the front, preserving the newest maxBuffer messages across the
|
|
195
|
+
* combined [requeued-older..., existing-newer...] buffer.
|
|
196
|
+
*/
|
|
197
|
+
requeueFront(msgs: BridgeMessage[]): void;
|
|
198
|
+
/**
|
|
199
|
+
* Poke subscribers + waiters to drain the buffer now. Used after a
|
|
200
|
+
* disconnect re-buffers un-acked messages so a still-connected consumer
|
|
201
|
+
* (e.g. a replacement WS) redelivers them without waiting for new inbound.
|
|
202
|
+
* Mirrors the notify tail of ingestOnce.
|
|
203
|
+
*/
|
|
204
|
+
wakeListeners(): void;
|
|
205
|
+
health(): {
|
|
206
|
+
status: "ok";
|
|
207
|
+
identity_id: string;
|
|
208
|
+
joined_channels: number;
|
|
209
|
+
buffered: number;
|
|
210
|
+
/** Cumulative inbound messages shed by the buffer cap (0 unless a flood hit the bound). */
|
|
211
|
+
dropped: number;
|
|
212
|
+
/** Cumulative ROOM messages dropped by the @mention filter (correct filtering ≠ idle). */
|
|
213
|
+
filtered_room: number;
|
|
214
|
+
/** Cumulative sends shed by the per-channel rate-limit floor (correct backpressure ≠ idle). */
|
|
215
|
+
rate_limited: number;
|
|
216
|
+
/** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
|
|
217
|
+
permanently_skipped: number;
|
|
218
|
+
uptime_s: number;
|
|
219
|
+
version: string;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Loopback observability snapshot served at GET /v1/metrics.
|
|
223
|
+
*
|
|
224
|
+
* `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
|
|
225
|
+
* locally and monotonically — send.encrypt / send.publish / send.total (the
|
|
226
|
+
* sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
|
|
227
|
+
* recipient half). Excludes the agent's LLM time entirely.
|
|
228
|
+
*
|
|
229
|
+
* `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
|
|
230
|
+
* provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
|
|
231
|
+
* channel appears only once it has had a decrypt error; `stuck:true` means
|
|
232
|
+
* repeated failures with no intervening successful decrypt.
|
|
233
|
+
*
|
|
234
|
+
* `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
|
|
235
|
+
* bounded-buffer cap. A daemon silently dropping messages under backpressure is
|
|
236
|
+
* exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
|
|
237
|
+
* on health()) rather than swallowed.
|
|
238
|
+
*
|
|
239
|
+
* `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
|
|
240
|
+
* dropped by the @mention filter. A silently-filtered room message produces no
|
|
241
|
+
* other signal, so an agent correctly filtering 95% of room traffic would look
|
|
242
|
+
* identical to an idle/wedged one without this counter.
|
|
243
|
+
*
|
|
244
|
+
* `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
|
|
245
|
+
* acked-and-dropped because they were provably past-epoch undecryptable
|
|
246
|
+
* (forward-secrecy secret gone), so a self-healed poison row is observable
|
|
247
|
+
* here instead of silently dropped.
|
|
248
|
+
*/
|
|
249
|
+
metrics(): {
|
|
250
|
+
stages: ReturnType<LatencyRecorder["snapshot"]>;
|
|
251
|
+
errors: ReturnType<MlsGroupKeyProvider["errorStats"]>;
|
|
252
|
+
dropped: number;
|
|
253
|
+
filteredRoomMessages: number;
|
|
254
|
+
rateLimitedSends: number;
|
|
255
|
+
permanentlySkipped: number;
|
|
256
|
+
welcomeRequestsSent: number;
|
|
257
|
+
};
|
|
258
|
+
close(): Promise<void>;
|
|
259
|
+
}
|