@securecoms/agent-bridge 0.1.0 → 0.3.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 +19 -0
- package/dist/bin.d.ts +10 -0
- package/dist/bin.js +2032 -77
- package/dist/bin.js.pre-c1-f803007 +5795 -0
- package/dist/bin.js.pre-c3-096a9a3 +5832 -0
- package/dist/bin.js.pre-ffrebuild-b162d4a +5639 -0
- package/dist/bin.js.pre-kpheal-20260708-150924 +5531 -0
- package/dist/bin.js.pre-kppersist-20260708-152341 +5543 -0
- package/dist/bin.js.pre-p0-20260708-102415 +5196 -0
- package/dist/bin.js.pre-p1b-02f47e1 +5639 -0
- package/dist/bin.js.pre-warmfix-20260708-153705 +5613 -0
- package/dist/bin.js.pretrace-113205 +5395 -0
- package/dist/bridge-agent.d.ts +393 -0
- package/dist/decrypt-error-log-limiter.d.ts +52 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +2009 -64
- package/dist/index.js.pre-p0-20260708-102415 +4879 -0
- package/dist/ws-server.d.ts +5 -0
- package/package.json +2 -1
|
@@ -0,0 +1,4879 @@
|
|
|
1
|
+
// ../connect-core/dist/envelope/types.js
|
|
2
|
+
var MESSAGE_TYPES = [
|
|
3
|
+
"text",
|
|
4
|
+
"decision_request",
|
|
5
|
+
"decision_response",
|
|
6
|
+
"status_alert",
|
|
7
|
+
"artifact_share",
|
|
8
|
+
"action_confirmation",
|
|
9
|
+
"heartbeat",
|
|
10
|
+
"system_event",
|
|
11
|
+
"a2a"
|
|
12
|
+
];
|
|
13
|
+
var AV_ENVELOPE_VERSION = "1.0";
|
|
14
|
+
|
|
15
|
+
// ../connect-core/dist/envelope/validate.js
|
|
16
|
+
import _Ajv2020 from "ajv/dist/2020.js";
|
|
17
|
+
import _addFormats from "ajv-formats";
|
|
18
|
+
|
|
19
|
+
// ../connect-core/dist/envelope/schemas.generated.js
|
|
20
|
+
var envelopeSchema = {
|
|
21
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
22
|
+
"$id": "https://agentvault.dev/connect/schemas/envelope-1.0.json",
|
|
23
|
+
"title": "AgentVaultEnvelope",
|
|
24
|
+
"description": "Canonical client-signed Connect envelope (spec 02 \xA73.2, post-Blocker-1+5). Single source of truth shared by the TypeScript codec (ajv) and the Python edge (jsonschema).",
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"required": [
|
|
28
|
+
"av_version",
|
|
29
|
+
"message_id",
|
|
30
|
+
"channel_id",
|
|
31
|
+
"enclave_id",
|
|
32
|
+
"message_type",
|
|
33
|
+
"sender_id",
|
|
34
|
+
"sender_type",
|
|
35
|
+
"identity_proof",
|
|
36
|
+
"trace_context",
|
|
37
|
+
"policy_binding",
|
|
38
|
+
"mls_group_epoch",
|
|
39
|
+
"replay_nonce",
|
|
40
|
+
"payload_encrypted",
|
|
41
|
+
"references"
|
|
42
|
+
],
|
|
43
|
+
"properties": {
|
|
44
|
+
"av_version": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"pattern": "^[0-9]+\\.[0-9]+$",
|
|
47
|
+
"description": "Envelope wire version (major.minor). Compatibility is enforced in code, not by this pattern."
|
|
48
|
+
},
|
|
49
|
+
"message_id": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"format": "uuid"
|
|
52
|
+
},
|
|
53
|
+
"channel_id": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"format": "uuid"
|
|
56
|
+
},
|
|
57
|
+
"enclave_id": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"format": "uuid"
|
|
60
|
+
},
|
|
61
|
+
"message_type": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": [
|
|
64
|
+
"text",
|
|
65
|
+
"decision_request",
|
|
66
|
+
"decision_response",
|
|
67
|
+
"status_alert",
|
|
68
|
+
"artifact_share",
|
|
69
|
+
"action_confirmation",
|
|
70
|
+
"heartbeat",
|
|
71
|
+
"system_event",
|
|
72
|
+
"a2a"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"sender_id": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"format": "uuid"
|
|
78
|
+
},
|
|
79
|
+
"sender_type": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"enum": [
|
|
82
|
+
"human",
|
|
83
|
+
"agent",
|
|
84
|
+
"guest"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"identity_proof": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"additionalProperties": false,
|
|
90
|
+
"required": [
|
|
91
|
+
"signature",
|
|
92
|
+
"algo",
|
|
93
|
+
"attested",
|
|
94
|
+
"canonicalization"
|
|
95
|
+
],
|
|
96
|
+
"properties": {
|
|
97
|
+
"signature": {
|
|
98
|
+
"$ref": "#/$defs/base64_nonempty"
|
|
99
|
+
},
|
|
100
|
+
"algo": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"enum": [
|
|
103
|
+
"ed25519",
|
|
104
|
+
"ecdsa_p384"
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
"attested": {
|
|
108
|
+
"type": "boolean"
|
|
109
|
+
},
|
|
110
|
+
"canonicalization": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"const": "RFC8785"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"trace_context": {
|
|
117
|
+
"type": "object",
|
|
118
|
+
"additionalProperties": false,
|
|
119
|
+
"required": [
|
|
120
|
+
"traceparent"
|
|
121
|
+
],
|
|
122
|
+
"properties": {
|
|
123
|
+
"traceparent": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"pattern": "^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$",
|
|
126
|
+
"description": "W3C TraceContext traceparent: version-traceid-spanid-flags."
|
|
127
|
+
},
|
|
128
|
+
"tracestate": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"maxLength": 512
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"policy_binding": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"additionalProperties": false,
|
|
137
|
+
"required": [
|
|
138
|
+
"binding_id",
|
|
139
|
+
"version",
|
|
140
|
+
"agent_skill_id",
|
|
141
|
+
"delegation_id",
|
|
142
|
+
"client_stage5_outcome",
|
|
143
|
+
"client_stage5_evaluators"
|
|
144
|
+
],
|
|
145
|
+
"properties": {
|
|
146
|
+
"binding_id": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"format": "uuid"
|
|
149
|
+
},
|
|
150
|
+
"version": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
|
153
|
+
"description": "Policy binding semver."
|
|
154
|
+
},
|
|
155
|
+
"agent_skill_id": {
|
|
156
|
+
"type": [
|
|
157
|
+
"string",
|
|
158
|
+
"null"
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
"delegation_id": {
|
|
162
|
+
"oneOf": [
|
|
163
|
+
{
|
|
164
|
+
"type": "string",
|
|
165
|
+
"format": "uuid"
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"type": "null"
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
"client_stage5_outcome": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"enum": [
|
|
175
|
+
"allow",
|
|
176
|
+
"flag"
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
"client_stage5_evaluators": {
|
|
180
|
+
"type": "array",
|
|
181
|
+
"items": {
|
|
182
|
+
"type": "string"
|
|
183
|
+
},
|
|
184
|
+
"maxItems": 64
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"mls_group_epoch": {
|
|
189
|
+
"type": "integer",
|
|
190
|
+
"minimum": 0,
|
|
191
|
+
"description": "Public DS-tracked epoch counter; the server rejects messages where this is below the channel's current epoch (mls.stale_epoch)."
|
|
192
|
+
},
|
|
193
|
+
"replay_nonce": {
|
|
194
|
+
"$ref": "#/$defs/base64_nonempty"
|
|
195
|
+
},
|
|
196
|
+
"payload_encrypted": {
|
|
197
|
+
"$ref": "#/$defs/base64"
|
|
198
|
+
},
|
|
199
|
+
"references": {
|
|
200
|
+
"type": "array",
|
|
201
|
+
"maxItems": 64,
|
|
202
|
+
"items": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"additionalProperties": false,
|
|
205
|
+
"required": [
|
|
206
|
+
"type",
|
|
207
|
+
"message_id"
|
|
208
|
+
],
|
|
209
|
+
"properties": {
|
|
210
|
+
"type": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"enum": [
|
|
213
|
+
"reply_to",
|
|
214
|
+
"recall",
|
|
215
|
+
"quote",
|
|
216
|
+
"reaction"
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
"message_id": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"format": "uuid"
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
"mentions": {
|
|
227
|
+
"type": "array",
|
|
228
|
+
"maxItems": 256,
|
|
229
|
+
"items": {
|
|
230
|
+
"type": "string",
|
|
231
|
+
"format": "uuid"
|
|
232
|
+
},
|
|
233
|
+
"description": "Optional plaintext mention-target identity ids for notification routing (FR-MSG-010). The @name display text lives in the encrypted payload; only the target ids are exposed as metadata so the server can fire mention notifications without seeing plaintext."
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
"$defs": {
|
|
237
|
+
"base64": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"pattern": "^$|^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
|
|
240
|
+
"description": "Standard base64 (RFC 4648 \xA74) with mod-4 block structure enforced (rejects lone '=', '==', and malformed padding like 'A='). Empty string permitted because payload_encrypted is opaque at this codec layer. Security-critical fields use base64_nonempty."
|
|
241
|
+
},
|
|
242
|
+
"base64_nonempty": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
|
|
245
|
+
"description": "Standard base64 (RFC 4648 \xA74), non-empty + mod-4 block structure. Used for replay_nonce and identity_proof.signature, which must always be present on a received (wire) envelope \u2014 an empty nonce defeats replay protection and an empty signature is an unsigned envelope."
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
var payloadsSchema = {
|
|
250
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
251
|
+
"$id": "https://agentvault.dev/connect/schemas/payloads-1.0.json",
|
|
252
|
+
"title": "ConnectPayloads",
|
|
253
|
+
"description": "Plaintext payload schemas per message_type, validated client-side BEFORE encryption and AFTER decryption (the edge never sees plaintext). v1.0 BASELINE: each schema requires its core field(s); additionalProperties is permitted because exact payload shapes firm up in Phase 1D/1E client + SDK work. The envelope schema (envelope.schema.json) is the strict, spec-pinned contract; these payloads are the evolving inner shapes.",
|
|
254
|
+
"$defs": {
|
|
255
|
+
"text": {
|
|
256
|
+
"type": "object",
|
|
257
|
+
"required": [
|
|
258
|
+
"body"
|
|
259
|
+
],
|
|
260
|
+
"properties": {
|
|
261
|
+
"body": {
|
|
262
|
+
"type": "string",
|
|
263
|
+
"maxLength": 1048576
|
|
264
|
+
},
|
|
265
|
+
"format": {
|
|
266
|
+
"type": "string",
|
|
267
|
+
"enum": [
|
|
268
|
+
"plain",
|
|
269
|
+
"markdown"
|
|
270
|
+
]
|
|
271
|
+
},
|
|
272
|
+
"mentions": {
|
|
273
|
+
"type": "array",
|
|
274
|
+
"items": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"format": "uuid"
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
"decision_request": {
|
|
282
|
+
"type": "object",
|
|
283
|
+
"required": [
|
|
284
|
+
"decision_id",
|
|
285
|
+
"prompt",
|
|
286
|
+
"options"
|
|
287
|
+
],
|
|
288
|
+
"properties": {
|
|
289
|
+
"decision_id": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"format": "uuid"
|
|
292
|
+
},
|
|
293
|
+
"prompt": {
|
|
294
|
+
"type": "string"
|
|
295
|
+
},
|
|
296
|
+
"options": {
|
|
297
|
+
"type": "array",
|
|
298
|
+
"items": {
|
|
299
|
+
"type": "string"
|
|
300
|
+
},
|
|
301
|
+
"minItems": 1
|
|
302
|
+
},
|
|
303
|
+
"deadline": {
|
|
304
|
+
"type": "string",
|
|
305
|
+
"format": "date-time"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"decision_response": {
|
|
310
|
+
"type": "object",
|
|
311
|
+
"required": [
|
|
312
|
+
"decision_id",
|
|
313
|
+
"choice"
|
|
314
|
+
],
|
|
315
|
+
"properties": {
|
|
316
|
+
"decision_id": {
|
|
317
|
+
"type": "string",
|
|
318
|
+
"format": "uuid"
|
|
319
|
+
},
|
|
320
|
+
"choice": {
|
|
321
|
+
"type": "string"
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
"status_alert": {
|
|
326
|
+
"type": "object",
|
|
327
|
+
"required": [
|
|
328
|
+
"severity",
|
|
329
|
+
"message"
|
|
330
|
+
],
|
|
331
|
+
"properties": {
|
|
332
|
+
"severity": {
|
|
333
|
+
"type": "string",
|
|
334
|
+
"enum": [
|
|
335
|
+
"info",
|
|
336
|
+
"warning",
|
|
337
|
+
"critical"
|
|
338
|
+
]
|
|
339
|
+
},
|
|
340
|
+
"message": {
|
|
341
|
+
"type": "string"
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
"artifact_share": {
|
|
346
|
+
"type": "object",
|
|
347
|
+
"required": [
|
|
348
|
+
"artifact_type",
|
|
349
|
+
"file_id"
|
|
350
|
+
],
|
|
351
|
+
"properties": {
|
|
352
|
+
"artifact_type": {
|
|
353
|
+
"type": "string"
|
|
354
|
+
},
|
|
355
|
+
"file_id": {
|
|
356
|
+
"type": "string",
|
|
357
|
+
"format": "uuid"
|
|
358
|
+
},
|
|
359
|
+
"filename": {
|
|
360
|
+
"type": "string"
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
"action_confirmation": {
|
|
365
|
+
"type": "object",
|
|
366
|
+
"required": [
|
|
367
|
+
"action"
|
|
368
|
+
],
|
|
369
|
+
"properties": {
|
|
370
|
+
"action": {
|
|
371
|
+
"type": "string"
|
|
372
|
+
},
|
|
373
|
+
"target_identity": {
|
|
374
|
+
"type": "string",
|
|
375
|
+
"format": "uuid"
|
|
376
|
+
},
|
|
377
|
+
"outcome": {
|
|
378
|
+
"type": "string",
|
|
379
|
+
"enum": [
|
|
380
|
+
"approved",
|
|
381
|
+
"denied",
|
|
382
|
+
"pending"
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
"heartbeat": {
|
|
388
|
+
"type": "object",
|
|
389
|
+
"properties": {
|
|
390
|
+
"client_time": {
|
|
391
|
+
"type": "string",
|
|
392
|
+
"format": "date-time"
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
"system_event": {
|
|
397
|
+
"type": "object",
|
|
398
|
+
"required": [
|
|
399
|
+
"event"
|
|
400
|
+
],
|
|
401
|
+
"properties": {
|
|
402
|
+
"event": {
|
|
403
|
+
"type": "string"
|
|
404
|
+
},
|
|
405
|
+
"detail": {
|
|
406
|
+
"type": "object"
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
"a2a": {
|
|
411
|
+
"type": "object",
|
|
412
|
+
"required": [
|
|
413
|
+
"a2a_version",
|
|
414
|
+
"kind",
|
|
415
|
+
"object"
|
|
416
|
+
],
|
|
417
|
+
"properties": {
|
|
418
|
+
"a2a_version": {
|
|
419
|
+
"type": "string"
|
|
420
|
+
},
|
|
421
|
+
"kind": {
|
|
422
|
+
"type": "string",
|
|
423
|
+
"enum": [
|
|
424
|
+
"message",
|
|
425
|
+
"task",
|
|
426
|
+
"artifact"
|
|
427
|
+
]
|
|
428
|
+
},
|
|
429
|
+
"object": {
|
|
430
|
+
"type": "object"
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
"agent_action": {
|
|
435
|
+
"description": 'The agent_action E2E message body (agent action tree). NOT a message_type \u2014 it rides envelope message_type "system_event"; this "type":"agent_action" field is a body-level discriminator. Two phases: partial (throttled, incremental upsert-by-id while an agent works) and settled (one authoritative full-tree frame, terminal for its runId). Note: this def is compiled standalone (see validate.ts validateAgentActionPayload), so its node sub-schema is nested under a LOCAL $defs (resolved relative to this def, not the sibling top-level $defs) rather than cross-referenced.',
|
|
436
|
+
"$defs": {
|
|
437
|
+
"node": {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"required": [
|
|
440
|
+
"id",
|
|
441
|
+
"type",
|
|
442
|
+
"title",
|
|
443
|
+
"status",
|
|
444
|
+
"ts"
|
|
445
|
+
],
|
|
446
|
+
"properties": {
|
|
447
|
+
"id": {
|
|
448
|
+
"type": "string"
|
|
449
|
+
},
|
|
450
|
+
"parentId": {
|
|
451
|
+
"type": "string"
|
|
452
|
+
},
|
|
453
|
+
"type": {
|
|
454
|
+
"type": "string",
|
|
455
|
+
"enum": [
|
|
456
|
+
"step",
|
|
457
|
+
"tool_call",
|
|
458
|
+
"sub_task",
|
|
459
|
+
"result"
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
"title": {
|
|
463
|
+
"type": "string"
|
|
464
|
+
},
|
|
465
|
+
"status": {
|
|
466
|
+
"type": "string",
|
|
467
|
+
"enum": [
|
|
468
|
+
"pending",
|
|
469
|
+
"running",
|
|
470
|
+
"done",
|
|
471
|
+
"error"
|
|
472
|
+
]
|
|
473
|
+
},
|
|
474
|
+
"detail": {
|
|
475
|
+
"type": "object",
|
|
476
|
+
"properties": {
|
|
477
|
+
"args": {},
|
|
478
|
+
"output": {},
|
|
479
|
+
"error": {
|
|
480
|
+
"type": "string"
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
"ts": {
|
|
485
|
+
"type": "number"
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
"oneOf": [
|
|
491
|
+
{
|
|
492
|
+
"type": "object",
|
|
493
|
+
"required": [
|
|
494
|
+
"v",
|
|
495
|
+
"type",
|
|
496
|
+
"phase",
|
|
497
|
+
"agentActionVersion",
|
|
498
|
+
"runId",
|
|
499
|
+
"nodes"
|
|
500
|
+
],
|
|
501
|
+
"properties": {
|
|
502
|
+
"v": {
|
|
503
|
+
"const": 1
|
|
504
|
+
},
|
|
505
|
+
"type": {
|
|
506
|
+
"const": "agent_action"
|
|
507
|
+
},
|
|
508
|
+
"phase": {
|
|
509
|
+
"const": "partial"
|
|
510
|
+
},
|
|
511
|
+
"agentActionVersion": {
|
|
512
|
+
"const": "1"
|
|
513
|
+
},
|
|
514
|
+
"runId": {
|
|
515
|
+
"type": "string"
|
|
516
|
+
},
|
|
517
|
+
"nodes": {
|
|
518
|
+
"type": "array",
|
|
519
|
+
"items": {
|
|
520
|
+
"$ref": "#/$defs/node"
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
"type": "object",
|
|
527
|
+
"required": [
|
|
528
|
+
"v",
|
|
529
|
+
"type",
|
|
530
|
+
"phase",
|
|
531
|
+
"agentActionVersion",
|
|
532
|
+
"runId",
|
|
533
|
+
"startedAt",
|
|
534
|
+
"endedAt",
|
|
535
|
+
"nodes"
|
|
536
|
+
],
|
|
537
|
+
"properties": {
|
|
538
|
+
"v": {
|
|
539
|
+
"const": 1
|
|
540
|
+
},
|
|
541
|
+
"type": {
|
|
542
|
+
"const": "agent_action"
|
|
543
|
+
},
|
|
544
|
+
"phase": {
|
|
545
|
+
"const": "settled"
|
|
546
|
+
},
|
|
547
|
+
"agentActionVersion": {
|
|
548
|
+
"const": "1"
|
|
549
|
+
},
|
|
550
|
+
"runId": {
|
|
551
|
+
"type": "string"
|
|
552
|
+
},
|
|
553
|
+
"startedAt": {
|
|
554
|
+
"type": "number"
|
|
555
|
+
},
|
|
556
|
+
"endedAt": {
|
|
557
|
+
"type": "number"
|
|
558
|
+
},
|
|
559
|
+
"resultMessageId": {
|
|
560
|
+
"type": "string"
|
|
561
|
+
},
|
|
562
|
+
"nodes": {
|
|
563
|
+
"type": "array",
|
|
564
|
+
"items": {
|
|
565
|
+
"$ref": "#/$defs/node"
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
]
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// ../connect-core/dist/envelope/validate.js
|
|
576
|
+
var Ajv2020 = _Ajv2020.default ?? _Ajv2020;
|
|
577
|
+
var addFormats = _addFormats.default ?? _addFormats;
|
|
578
|
+
var ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
579
|
+
addFormats(ajv);
|
|
580
|
+
var envelopeValidator = ajv.compile(envelopeSchema);
|
|
581
|
+
var payloadDefs = payloadsSchema.$defs;
|
|
582
|
+
var payloadValidators = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const t of MESSAGE_TYPES) {
|
|
584
|
+
const def = payloadDefs[t];
|
|
585
|
+
if (!def) {
|
|
586
|
+
throw new Error(`payloads.schema.json is missing a $defs schema for message_type "${t}"`);
|
|
587
|
+
}
|
|
588
|
+
payloadValidators.set(t, ajv.compile(def));
|
|
589
|
+
}
|
|
590
|
+
var agentActionDef = payloadDefs["agent_action"];
|
|
591
|
+
if (!agentActionDef) {
|
|
592
|
+
throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
|
|
593
|
+
}
|
|
594
|
+
var agentActionValidator = ajv.compile(agentActionDef);
|
|
595
|
+
|
|
596
|
+
// ../connect-core/dist/envelope/sign.js
|
|
597
|
+
import { canonicalize } from "@agentvault/crypto/canonical";
|
|
598
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
599
|
+
import { p384 } from "@noble/curves/p384";
|
|
600
|
+
|
|
601
|
+
// ../connect-core/dist/base64.js
|
|
602
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
603
|
+
var LOOKUP = (() => {
|
|
604
|
+
const t = new Int16Array(256).fill(-1);
|
|
605
|
+
for (let i = 0; i < ALPHABET.length; i++)
|
|
606
|
+
t[ALPHABET.charCodeAt(i)] = i;
|
|
607
|
+
return t;
|
|
608
|
+
})();
|
|
609
|
+
function bytesToBase64(bytes) {
|
|
610
|
+
let out = "";
|
|
611
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
612
|
+
const b0 = bytes[i];
|
|
613
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
614
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
615
|
+
out += ALPHABET[b0 >> 2];
|
|
616
|
+
out += ALPHABET[(b0 & 3) << 4 | b1 >> 4];
|
|
617
|
+
out += i + 1 < bytes.length ? ALPHABET[(b1 & 15) << 2 | b2 >> 6] : "=";
|
|
618
|
+
out += i + 2 < bytes.length ? ALPHABET[b2 & 63] : "=";
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
function base64ToBytes(s) {
|
|
623
|
+
const clean = s.replace(/=+$/, "");
|
|
624
|
+
const len = clean.length;
|
|
625
|
+
const outLen = Math.floor(len * 6 / 8);
|
|
626
|
+
const out = new Uint8Array(outLen);
|
|
627
|
+
let bits = 0;
|
|
628
|
+
let acc = 0;
|
|
629
|
+
let o = 0;
|
|
630
|
+
for (let i = 0; i < len; i++) {
|
|
631
|
+
const v = LOOKUP[clean.charCodeAt(i)];
|
|
632
|
+
if (v < 0)
|
|
633
|
+
throw new Error("base64ToBytes: invalid character");
|
|
634
|
+
acc = acc << 6 | v;
|
|
635
|
+
bits += 6;
|
|
636
|
+
if (bits >= 8) {
|
|
637
|
+
bits -= 8;
|
|
638
|
+
out[o++] = acc >> bits & 255;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ../connect-core/dist/envelope/sign.js
|
|
645
|
+
async function canonicalEnvelopeBytes(envelope) {
|
|
646
|
+
const { signature: _signature, ...proofWithoutSignature } = envelope.identity_proof;
|
|
647
|
+
const toSign = { ...envelope, identity_proof: proofWithoutSignature };
|
|
648
|
+
return canonicalize(toSign);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ../connect-core/dist/envelope/aead.js
|
|
652
|
+
import { gcm } from "@noble/ciphers/aes";
|
|
653
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
654
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
655
|
+
import { canonicalize as canonicalize2 } from "@agentvault/crypto/canonical";
|
|
656
|
+
var APPLICATION_KEY_BYTES = 32;
|
|
657
|
+
var REPLAY_NONCE_BYTES = 24;
|
|
658
|
+
var NONCE_BYTES = {
|
|
659
|
+
xchacha20poly1305: 24,
|
|
660
|
+
aes256gcm: 12
|
|
661
|
+
};
|
|
662
|
+
function concatBytes(...parts) {
|
|
663
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
664
|
+
const out = new Uint8Array(total);
|
|
665
|
+
let off = 0;
|
|
666
|
+
for (const p of parts) {
|
|
667
|
+
out.set(p, off);
|
|
668
|
+
off += p.length;
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
function epochToBytes(epoch) {
|
|
673
|
+
const b = new Uint8Array(8);
|
|
674
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(epoch), false);
|
|
675
|
+
return b;
|
|
676
|
+
}
|
|
677
|
+
function buildExporterContext(enclaveId, channelId, epoch) {
|
|
678
|
+
const enc = new TextEncoder();
|
|
679
|
+
return concatBytes(enc.encode(enclaveId), enc.encode(channelId), epochToBytes(epoch));
|
|
680
|
+
}
|
|
681
|
+
function cipherFor(suite, key, nonce, aad) {
|
|
682
|
+
return suite === "aes256gcm" ? gcm(key, nonce, aad) : xchacha20poly1305(key, nonce, aad);
|
|
683
|
+
}
|
|
684
|
+
function assertKey(key) {
|
|
685
|
+
if (key.length !== APPLICATION_KEY_BYTES) {
|
|
686
|
+
throw new Error(`application key must be ${APPLICATION_KEY_BYTES} bytes (the MLS exporter output); got ${key.length}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function sealPayload(p) {
|
|
690
|
+
assertKey(p.key);
|
|
691
|
+
const nonce = p.nonce ?? randomBytes(NONCE_BYTES[p.suite]);
|
|
692
|
+
const ciphertext = cipherFor(p.suite, p.key, nonce, p.associatedData).encrypt(p.plaintext);
|
|
693
|
+
return concatBytes(nonce, ciphertext);
|
|
694
|
+
}
|
|
695
|
+
function openPayload(p) {
|
|
696
|
+
assertKey(p.key);
|
|
697
|
+
const nlen = NONCE_BYTES[p.suite];
|
|
698
|
+
if (p.ciphertext.length < nlen) {
|
|
699
|
+
throw new Error("openPayload: ciphertext shorter than the AEAD nonce");
|
|
700
|
+
}
|
|
701
|
+
const nonce = p.ciphertext.subarray(0, nlen);
|
|
702
|
+
const body = p.ciphertext.subarray(nlen);
|
|
703
|
+
return cipherFor(p.suite, p.key, nonce, p.associatedData).decrypt(body);
|
|
704
|
+
}
|
|
705
|
+
async function buildAssociatedData(fields) {
|
|
706
|
+
return canonicalize2({
|
|
707
|
+
message_id: fields.message_id,
|
|
708
|
+
channel_id: fields.channel_id,
|
|
709
|
+
sender_id: fields.sender_id,
|
|
710
|
+
mls_group_epoch: fields.mls_group_epoch,
|
|
711
|
+
replay_nonce: fields.replay_nonce,
|
|
712
|
+
policy_binding: fields.policy_binding
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
function generateReplayNonce() {
|
|
716
|
+
return bytesToBase64(randomBytes(REPLAY_NONCE_BYTES));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ../connect-core/dist/sdk-dto/validate.js
|
|
720
|
+
import _Ajv20202 from "ajv/dist/2020.js";
|
|
721
|
+
import _addFormats2 from "ajv-formats";
|
|
722
|
+
|
|
723
|
+
// ../connect-core/dist/sdk-dto/schemas.generated.js
|
|
724
|
+
var modelDescriptorSchema = {
|
|
725
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
726
|
+
"$id": "agentvault.connect.sdk.model_descriptor",
|
|
727
|
+
"type": "object",
|
|
728
|
+
"additionalProperties": false,
|
|
729
|
+
"required": [
|
|
730
|
+
"provider",
|
|
731
|
+
"name"
|
|
732
|
+
],
|
|
733
|
+
"properties": {
|
|
734
|
+
"provider": {
|
|
735
|
+
"type": "string",
|
|
736
|
+
"minLength": 1
|
|
737
|
+
},
|
|
738
|
+
"name": {
|
|
739
|
+
"type": "string",
|
|
740
|
+
"minLength": 1
|
|
741
|
+
},
|
|
742
|
+
"version": {
|
|
743
|
+
"type": "string"
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
var registerSchema = {
|
|
748
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
749
|
+
"$id": "agentvault.connect.sdk.register_request",
|
|
750
|
+
"type": "object",
|
|
751
|
+
"additionalProperties": false,
|
|
752
|
+
"required": [
|
|
753
|
+
"skill_id",
|
|
754
|
+
"public_key",
|
|
755
|
+
"algo",
|
|
756
|
+
"model"
|
|
757
|
+
],
|
|
758
|
+
"properties": {
|
|
759
|
+
"skill_id": {
|
|
760
|
+
"type": "string",
|
|
761
|
+
"minLength": 1
|
|
762
|
+
},
|
|
763
|
+
"public_key": {
|
|
764
|
+
"type": "string",
|
|
765
|
+
"minLength": 1
|
|
766
|
+
},
|
|
767
|
+
"algo": {
|
|
768
|
+
"type": "string",
|
|
769
|
+
"enum": [
|
|
770
|
+
"ed25519",
|
|
771
|
+
"ecdsa_p384"
|
|
772
|
+
]
|
|
773
|
+
},
|
|
774
|
+
"attested": {
|
|
775
|
+
"type": "boolean"
|
|
776
|
+
},
|
|
777
|
+
"model": {
|
|
778
|
+
"$ref": "agentvault.connect.sdk.model_descriptor"
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
var auditQuerySchema = {
|
|
783
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
784
|
+
"$id": "agentvault.connect.sdk.audit_query",
|
|
785
|
+
"type": "object",
|
|
786
|
+
"additionalProperties": false,
|
|
787
|
+
"required": [
|
|
788
|
+
"agent_id"
|
|
789
|
+
],
|
|
790
|
+
"properties": {
|
|
791
|
+
"agent_id": {
|
|
792
|
+
"type": "string",
|
|
793
|
+
"format": "uuid"
|
|
794
|
+
},
|
|
795
|
+
"from": {
|
|
796
|
+
"type": "string",
|
|
797
|
+
"format": "date-time"
|
|
798
|
+
},
|
|
799
|
+
"to": {
|
|
800
|
+
"type": "string",
|
|
801
|
+
"format": "date-time"
|
|
802
|
+
},
|
|
803
|
+
"channel_id": {
|
|
804
|
+
"type": "string",
|
|
805
|
+
"format": "uuid"
|
|
806
|
+
},
|
|
807
|
+
"message_type": {
|
|
808
|
+
"type": "string"
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
// ../connect-core/dist/sdk-dto/validate.js
|
|
814
|
+
var Ajv20202 = _Ajv20202.default ?? _Ajv20202;
|
|
815
|
+
var addFormats2 = _addFormats2.default ?? _addFormats2;
|
|
816
|
+
var ajv2 = new Ajv20202({ allErrors: true, strict: true });
|
|
817
|
+
addFormats2(ajv2);
|
|
818
|
+
ajv2.addSchema(modelDescriptorSchema, "agentvault.connect.sdk.model_descriptor");
|
|
819
|
+
var modelValidator = ajv2.getSchema("agentvault.connect.sdk.model_descriptor");
|
|
820
|
+
var registerValidator = ajv2.compile(registerSchema);
|
|
821
|
+
var auditQueryValidator = ajv2.compile(auditQuerySchema);
|
|
822
|
+
|
|
823
|
+
// ../connect-clients-core/dist/stage5/evaluators.js
|
|
824
|
+
var ALLOW = { outcome: "allow" };
|
|
825
|
+
var PHI_PATTERNS = [/\b\d{3}-\d{2}-\d{4}\b/, /\bMRN[:#]?\s*\d{6,}\b/i];
|
|
826
|
+
var IP_PATTERNS = [
|
|
827
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
828
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
829
|
+
/\bconfidential\b/i
|
|
830
|
+
];
|
|
831
|
+
var RELEASABILITY_PATTERNS = [/\bNOFORN\b/, /\bdo not (?:forward|share)\b/i];
|
|
832
|
+
function patternEvaluator(id, patterns, reason) {
|
|
833
|
+
return {
|
|
834
|
+
id,
|
|
835
|
+
async evaluate(plaintext) {
|
|
836
|
+
return patterns.some((p) => p.test(plaintext)) ? { outcome: "flag", reason } : ALLOW;
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
var phiEvaluator = patternEvaluator("phi", PHI_PATTERNS, "phi.detected");
|
|
841
|
+
var ipEvaluator = patternEvaluator("ip", IP_PATTERNS, "ip.detected");
|
|
842
|
+
var releasabilityEvaluator = patternEvaluator("releasability", RELEASABILITY_PATTERNS, "releasability.hint");
|
|
843
|
+
|
|
844
|
+
// ../connect-sdk-ts/dist/custody/software.js
|
|
845
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
846
|
+
import { p384 as p3842 } from "@noble/curves/p384";
|
|
847
|
+
var SoftwareKeyCustody = class _SoftwareKeyCustody {
|
|
848
|
+
algo;
|
|
849
|
+
priv;
|
|
850
|
+
pub;
|
|
851
|
+
attested = false;
|
|
852
|
+
constructor(algo, priv, pub, quiet = false) {
|
|
853
|
+
this.algo = algo;
|
|
854
|
+
this.priv = priv;
|
|
855
|
+
this.pub = pub;
|
|
856
|
+
if (!quiet) {
|
|
857
|
+
console.warn("[AgentVault] using a software key (dev/CI only) -- envelopes are not attested. Use kms-hosted or byok custody in production.");
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
static generate(algo) {
|
|
861
|
+
if (algo === "ed25519") {
|
|
862
|
+
const priv2 = ed255192.utils.randomPrivateKey();
|
|
863
|
+
return new _SoftwareKeyCustody(algo, priv2, ed255192.getPublicKey(priv2));
|
|
864
|
+
}
|
|
865
|
+
const priv = p3842.utils.randomPrivateKey();
|
|
866
|
+
return new _SoftwareKeyCustody(algo, priv, p3842.getPublicKey(priv));
|
|
867
|
+
}
|
|
868
|
+
/** Construct from an existing private key (e.g. a CI fixture).
|
|
869
|
+
* `quiet` suppresses the dev banner for browser flows (start-fresh) where
|
|
870
|
+
* the software key IS the production custody and the warning is noise. */
|
|
871
|
+
static fromPrivateKey(algo, priv, opts) {
|
|
872
|
+
const pub = algo === "ed25519" ? ed255192.getPublicKey(priv) : p3842.getPublicKey(priv);
|
|
873
|
+
return new _SoftwareKeyCustody(algo, priv, pub, opts?.quiet === true);
|
|
874
|
+
}
|
|
875
|
+
async sign(canonicalBytes) {
|
|
876
|
+
if (this.algo === "ed25519")
|
|
877
|
+
return ed255192.sign(canonicalBytes, this.priv);
|
|
878
|
+
return p3842.sign(canonicalBytes, this.priv, { prehash: true }).toCompactRawBytes();
|
|
879
|
+
}
|
|
880
|
+
publicKey() {
|
|
881
|
+
return this.pub;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// ../connect-sdk-ts/dist/errors.js
|
|
886
|
+
var ConnectSdkError = class extends Error {
|
|
887
|
+
constructor(message) {
|
|
888
|
+
super(message);
|
|
889
|
+
this.name = new.target.name;
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
var RegistrationError = class extends ConnectSdkError {
|
|
893
|
+
};
|
|
894
|
+
var RateLimitError = class extends ConnectSdkError {
|
|
895
|
+
retryAfterSec;
|
|
896
|
+
constructor(message, retryAfterSec) {
|
|
897
|
+
super(message);
|
|
898
|
+
this.retryAfterSec = retryAfterSec;
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
var AuditAccessDenied = class extends ConnectSdkError {
|
|
902
|
+
};
|
|
903
|
+
var StaleEpochError = class extends ConnectSdkError {
|
|
904
|
+
clientEpoch;
|
|
905
|
+
serverEpoch;
|
|
906
|
+
constructor(clientEpoch, serverEpoch) {
|
|
907
|
+
super(`mls.stale_epoch: client_epoch=${clientEpoch} server_epoch=${serverEpoch}`);
|
|
908
|
+
this.clientEpoch = clientEpoch;
|
|
909
|
+
this.serverEpoch = serverEpoch;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
async function staleEpochErrorFromResponse(res) {
|
|
913
|
+
if (res.status !== 400)
|
|
914
|
+
return null;
|
|
915
|
+
let body;
|
|
916
|
+
try {
|
|
917
|
+
body = await res.json();
|
|
918
|
+
} catch {
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
const detail = body?.detail;
|
|
922
|
+
const d = detail;
|
|
923
|
+
if (!d || d.error !== "mls.stale_epoch")
|
|
924
|
+
return null;
|
|
925
|
+
return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
929
|
+
async function buildOutboundEnvelope(p) {
|
|
930
|
+
const suite = p.suite ?? p.groupKey.suite;
|
|
931
|
+
const key = await p.groupKey.exporterKey(p.enclaveId, p.channelId, p.epoch);
|
|
932
|
+
const replayNonce = p.replayNonce ?? generateReplayNonce();
|
|
933
|
+
const policyBinding = {
|
|
934
|
+
binding_id: p.bindingId,
|
|
935
|
+
version: p.bindingVersion,
|
|
936
|
+
agent_skill_id: p.agentSkillId,
|
|
937
|
+
delegation_id: null,
|
|
938
|
+
client_stage5_outcome: p.stage5Outcome,
|
|
939
|
+
client_stage5_evaluators: p.stage5Evaluators
|
|
940
|
+
};
|
|
941
|
+
const aad = await buildAssociatedData({
|
|
942
|
+
message_id: p.messageId,
|
|
943
|
+
channel_id: p.channelId,
|
|
944
|
+
sender_id: p.senderId,
|
|
945
|
+
mls_group_epoch: p.epoch,
|
|
946
|
+
replay_nonce: replayNonce,
|
|
947
|
+
policy_binding: policyBinding
|
|
948
|
+
});
|
|
949
|
+
const sealed = sealPayload({
|
|
950
|
+
plaintext: new TextEncoder().encode(p.bodyPlaintext),
|
|
951
|
+
key,
|
|
952
|
+
associatedData: aad,
|
|
953
|
+
suite,
|
|
954
|
+
nonce: p.aeadNonce
|
|
955
|
+
});
|
|
956
|
+
const envelope = {
|
|
957
|
+
av_version: AV_ENVELOPE_VERSION,
|
|
958
|
+
message_id: p.messageId,
|
|
959
|
+
channel_id: p.channelId,
|
|
960
|
+
enclave_id: p.enclaveId,
|
|
961
|
+
message_type: p.type,
|
|
962
|
+
sender_id: p.senderId,
|
|
963
|
+
sender_type: p.senderType,
|
|
964
|
+
identity_proof: {
|
|
965
|
+
signature: "",
|
|
966
|
+
algo: p.custody.algo,
|
|
967
|
+
attested: p.custody.attested,
|
|
968
|
+
canonicalization: "RFC8785"
|
|
969
|
+
},
|
|
970
|
+
trace_context: { traceparent: p.traceparent },
|
|
971
|
+
policy_binding: policyBinding,
|
|
972
|
+
mls_group_epoch: p.epoch,
|
|
973
|
+
replay_nonce: replayNonce,
|
|
974
|
+
payload_encrypted: bytesToBase64(sealed),
|
|
975
|
+
references: p.references
|
|
976
|
+
};
|
|
977
|
+
if (p.mentions && p.mentions.length > 0)
|
|
978
|
+
envelope.mentions = p.mentions;
|
|
979
|
+
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
980
|
+
envelope.identity_proof.signature = bytesToBase64(await p.custody.sign(canonical));
|
|
981
|
+
return envelope;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ../connect-sdk-ts/dist/groupkey/send-self-heal.js
|
|
985
|
+
async function sendWithSelfHeal(session, channelId, build, publish) {
|
|
986
|
+
await session.reconcileForSend(channelId);
|
|
987
|
+
const epoch = await session.currentEpoch(channelId);
|
|
988
|
+
const envelope = await build(epoch);
|
|
989
|
+
try {
|
|
990
|
+
return await publish(envelope);
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (!(err instanceof StaleEpochError))
|
|
993
|
+
throw err;
|
|
994
|
+
await session.reconcileForSend(channelId);
|
|
995
|
+
const retryEpoch = await session.currentEpoch(channelId);
|
|
996
|
+
const retryEnvelope = await build(retryEpoch);
|
|
997
|
+
return await publish(retryEnvelope);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ../connect-sdk-ts/dist/groupkey/stub.js
|
|
1002
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
1003
|
+
|
|
1004
|
+
// ../connect-sdk-ts/dist/spans/names.js
|
|
1005
|
+
var SPAN = {
|
|
1006
|
+
MESSAGE_SENT: "agentvault.connect.message.sent",
|
|
1007
|
+
MESSAGE_QUEUED_OFFLINE: "agentvault.connect.message.queued_offline",
|
|
1008
|
+
POLICY_RESCAN: "agentvault.connect.policy.rescan",
|
|
1009
|
+
POLICY_EVAL_DELAYED_FLAG: "agentvault.connect.policy.eval.delayed_flag",
|
|
1010
|
+
AUDIT_QUERY: "agentvault.connect.audit.query",
|
|
1011
|
+
AGENT_REGISTERED: "agentvault.connect.agent.registered",
|
|
1012
|
+
AGENT_JOINED: "agentvault.connect.agent.joined",
|
|
1013
|
+
AGENT_CAPABILITIES_DECLARED: "agentvault.connect.agent.capabilities_declared",
|
|
1014
|
+
AGENT_CAPABILITY_INVOKED: "agentvault.connect.agent.capability_invoked",
|
|
1015
|
+
AGENT_TOOL_CALLED: "agentvault.connect.agent.tool_called",
|
|
1016
|
+
ACTION_CONFIRMATION_REQUESTED: "agentvault.connect.action_confirmation.requested",
|
|
1017
|
+
ACTION_CONFIRMATION_RECEIVED: "agentvault.connect.action_confirmation.received",
|
|
1018
|
+
MODEL_DESCRIPTOR_CHANGED: "agentvault.connect.model.descriptor_changed",
|
|
1019
|
+
AGENT_STATUS_REPORTED: "agentvault.connect.agent.status_reported"
|
|
1020
|
+
};
|
|
1021
|
+
var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
|
|
1022
|
+
SPAN.AGENT_REGISTERED,
|
|
1023
|
+
SPAN.ACTION_CONFIRMATION_REQUESTED,
|
|
1024
|
+
SPAN.ACTION_CONFIRMATION_RECEIVED
|
|
1025
|
+
]);
|
|
1026
|
+
|
|
1027
|
+
// ../connect-sdk-ts/dist/spans/emitter.js
|
|
1028
|
+
var KNOWN = new Set(Object.values(SPAN));
|
|
1029
|
+
|
|
1030
|
+
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1031
|
+
import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
|
|
1032
|
+
|
|
1033
|
+
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
1034
|
+
var MlsOperationQueue = class {
|
|
1035
|
+
chains = /* @__PURE__ */ new Map();
|
|
1036
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1037
|
+
async run(channelId, op) {
|
|
1038
|
+
const prev = this.chains.get(channelId) ?? Promise.resolve();
|
|
1039
|
+
const next = prev.then(op, op);
|
|
1040
|
+
this.chains.set(channelId, next);
|
|
1041
|
+
this.inFlight.set(channelId, (this.inFlight.get(channelId) ?? 0) + 1);
|
|
1042
|
+
try {
|
|
1043
|
+
return await next;
|
|
1044
|
+
} finally {
|
|
1045
|
+
const remaining = (this.inFlight.get(channelId) ?? 1) - 1;
|
|
1046
|
+
if (remaining === 0) {
|
|
1047
|
+
this.inFlight.delete(channelId);
|
|
1048
|
+
if (this.chains.get(channelId) === next) {
|
|
1049
|
+
this.chains.delete(channelId);
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
this.inFlight.set(channelId, remaining);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/** Test / diagnostic only. Number of in-flight + pending ops for a channel. */
|
|
1057
|
+
chainSize(channelId) {
|
|
1058
|
+
return this.inFlight.get(channelId) ?? 0;
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
1063
|
+
var LABEL = "agentvault-connect-v1";
|
|
1064
|
+
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
1065
|
+
var BootstrapRaceLostError = class extends Error {
|
|
1066
|
+
constructor() {
|
|
1067
|
+
super("bootstrap race lost: another device created the group; join instead");
|
|
1068
|
+
this.name = "BootstrapRaceLostError";
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
var LeafRosterMismatchError = class extends Error {
|
|
1072
|
+
constructor() {
|
|
1073
|
+
super("removeDeviceFromLiveGroup: leaf/roster length mismatch (stale roster \u2014 re-fetch and retry)");
|
|
1074
|
+
this.name = "LeafRosterMismatchError";
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
var HealAddAfterRemoveError = class extends Error {
|
|
1078
|
+
constructor(cause) {
|
|
1079
|
+
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
1080
|
+
this.name = "HealAddAfterRemoveError";
|
|
1081
|
+
this.cause = cause;
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
var GuestHealStaleKpError = class extends Error {
|
|
1085
|
+
constructor(kpId) {
|
|
1086
|
+
super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
|
|
1087
|
+
this.name = "GuestHealStaleKpError";
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
var ForgedCredentialError = class extends Error {
|
|
1091
|
+
constructor(expected, actual) {
|
|
1092
|
+
super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
|
|
1093
|
+
this.name = "ForgedCredentialError";
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
var MlsGroupKeyProvider = class {
|
|
1097
|
+
o;
|
|
1098
|
+
suite;
|
|
1099
|
+
managers = /* @__PURE__ */ new Map();
|
|
1100
|
+
/**
|
|
1101
|
+
* Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
|
|
1102
|
+
* manager-mutating operation on a channel takes this lock keyed by channelId,
|
|
1103
|
+
* so at most one runs per channel at a time; different channels still run in
|
|
1104
|
+
* parallel. This is the fix for the manager-mutation interleave Sigil's review
|
|
1105
|
+
* of the self-heal-on-send fix surfaced: the device-wide receive pump, the
|
|
1106
|
+
* committer ops, and reconcileForSend could all mutate one channel's cached
|
|
1107
|
+
* MLSGroupManager concurrently.
|
|
1108
|
+
*
|
|
1109
|
+
* Granularity (load-bearing): receiveOnce is DEVICE-wide — it pulls one queue
|
|
1110
|
+
* and routes commit/application rows to MANY channels' managers. It therefore
|
|
1111
|
+
* takes the lock PER ROW, keyed by that row's channel, rather than one
|
|
1112
|
+
* provider-wide lock — so a commit on channel A never serializes against a
|
|
1113
|
+
* decrypt on channel B. The committer ops take the lock for their WHOLE
|
|
1114
|
+
* resolve→commit→distribute→persist window. Because the lock is not reentrant,
|
|
1115
|
+
* the committers' own conflict-recovery drain runs the UNLOCKED core
|
|
1116
|
+
* (receiveOnceUnlocked, scoped to the committer's own channel — it already
|
|
1117
|
+
* holds that channel's lock).
|
|
1118
|
+
*/
|
|
1119
|
+
locks = new MlsOperationQueue();
|
|
1120
|
+
/** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
|
|
1121
|
+
* guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
|
|
1122
|
+
* both pass "no pending bundle", both POST, and orphan the first bundle when the
|
|
1123
|
+
* server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
|
|
1124
|
+
* from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
|
|
1125
|
+
publishLock = new MlsOperationQueue();
|
|
1126
|
+
/**
|
|
1127
|
+
* Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
|
|
1128
|
+
* of channels this device touches (channels are bounded; an entry is created
|
|
1129
|
+
* only on a real decrypt error), so it cannot grow without limit. Read via
|
|
1130
|
+
* {@link errorStats}; mutated only through {@link recordDecryptError} /
|
|
1131
|
+
* {@link recordDecryptSuccess}.
|
|
1132
|
+
*/
|
|
1133
|
+
errors = /* @__PURE__ */ new Map();
|
|
1134
|
+
stuckThreshold;
|
|
1135
|
+
/** Cumulative count of past-epoch application rows permanently skipped (acked
|
|
1136
|
+
* and drained) because forward-secrecy has deleted their epoch's secrets. Read
|
|
1137
|
+
* via {@link permanentlySkippedCount}. */
|
|
1138
|
+
permanentlySkipped = 0;
|
|
1139
|
+
constructor(o) {
|
|
1140
|
+
this.o = o;
|
|
1141
|
+
this.suite = o.suite ?? "xchacha20poly1305";
|
|
1142
|
+
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
1143
|
+
}
|
|
1144
|
+
get deviceId() {
|
|
1145
|
+
return this.o.deviceId;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Count a decrypt/parse failure against a channel and fire the optional
|
|
1149
|
+
* onDecryptError hook. Increments BOTH the cumulative total and the
|
|
1150
|
+
* consecutive-since-last-success counter (the latter drives `stuck`). A row
|
|
1151
|
+
* with no channel mapping is still surfaced via the hook but not counted
|
|
1152
|
+
* against any channel (we cannot attribute it). Always called from inside the
|
|
1153
|
+
* row's catch — never changes decrypt/convergence behavior.
|
|
1154
|
+
*/
|
|
1155
|
+
recordDecryptError(row, error) {
|
|
1156
|
+
this.o.onDecryptError?.(row, error);
|
|
1157
|
+
const cid = row.connect_channel_id;
|
|
1158
|
+
if (!cid)
|
|
1159
|
+
return;
|
|
1160
|
+
const e = this.errors.get(cid) ?? { decryptErrors: 0, consecutiveErrors: 0, lastErrorAt: null };
|
|
1161
|
+
e.decryptErrors += 1;
|
|
1162
|
+
e.consecutiveErrors += 1;
|
|
1163
|
+
e.lastErrorAt = Date.now();
|
|
1164
|
+
this.errors.set(cid, e);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* A commit/application row for the channel decrypted cleanly: the channel is
|
|
1168
|
+
* advancing, so clear the consecutive-error run (un-sticks it). Leaves the
|
|
1169
|
+
* cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
|
|
1170
|
+
* that never errored.
|
|
1171
|
+
*/
|
|
1172
|
+
recordDecryptSuccess(channelId) {
|
|
1173
|
+
const e = this.errors.get(channelId);
|
|
1174
|
+
if (e && e.consecutiveErrors !== 0)
|
|
1175
|
+
e.consecutiveErrors = 0;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
1179
|
+
* Surfaces a wedged channel that would otherwise "look idle": the daemon serves
|
|
1180
|
+
* it on GET /v1/metrics and the web shell reads it via a selector. A channel is
|
|
1181
|
+
* `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
|
|
1182
|
+
* intervening successful decrypt. Returns a plain object keyed by channelId;
|
|
1183
|
+
* absence of a key means zero errors observed for that channel.
|
|
1184
|
+
*/
|
|
1185
|
+
errorStats() {
|
|
1186
|
+
const out = {};
|
|
1187
|
+
for (const [cid, e] of this.errors) {
|
|
1188
|
+
out[cid] = {
|
|
1189
|
+
decryptErrors: e.decryptErrors,
|
|
1190
|
+
consecutiveErrors: e.consecutiveErrors,
|
|
1191
|
+
lastErrorAt: e.lastErrorAt,
|
|
1192
|
+
stuck: e.consecutiveErrors >= this.stuckThreshold
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
return out;
|
|
1196
|
+
}
|
|
1197
|
+
/** Total past-epoch rows permanently skipped (acked without decrypting) since
|
|
1198
|
+
* this provider was instantiated. A permanently-undecryptable row is one whose
|
|
1199
|
+
* `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
|
|
1200
|
+
* has deleted its epoch secrets so it can never decrypt on any retry. */
|
|
1201
|
+
permanentlySkippedCount() {
|
|
1202
|
+
return this.permanentlySkipped;
|
|
1203
|
+
}
|
|
1204
|
+
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1205
|
+
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1206
|
+
* not enough on its own, because a warm manager keeps decrypting until the
|
|
1207
|
+
* page reloads. The persisted store record is removed separately by the
|
|
1208
|
+
* caller; this clears the matching in-memory cache. `managers` is the only
|
|
1209
|
+
* per-channel in-memory cache the provider holds. */
|
|
1210
|
+
evictChannelState(channelId) {
|
|
1211
|
+
this.managers.delete(channelId);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Warm a channel's group manager so a commit can run even when the room isn't
|
|
1215
|
+
* currently open. Used by the guest reconcile sweep (and slice-3 bell-approve):
|
|
1216
|
+
* the sweep may fire for a channel the host hasn't opened this session, so the
|
|
1217
|
+
* in-memory manager is cold even though the store holds usable state.
|
|
1218
|
+
*
|
|
1219
|
+
* If the store has persisted state, load it into the warm in-memory cache (via
|
|
1220
|
+
* the private manager() loader). Otherwise fall through to the join path
|
|
1221
|
+
* (ensureSession bootstrap:false), which consumes a queued Welcome. Idempotent:
|
|
1222
|
+
* a second call on an already-warm channel is a cheap no-op (manager() returns
|
|
1223
|
+
* the cached manager and ensureSession early-returns on existing store state).
|
|
1224
|
+
*/
|
|
1225
|
+
async ensureWarm(channelId, enclaveId) {
|
|
1226
|
+
if (await this.o.store.load(channelId)) {
|
|
1227
|
+
await this.locks.run(channelId, async () => {
|
|
1228
|
+
await this.manager(channelId);
|
|
1229
|
+
});
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
await this.ensureSession(channelId, { enclaveId, bootstrap: false });
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Admit a guest's (possibly new) device into a live group, evicting any prior
|
|
1236
|
+
* device leaf for that SAME guest identity so a returning guest holds exactly
|
|
1237
|
+
* ONE active leaf. Used by the guest reconcile sweep when an approved guest
|
|
1238
|
+
* sits in `joining`.
|
|
1239
|
+
*
|
|
1240
|
+
* Add then evict: we commit the Add first so the new leaf exists before the
|
|
1241
|
+
* old one is dropped (the guest is never momentarily leaf-less in the group).
|
|
1242
|
+
* The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
|
|
1243
|
+
* which resolves the target leaf POSITIONALLY by add-order. At the moment of
|
|
1244
|
+
* eviction the identity owns TWO leaves (prior + new), so the ordered-device
|
|
1245
|
+
* list MUST name both in add-order — the prior device was added first, the new
|
|
1246
|
+
* one just now — or the SDK's leaf/roster guard throws LeafRosterMismatchError.
|
|
1247
|
+
* Callers that hold the real channel roster may override `orderedDeviceIds`;
|
|
1248
|
+
* the default `[priorDeviceId, newDeviceId]` is the natural add-order for the
|
|
1249
|
+
* common single-prior-device return.
|
|
1250
|
+
*
|
|
1251
|
+
* No-op eviction when there is no priorDeviceId (first-time guest) or when it
|
|
1252
|
+
* equals newDeviceId (the same device re-admitted).
|
|
1253
|
+
*/
|
|
1254
|
+
async admitGuestDevice(channelId, g) {
|
|
1255
|
+
await this.addMemberToLiveGroup(channelId, g.newDeviceId);
|
|
1256
|
+
if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1257
|
+
const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
|
|
1258
|
+
await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
|
|
1259
|
+
identity: g.identityId,
|
|
1260
|
+
orderedDeviceIds
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/** Load (or reuse) the per-channel manager from the store. Throws if no state. */
|
|
1265
|
+
async manager(channelId) {
|
|
1266
|
+
const cached = this.managers.get(channelId);
|
|
1267
|
+
if (cached)
|
|
1268
|
+
return cached;
|
|
1269
|
+
const saved = await this.o.store.load(channelId);
|
|
1270
|
+
if (!saved)
|
|
1271
|
+
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1272
|
+
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
1273
|
+
mgr.importState(saved);
|
|
1274
|
+
this.managers.set(channelId, mgr);
|
|
1275
|
+
return mgr;
|
|
1276
|
+
}
|
|
1277
|
+
async currentEpoch(channelId) {
|
|
1278
|
+
return Number((await this.manager(channelId)).epoch);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Run a committer op and, on ANY throw that escapes it, evict the channel's
|
|
1282
|
+
* cached MLSGroupManager.
|
|
1283
|
+
*
|
|
1284
|
+
* The committer ops mutate the CACHED manager (mgr.addMembers / removeMembers
|
|
1285
|
+
* advance it) and persist the advanced state only AFTER a successful
|
|
1286
|
+
* distribute. The distribute-catch already evicts on a 409. But if the
|
|
1287
|
+
* mutation itself throws — or the B11 epoch-drift guard throws after the
|
|
1288
|
+
* mutation advanced the manager — the cached manager is left partially/fully
|
|
1289
|
+
* mutated and never persisted. The per-channel queue would then run the next
|
|
1290
|
+
* serialized op against that SAME poisoned cache entry (manager() returns the
|
|
1291
|
+
* cache before touching the store). Dropping the cache entry forces the next
|
|
1292
|
+
* manager() to reload a clean instance from the committed store state.
|
|
1293
|
+
*
|
|
1294
|
+
* Always called from INSIDE the channel lock, so the eviction can't race a
|
|
1295
|
+
* concurrent op on the same channel. Idempotent with the distribute-catch's own
|
|
1296
|
+
* delete (a re-delete is a no-op). On success the manager stays cached as
|
|
1297
|
+
* before — no eviction.
|
|
1298
|
+
*/
|
|
1299
|
+
async evictOnThrow(channelId, op) {
|
|
1300
|
+
try {
|
|
1301
|
+
return await op();
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
this.managers.delete(channelId);
|
|
1304
|
+
throw e;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
async exporterKey(enclaveId, channelId, epoch) {
|
|
1308
|
+
const mgr = await this.manager(channelId);
|
|
1309
|
+
return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Join a channel from the host-committed Welcome, then drain any commits that
|
|
1313
|
+
* landed after the Welcome so we converge on the true current epoch. Without
|
|
1314
|
+
* this, confirm-join 409s `member_epoch_stale` whenever the committer advanced
|
|
1315
|
+
* the epoch between Welcome generation and our join.
|
|
1316
|
+
*/
|
|
1317
|
+
async joinAndReconcile(channelId, enclaveId) {
|
|
1318
|
+
await this.ensureSession(channelId, { enclaveId, bootstrap: false });
|
|
1319
|
+
try {
|
|
1320
|
+
await this.receiveOnce(this.o.deviceId);
|
|
1321
|
+
} catch {
|
|
1322
|
+
}
|
|
1323
|
+
return this.currentEpoch(channelId);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Self-heal-on-send (Phase 1.1). Converge the sender to the live group epoch
|
|
1327
|
+
* BEFORE it seals an application message: drain any pending commits (via
|
|
1328
|
+
* {@link receiveOnce}) so {@link currentEpoch} reflects the live group. A
|
|
1329
|
+
* sender one epoch behind would otherwise seal at a stale epoch — the backend
|
|
1330
|
+
* rejects that with an epoch-stale 400 (the "owner-send-stuck / shows queued,
|
|
1331
|
+
* never posts" symptom), and even a blind relay would deliver ciphertext no
|
|
1332
|
+
* live-epoch peer can decrypt (forward secrecy deleted the old secret).
|
|
1333
|
+
*
|
|
1334
|
+
* Best-effort and idempotent: if there is nothing pending it is a cheap no-op
|
|
1335
|
+
* (one pullDelivery), and a drain failure is swallowed so it never breaks the
|
|
1336
|
+
* send path — the caller publishes at whatever epoch we reach and the
|
|
1337
|
+
* defensive reconcile-then-retry-once on a publish 400 covers a commit that
|
|
1338
|
+
* lands between this drain and publish.
|
|
1339
|
+
*
|
|
1340
|
+
* COMMITTER SAFETY (B11): this MUST only be called from a normal application-
|
|
1341
|
+
* send entry point, never inside a committer's resolve→distribute window
|
|
1342
|
+
* (addMemberToLiveGroup / removeMemberFromLiveGroup / removeDeviceFromLiveGroup
|
|
1343
|
+
* stage their own commit and explicitly must NOT receiveOnce between resolving
|
|
1344
|
+
* members and distributing — doing so would race their in-flight commit). A
|
|
1345
|
+
* text/a2a/status send is never mid-distribute, so scoping reconcileForSend to
|
|
1346
|
+
* those entry points keeps the receiveOnce drain out of any committer window.
|
|
1347
|
+
* If the channel has no local MLS state yet (nothing to drain/seal), this is a
|
|
1348
|
+
* no-op rather than an error.
|
|
1349
|
+
*/
|
|
1350
|
+
async reconcileForSend(channelId) {
|
|
1351
|
+
if (!await this.o.store.load(channelId))
|
|
1352
|
+
return;
|
|
1353
|
+
try {
|
|
1354
|
+
await this.receiveOnce(this.o.deviceId);
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Peek the device's delivery queue for a pending Welcome WITHOUT acking it.
|
|
1360
|
+
*
|
|
1361
|
+
* Returns the `connect_channel_id` of the first welcome row, or null when none
|
|
1362
|
+
* is queued yet. Used by the external-guest join to detect host approval over
|
|
1363
|
+
* the guest bearer (the guest can't call the Clerk-gated /me or channel-list).
|
|
1364
|
+
* The row is deliberately NOT acked here — the subsequent ensureSession(join) /
|
|
1365
|
+
* joinAndReconcile consumes the same Welcome and acks it after joining.
|
|
1366
|
+
*/
|
|
1367
|
+
async peekWelcomeChannel(deviceId) {
|
|
1368
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
1369
|
+
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
|
|
1370
|
+
return w?.connect_channel_id ?? null;
|
|
1371
|
+
}
|
|
1372
|
+
async publishKeyPackage() {
|
|
1373
|
+
return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
|
|
1374
|
+
}
|
|
1375
|
+
async publishKeyPackageUnlocked() {
|
|
1376
|
+
if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
|
|
1377
|
+
console.warn("publishKeyPackage: pending bundle exists; skipping republish");
|
|
1378
|
+
return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
|
|
1379
|
+
}
|
|
1380
|
+
const mgr = new MLSGroupManager();
|
|
1381
|
+
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
1382
|
+
const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1383
|
+
const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1384
|
+
await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
|
|
1385
|
+
return id;
|
|
1386
|
+
}
|
|
1387
|
+
/** The server-assigned id of the last KeyPackage this device published,
|
|
1388
|
+
* or null if none has been published yet. Lets a diverged guest's self-heal
|
|
1389
|
+
* signal name its outstanding KP (P2-A). */
|
|
1390
|
+
async getLastPublishedKpId() {
|
|
1391
|
+
return this.o.store.loadLastKpId(this.o.deviceId);
|
|
1392
|
+
}
|
|
1393
|
+
async ensureSession(channelId, opts) {
|
|
1394
|
+
return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
|
|
1395
|
+
}
|
|
1396
|
+
async ensureSessionUnlocked(channelId, opts) {
|
|
1397
|
+
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
1398
|
+
return;
|
|
1399
|
+
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1400
|
+
if (!bundle && (opts.bootstrap || opts.forceReinit)) {
|
|
1401
|
+
await this.publishKeyPackage();
|
|
1402
|
+
bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1403
|
+
}
|
|
1404
|
+
if (!bundle)
|
|
1405
|
+
throw new Error("ensureSession: call publishKeyPackage() first");
|
|
1406
|
+
if (opts.forceReinit) {
|
|
1407
|
+
const others = opts.otherDeviceIds ?? [];
|
|
1408
|
+
const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
|
|
1409
|
+
const mgr = new MLSGroupManager();
|
|
1410
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
1411
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1412
|
+
const pkgs = others.map((d) => {
|
|
1413
|
+
const hex = kps[d];
|
|
1414
|
+
if (!hex)
|
|
1415
|
+
throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
|
|
1416
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1417
|
+
});
|
|
1418
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1419
|
+
await this.o.backend.distribute(reinit.group_id, {
|
|
1420
|
+
device_id: this.o.deviceId,
|
|
1421
|
+
commit: bytesToHex(commit),
|
|
1422
|
+
commit_epoch: Number(mgr.epoch),
|
|
1423
|
+
expected_epoch: reinit.current_epoch,
|
|
1424
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
1425
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
1426
|
+
});
|
|
1427
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1428
|
+
this.managers.set(channelId, mgr);
|
|
1429
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (opts.bootstrap) {
|
|
1433
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1434
|
+
if (init.created === false)
|
|
1435
|
+
throw new BootstrapRaceLostError();
|
|
1436
|
+
const mgr = new MLSGroupManager();
|
|
1437
|
+
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
1438
|
+
const others = opts.otherDeviceIds ?? [];
|
|
1439
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1440
|
+
const pkgs = others.map((d) => {
|
|
1441
|
+
const hex = kps[d];
|
|
1442
|
+
if (!hex)
|
|
1443
|
+
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
1444
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1445
|
+
});
|
|
1446
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1447
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1448
|
+
device_id: this.o.deviceId,
|
|
1449
|
+
commit: bytesToHex(commit),
|
|
1450
|
+
commit_epoch: Number(mgr.epoch),
|
|
1451
|
+
expected_epoch: init.current_epoch,
|
|
1452
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
1453
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
1454
|
+
});
|
|
1455
|
+
this.managers.set(channelId, mgr);
|
|
1456
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1457
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1458
|
+
} else {
|
|
1459
|
+
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1460
|
+
const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
|
|
1461
|
+
if (!w)
|
|
1462
|
+
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
1463
|
+
const mgr = new MLSGroupManager();
|
|
1464
|
+
try {
|
|
1465
|
+
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
1466
|
+
} catch (err) {
|
|
1467
|
+
this.recordDecryptError(w, err);
|
|
1468
|
+
console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
|
|
1469
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
this.managers.set(channelId, mgr);
|
|
1473
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1474
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1475
|
+
const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1476
|
+
let burstPending = false;
|
|
1477
|
+
for (const r of remaining) {
|
|
1478
|
+
if (r.message_type !== "welcome")
|
|
1479
|
+
continue;
|
|
1480
|
+
const cid = r.connect_channel_id;
|
|
1481
|
+
if (!cid || cid === channelId || !await this.o.store.load(cid)) {
|
|
1482
|
+
burstPending = true;
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (!burstPending)
|
|
1487
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Committer-side: add a member to an ALREADY-bootstrapped (live) group.
|
|
1492
|
+
*
|
|
1493
|
+
* The bootstrap path (ensureSession bootstrap:true) only adds members at group
|
|
1494
|
+
* creation. The admission flow admits a member AFTER the group is live, so the
|
|
1495
|
+
* committer commits the Add against its saved state and distributes the commit
|
|
1496
|
+
* plus a Welcome targeting the new device.
|
|
1497
|
+
*
|
|
1498
|
+
* group_id resolution reuses the same idempotent init endpoint bootstrap uses:
|
|
1499
|
+
* for an existing channel it returns the live group's id, current epoch, and
|
|
1500
|
+
* full member roster (unioning in any device ids we pass). That avoids a
|
|
1501
|
+
* parallel group_id-by-channel resolver.
|
|
1502
|
+
*/
|
|
1503
|
+
async addMemberToLiveGroup(channelId, newDeviceId) {
|
|
1504
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
|
|
1505
|
+
}
|
|
1506
|
+
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
|
|
1507
|
+
const mgr = await this.manager(channelId);
|
|
1508
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1509
|
+
const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
|
|
1510
|
+
const hex = kps[newDeviceId];
|
|
1511
|
+
if (!hex)
|
|
1512
|
+
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
1513
|
+
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1514
|
+
if (opts?.expectedIdentityId) {
|
|
1515
|
+
const cred = pkg.leafNode.credential;
|
|
1516
|
+
const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
|
|
1517
|
+
if (claimed !== opts.expectedIdentityId) {
|
|
1518
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1522
|
+
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
1523
|
+
const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
1524
|
+
try {
|
|
1525
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1526
|
+
device_id: this.o.deviceId,
|
|
1527
|
+
commit: bytesToHex(commit),
|
|
1528
|
+
commit_epoch: Number(mgr.epoch),
|
|
1529
|
+
expected_epoch: priorEpoch,
|
|
1530
|
+
member_device_ids: members,
|
|
1531
|
+
op: "add",
|
|
1532
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex(welcome) }] : []
|
|
1533
|
+
});
|
|
1534
|
+
} catch (e) {
|
|
1535
|
+
this.managers.delete(channelId);
|
|
1536
|
+
if (_attempt >= 2)
|
|
1537
|
+
throw e;
|
|
1538
|
+
try {
|
|
1539
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
const work = await this.o.backend.getCommitterWork(channelId);
|
|
1544
|
+
if (!work.adds.includes(newDeviceId))
|
|
1545
|
+
return;
|
|
1546
|
+
} catch {
|
|
1547
|
+
}
|
|
1548
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
|
|
1549
|
+
}
|
|
1550
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Committer-side: REMOVE a member from a live group (Phase C eviction).
|
|
1554
|
+
*
|
|
1555
|
+
* Mirrors {@link addMemberToLiveGroup}, but commits a Remove (no Welcome) and
|
|
1556
|
+
* uses the store's two-slot staging for crash/conflict safety: the advanced
|
|
1557
|
+
* (post-removal) state is written to STAGING before the removal-Commit is
|
|
1558
|
+
* distributed, promoted to MAIN only on a 2xx, and discarded on a 409 so a
|
|
1559
|
+
* reload from MAIN yields the pre-commit state. The removed member's leaf is
|
|
1560
|
+
* identified by its credential identity (a member's devices share one identity,
|
|
1561
|
+
* so this evicts ALL of that member's device leaves). The ts-mls forced
|
|
1562
|
+
* UpdatePath (patches/ts-mls+1.6.2.patch) rotates the new epoch's key schedule
|
|
1563
|
+
* away from every evicted leaf, so the removed member cannot derive the new
|
|
1564
|
+
* exporter secret.
|
|
1565
|
+
*
|
|
1566
|
+
* The server resolves removed_identity_id → the device(s) to drop from the
|
|
1567
|
+
* roster + purge from delivery; member_device_ids here is the SDK's roster view
|
|
1568
|
+
* for relays that don't do that resolution. No-op if the identity isn't a member
|
|
1569
|
+
* of this group.
|
|
1570
|
+
*/
|
|
1571
|
+
async removeMemberFromLiveGroup(channelId, identityId) {
|
|
1572
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0)));
|
|
1573
|
+
}
|
|
1574
|
+
async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
|
|
1575
|
+
const mgr = await this.manager(channelId);
|
|
1576
|
+
const indices = mgr.leafIndicesForIdentity(identityId);
|
|
1577
|
+
if (indices.length === 0)
|
|
1578
|
+
return;
|
|
1579
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1580
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1581
|
+
const { commit } = await mgr.removeMembers(indices);
|
|
1582
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
1583
|
+
throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
|
|
1584
|
+
}
|
|
1585
|
+
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
1586
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
1587
|
+
try {
|
|
1588
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1589
|
+
device_id: this.o.deviceId,
|
|
1590
|
+
commit: bytesToHex(commit),
|
|
1591
|
+
commit_epoch: Number(mgr.epoch),
|
|
1592
|
+
expected_epoch: priorEpoch,
|
|
1593
|
+
member_device_ids: members,
|
|
1594
|
+
op: "remove",
|
|
1595
|
+
removed_identity_id: identityId,
|
|
1596
|
+
welcomes: []
|
|
1597
|
+
});
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
this.managers.delete(channelId);
|
|
1600
|
+
await this.o.store.discardStaging(channelId);
|
|
1601
|
+
if (_attempt >= 2)
|
|
1602
|
+
throw e;
|
|
1603
|
+
try {
|
|
1604
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1605
|
+
} catch {
|
|
1606
|
+
}
|
|
1607
|
+
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
|
|
1608
|
+
}
|
|
1609
|
+
await this.o.store.promoteStaging(channelId);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
|
|
1613
|
+
* per-device eviction).
|
|
1614
|
+
*
|
|
1615
|
+
* The identity-level {@link removeMemberFromLiveGroup} evicts EVERY leaf of a
|
|
1616
|
+
* credential identity. This evicts ONE — the counterpart to revoking a single
|
|
1617
|
+
* device while the owner keeps their other devices in the channel.
|
|
1618
|
+
*
|
|
1619
|
+
* Leaf resolution (LOCKED): MLS leaves carry only the member's credential
|
|
1620
|
+
* identity, never a device id, so same-identity devices are indistinguishable
|
|
1621
|
+
* at the crypto layer. The ONLY correct mapping is POSITIONAL BY ADD-ORDER:
|
|
1622
|
+
* leaves are appended in the order devices were added, so the i-th add-ordered
|
|
1623
|
+
* device of an identity occupies the i-th ascending leaf index for that
|
|
1624
|
+
* identity. The caller (which holds the channel roster with identities) passes
|
|
1625
|
+
* the target's identity AND the add-ordered device-id list; we resolve the leaf
|
|
1626
|
+
* by `orderedDeviceIds.indexOf(deviceId)`. If the tree's leaf count for that
|
|
1627
|
+
* identity disagrees with the roster's device count, we REFUSE rather than guess
|
|
1628
|
+
* (a wrong leaf would evict the wrong device).
|
|
1629
|
+
*
|
|
1630
|
+
* Everything else mirrors {@link removeMemberFromLiveGroup}: resolve leaf BEFORE
|
|
1631
|
+
* the commit, capture priorEpoch, single-leaf removeMembers, the B11 epoch-drift
|
|
1632
|
+
* guard, and two-slot staging (save→distribute→promote on 2xx / discard + evict
|
|
1633
|
+
* + receiveOnce + retry on 409). The distribute carries op:"remove" +
|
|
1634
|
+
* `removed_device_id` (instead of removed_identity_id); the server resolves the
|
|
1635
|
+
* device's identity, asserts it matches, and purges that ONE device.
|
|
1636
|
+
*/
|
|
1637
|
+
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
1638
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
|
|
1639
|
+
}
|
|
1640
|
+
async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
|
|
1641
|
+
const mgr = await this.manager(channelId);
|
|
1642
|
+
const pos = opts.orderedDeviceIds.indexOf(deviceId);
|
|
1643
|
+
if (pos < 0)
|
|
1644
|
+
return;
|
|
1645
|
+
const leaves = mgr.leafIndicesForIdentity(opts.identity);
|
|
1646
|
+
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
1647
|
+
throw new LeafRosterMismatchError();
|
|
1648
|
+
}
|
|
1649
|
+
const leafIndex = leaves[pos];
|
|
1650
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1651
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1652
|
+
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
1653
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
1654
|
+
throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
|
|
1655
|
+
}
|
|
1656
|
+
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
1657
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
1658
|
+
const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex(opts.identity);
|
|
1659
|
+
try {
|
|
1660
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1661
|
+
device_id: this.o.deviceId,
|
|
1662
|
+
commit: bytesToHex(commit),
|
|
1663
|
+
commit_epoch: Number(mgr.epoch),
|
|
1664
|
+
expected_epoch: priorEpoch,
|
|
1665
|
+
member_device_ids: members,
|
|
1666
|
+
op: "remove",
|
|
1667
|
+
removed_identity_id: removedIdentity,
|
|
1668
|
+
removed_device_id: deviceId,
|
|
1669
|
+
welcomes: []
|
|
1670
|
+
});
|
|
1671
|
+
} catch (e) {
|
|
1672
|
+
this.managers.delete(channelId);
|
|
1673
|
+
await this.o.store.discardStaging(channelId);
|
|
1674
|
+
if (_attempt >= 2)
|
|
1675
|
+
throw e;
|
|
1676
|
+
try {
|
|
1677
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1678
|
+
} catch {
|
|
1679
|
+
}
|
|
1680
|
+
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
1681
|
+
}
|
|
1682
|
+
await this.o.store.promoteStaging(channelId);
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
|
|
1686
|
+
* Add(device), re-keying it with a fresh Welcome at the live epoch (Phase 1
|
|
1687
|
+
* member self-heal).
|
|
1688
|
+
*
|
|
1689
|
+
* ATOMICITY (S1, honest framing): this is atomic against CONCURRENCY — the
|
|
1690
|
+
* per-channel lock is taken ONCE and held continuously across both mutations, so
|
|
1691
|
+
* no peer commit or receive-pump row can interleave between the Remove and the
|
|
1692
|
+
* Add. It is NOT TRANSACTIONAL across the two server distributes: there is no
|
|
1693
|
+
* rollback of a landed Remove. If the Add throws AFTER the Remove's distribute
|
|
1694
|
+
* already succeeded, the member is left REMOVED-but-not-readded (detached —
|
|
1695
|
+
* strictly worse than the original wedge). That specific failure is surfaced as a
|
|
1696
|
+
* {@link HealAddAfterRemoveError} so a caller can recover by RE-RUNNING the heal:
|
|
1697
|
+
* the retry finds zero leaves for the identity and takes the Add-only branch,
|
|
1698
|
+
* completing the re-key. An Add failure BEFORE any Remove landed (e.g. the
|
|
1699
|
+
* zero-leaf branch, or a fetchKeyPackages failure) is not detaching and rethrows
|
|
1700
|
+
* as-is.
|
|
1701
|
+
*
|
|
1702
|
+
* The wedge this clears: a member fell behind, can't catch up (forward secrecy
|
|
1703
|
+
* deleted the key it needs), still HOLDS a leaf (so the admission watcher won't
|
|
1704
|
+
* re-add it), and its saved MLS state is diverged. There is no MLS-correct
|
|
1705
|
+
* "re-welcome in place" for a member that still occupies a leaf — handing a
|
|
1706
|
+
* Welcome to an identity that already holds a leaf is a two-leaf bug. The only
|
|
1707
|
+
* correct heal is to drop its stale leaf and re-Add the device, which seals a
|
|
1708
|
+
* fresh Welcome to it at the post-Remove live epoch. (reinit is the WRONG tool —
|
|
1709
|
+
* that is the committer-loss hammer, guarded to zero-other-devices, and
|
|
1710
|
+
* Welcome-storms every healthy member.)
|
|
1711
|
+
*
|
|
1712
|
+
* ONE acquisition (load-bearing): we take the per-channel lock ONCE and run BOTH
|
|
1713
|
+
* mutations inside it via the Unlocked variants, so the lock is never released
|
|
1714
|
+
* between Remove and Add — that gap would open a fork window (a peer commit or
|
|
1715
|
+
* the device-wide receive pump could advance the manager between the two). Do
|
|
1716
|
+
* NOT call the public (self-locking) variants in sequence. Remove FIRST, then
|
|
1717
|
+
* Add, so the identity is never momentarily double-leafed.
|
|
1718
|
+
*
|
|
1719
|
+
* Device-level removal (not identity-level): we resolve the member's credential
|
|
1720
|
+
* identity from its freshly-published KeyPackage (which the Add needs anyway)
|
|
1721
|
+
* and evict only the single stale leaf, so the member identity's OTHER healthy
|
|
1722
|
+
* devices are not collaterally evicted. Leaf resolution reads the LIVE
|
|
1723
|
+
* (post-lock) tree:
|
|
1724
|
+
* - exactly ONE leaf → device-level Remove (orderedDeviceIds=[deviceId]) then Add;
|
|
1725
|
+
* - ZERO leaves → no stale leaf (e.g. a heal retry whose Remove already
|
|
1726
|
+
* landed) → Add-only re-key;
|
|
1727
|
+
* - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
|
|
1728
|
+
* leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
|
|
1729
|
+
*
|
|
1730
|
+
* The Add fetches the device's freshly published KeyPackage from the backend,
|
|
1731
|
+
* exactly like a normal add — the CALLER is responsible for the member having
|
|
1732
|
+
* re-published one first (mirrors addMemberToLiveGroup's "no published
|
|
1733
|
+
* KeyPackage" error). After the call the committer's group has the member
|
|
1734
|
+
* re-added at the live epoch.
|
|
1735
|
+
*
|
|
1736
|
+
* Authorization / single-committer guards are inherited unchanged from the
|
|
1737
|
+
* Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
|
|
1738
|
+
* addMemberToLiveGroupUnlocked) — no weaker path is introduced.
|
|
1739
|
+
*/
|
|
1740
|
+
async healDivergedMember(channelId, memberDeviceId, opts) {
|
|
1741
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
1742
|
+
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
1743
|
+
const hex = kps[memberDeviceId];
|
|
1744
|
+
if (!hex)
|
|
1745
|
+
throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
|
|
1746
|
+
const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
|
|
1747
|
+
if (cred.credentialType !== "basic") {
|
|
1748
|
+
throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
|
|
1749
|
+
}
|
|
1750
|
+
const identity = new TextDecoder().decode(cred.identity);
|
|
1751
|
+
if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
|
|
1752
|
+
throw new ForgedCredentialError(opts.expectedIdentityId, identity);
|
|
1753
|
+
}
|
|
1754
|
+
const mgr = await this.manager(channelId);
|
|
1755
|
+
const leaves = mgr.leafIndicesForIdentity(identity);
|
|
1756
|
+
if (leaves.length > 1) {
|
|
1757
|
+
throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
|
|
1758
|
+
}
|
|
1759
|
+
let removeLanded = false;
|
|
1760
|
+
if (leaves.length === 1) {
|
|
1761
|
+
await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
|
|
1762
|
+
removeLanded = true;
|
|
1763
|
+
} else {
|
|
1764
|
+
const stillZero = (await this.manager(channelId)).leafIndicesForIdentity(identity);
|
|
1765
|
+
if (stillZero.length !== 0) {
|
|
1766
|
+
throw new Error(`healDivergedMember: expected 0 leaves for the Add-only branch but found ${stillZero.length} (a leaf reappeared under the held lock \u2014 refusing to create a duplicate leaf)`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
try {
|
|
1770
|
+
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
|
|
1771
|
+
} catch (e) {
|
|
1772
|
+
if (removeLanded)
|
|
1773
|
+
throw new HealAddAfterRemoveError(e);
|
|
1774
|
+
throw e;
|
|
1775
|
+
}
|
|
1776
|
+
}));
|
|
1777
|
+
}
|
|
1778
|
+
/** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
|
|
1779
|
+
* epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
|
|
1780
|
+
* re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
|
|
1781
|
+
* sweep call sites and for the cross-tenant authz that differs only server-side.
|
|
1782
|
+
* Caller contract (like the member path): the guest republishes a fresh
|
|
1783
|
+
* KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
|
|
1784
|
+
*
|
|
1785
|
+
* Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
|
|
1786
|
+
* retained KeyPackage (the one the caller confirmed the guest republished).
|
|
1787
|
+
* Before doing anything else we fetch it BY ID and refuse
|
|
1788
|
+
* ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
|
|
1789
|
+
* strength of "some KeyPackage is published for this device", because that
|
|
1790
|
+
* could be stale, consumed, or absent and would seal an un-openable Welcome,
|
|
1791
|
+
* bricking the guest instead of healing it. Once the gate passes we delegate to
|
|
1792
|
+
* the proven {@link healDivergedMember}: given `publish_key_package`'s
|
|
1793
|
+
* delete-and-replace semantics and the guest's pending-bundle republish guard,
|
|
1794
|
+
* the by-device fetch inside healDivergedMember resolves to exactly this same
|
|
1795
|
+
* KeyPackage — the id-fetch here is the fail-closed contract, the member heal
|
|
1796
|
+
* is the mechanism. */
|
|
1797
|
+
async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
|
|
1798
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
1799
|
+
if (!hex)
|
|
1800
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
1801
|
+
return this.healDivergedMember(channelId, guestDeviceId);
|
|
1802
|
+
}
|
|
1803
|
+
/** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
|
|
1804
|
+
* AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
|
|
1805
|
+
* id-by-value gate (refuse if `expectedKpId` is gone/consumed →
|
|
1806
|
+
* {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
|
|
1807
|
+
* delegates to {@link healDivergedMember} with `expectedIdentityId` so the
|
|
1808
|
+
* committed-bytes assertion binds the re-Added leaf to the intended identity
|
|
1809
|
+
* (throws {@link ForgedCredentialError} on a credential-identity mismatch,
|
|
1810
|
+
* including on the 409-retry path). This is the entry point the owner's
|
|
1811
|
+
* agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
|
|
1812
|
+
* the welcome-request row the agent published, so a compromised agent cannot
|
|
1813
|
+
* substitute a KeyPackage that forges a different identity. */
|
|
1814
|
+
async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
|
|
1815
|
+
const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
|
|
1816
|
+
if (!hex)
|
|
1817
|
+
throw new GuestHealStaleKpError(expectedKpId);
|
|
1818
|
+
return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Member-side: drop the diverged local state for a channel so a subsequent
|
|
1822
|
+
* {@link joinAndReconcile} consumes the committer's fresh Welcome (from
|
|
1823
|
+
* {@link healDivergedMember}) instead of early-returning on the stale saved
|
|
1824
|
+
* state (Phase 1 member self-heal). This does NOT rejoin — the caller invokes
|
|
1825
|
+
* joinAndReconcile afterward.
|
|
1826
|
+
*
|
|
1827
|
+
* BOTH the in-memory manager AND the persisted store row must go: evict alone is
|
|
1828
|
+
* insufficient because ensureSession(join) early-returns whenever
|
|
1829
|
+
* `store.load(channelId)` returns existing state, so the member would reload the
|
|
1830
|
+
* diverged row and never rejoin. Also clear this channel's decrypt-error tracker
|
|
1831
|
+
* entry so a healed channel doesn't read as permanently `stuck` in errorStats.
|
|
1832
|
+
*
|
|
1833
|
+
* CHANNEL LOCK (M1): the whole reset runs under the per-channel lock, serialized
|
|
1834
|
+
* against receiveOnce / committer ops / ensureSession on the SAME channel. Without
|
|
1835
|
+
* it a concurrent receiveOnce processing a commit row for this channel could
|
|
1836
|
+
* re-persist the diverged state immediately after our `store.delete`, re-wedging
|
|
1837
|
+
* the rejoin (the join would then early-return on the resurrected row). The
|
|
1838
|
+
* in-memory evict and `errors.delete` are cheap synchronous map mutations but are
|
|
1839
|
+
* kept inside the lock so the reset is one coherent, atomic state clear.
|
|
1840
|
+
*
|
|
1841
|
+
* STAGING (M2): also discard any persisted staging row for the channel. A removal
|
|
1842
|
+
* op interrupted between saveStaging and promote/discard can leave a stale staged
|
|
1843
|
+
* advanced state; if that survives the reset it can re-diverge the rejoin. The
|
|
1844
|
+
* in-memory store's discardStaging is a no-op when no staging row exists.
|
|
1845
|
+
*/
|
|
1846
|
+
async resetChannelForRejoin(channelId) {
|
|
1847
|
+
await this.locks.run(channelId, async () => {
|
|
1848
|
+
this.evictChannelState(channelId);
|
|
1849
|
+
await this.o.store.delete(channelId);
|
|
1850
|
+
await this.o.store.discardStaging(channelId);
|
|
1851
|
+
this.errors.delete(channelId);
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
/** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
|
|
1855
|
+
* ensureSession forceReinit branch but posts a client-chosen new_group_id +
|
|
1856
|
+
* signed attestation to the reset-mode endpoint, then builds + distributes
|
|
1857
|
+
* the new group. Runs under the channel lock (serialized against
|
|
1858
|
+
* receiveOnce / committer ops).
|
|
1859
|
+
*
|
|
1860
|
+
* `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
|
|
1861
|
+
* member device holding an unconsumed KeyPackage (H2) -- the caller (the web
|
|
1862
|
+
* client's members-endpoint enumeration) is responsible for that union;
|
|
1863
|
+
* this method only handles the mechanical THIS-device exclusion: this
|
|
1864
|
+
* device is the committer that creates the group, so it must not be in the
|
|
1865
|
+
* MLS addMembers set (adding your own new leaf via addMembers is
|
|
1866
|
+
* nonsensical / unsupported). The wire member_device_ids below re-includes
|
|
1867
|
+
* it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
|
|
1868
|
+
* (required subset of supplied) is still satisfied even though it never
|
|
1869
|
+
* goes through addMembers. */
|
|
1870
|
+
async resetSessionSupersede(channelId, opts) {
|
|
1871
|
+
return this.locks.run(channelId, async () => {
|
|
1872
|
+
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1873
|
+
if (!bundle) {
|
|
1874
|
+
await this.publishKeyPackage();
|
|
1875
|
+
bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1876
|
+
}
|
|
1877
|
+
if (!bundle)
|
|
1878
|
+
throw new Error("resetSessionSupersede: call publishKeyPackage() first");
|
|
1879
|
+
const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
|
|
1880
|
+
const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
|
|
1881
|
+
const mgr = new MLSGroupManager();
|
|
1882
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
1883
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1884
|
+
const pkgs = others.map((d) => {
|
|
1885
|
+
const hex = kps[d];
|
|
1886
|
+
if (!hex)
|
|
1887
|
+
throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
|
|
1888
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1889
|
+
});
|
|
1890
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1891
|
+
await this.o.backend.distribute(reinit.group_id, {
|
|
1892
|
+
device_id: this.o.deviceId,
|
|
1893
|
+
commit: bytesToHex(commit),
|
|
1894
|
+
commit_epoch: Number(mgr.epoch),
|
|
1895
|
+
expected_epoch: reinit.current_epoch,
|
|
1896
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
1897
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
1898
|
+
});
|
|
1899
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1900
|
+
this.managers.set(channelId, mgr);
|
|
1901
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
1905
|
+
* Cheap synchronous check; does NOT consult the store. */
|
|
1906
|
+
hasChannelState(channelId) {
|
|
1907
|
+
return this.managers.has(channelId);
|
|
1908
|
+
}
|
|
1909
|
+
/** Returns true when the channel has either a live in-memory manager OR
|
|
1910
|
+
* persisted state in the store. Use this to decide whether to call
|
|
1911
|
+
* ensureSession(bootstrap:false) or ensureSession(bootstrap:true). */
|
|
1912
|
+
async hasPersistedChannelState(channelId) {
|
|
1913
|
+
if (this.managers.has(channelId))
|
|
1914
|
+
return true;
|
|
1915
|
+
return await this.o.store.load(channelId) !== null;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Return the number of leaves the given identity holds in the live tree for
|
|
1919
|
+
* `channelId`. Loads the manager from the store if not already cached.
|
|
1920
|
+
*
|
|
1921
|
+
* Primarily used by the gate (S20) to assert that a peer-committer 409-retry
|
|
1922
|
+
* does NOT create a duplicate leaf for an already-added device. A return value
|
|
1923
|
+
* of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
|
|
1924
|
+
* duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
|
|
1925
|
+
*/
|
|
1926
|
+
async leafCountForIdentity(channelId, identityId) {
|
|
1927
|
+
const mgr = await this.manager(channelId);
|
|
1928
|
+
return mgr.leafIndicesForIdentity(identityId).length;
|
|
1929
|
+
}
|
|
1930
|
+
/** Returns true when the live in-memory MLS tree for `channelId` contains at
|
|
1931
|
+
* least one leaf bearing `identityId` as its credential. Used by the agent
|
|
1932
|
+
* admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
|
|
1933
|
+
* needed — if the agent already has a leaf the sweep skips it. Returns false
|
|
1934
|
+
* when there is no in-memory manager for the channel (i.e. the group is not
|
|
1935
|
+
* yet loaded), which causes the sweep to attempt the Add (idempotent on the
|
|
1936
|
+
* server side). */
|
|
1937
|
+
hasLeafForIdentity(channelId, identityId) {
|
|
1938
|
+
const mgr = this.managers.get(channelId);
|
|
1939
|
+
if (!mgr)
|
|
1940
|
+
return false;
|
|
1941
|
+
return mgr.leafIndicesForIdentity(identityId).length > 0;
|
|
1942
|
+
}
|
|
1943
|
+
/** Probe the server for the channel's MLS group without modifying any local
|
|
1944
|
+
* state. Maps the ChannelGroupResp field names to SDK-friendly camelCase. */
|
|
1945
|
+
async probeChannelGroup(channelId) {
|
|
1946
|
+
const r = await this.o.backend.getConnectChannelGroup(channelId);
|
|
1947
|
+
return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Verified group supersede for the agent's receive pump (owner-attested reset).
|
|
1951
|
+
* Returns true when the channel's state was swapped to the new group and the
|
|
1952
|
+
* Welcome row acked; false when the supersede was REFUSED (row left un-acked).
|
|
1953
|
+
*
|
|
1954
|
+
* FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
|
|
1955
|
+
* ⇒ refuse, never call verify), then structural signer check, then attestation
|
|
1956
|
+
* verify against the NON-EMPTY pinned key, then post-join group-id assertion.
|
|
1957
|
+
*/
|
|
1958
|
+
async trySupersede(channelId, row, heldGroupId) {
|
|
1959
|
+
const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
|
|
1960
|
+
if (!owner || !owner.publicKeyHex) {
|
|
1961
|
+
this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
|
|
1962
|
+
return false;
|
|
1963
|
+
}
|
|
1964
|
+
const retired = await this.o.store.loadRetiredGroupIds(channelId);
|
|
1965
|
+
if (retired.includes(row.group_id)) {
|
|
1966
|
+
this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
|
|
1967
|
+
return false;
|
|
1968
|
+
}
|
|
1969
|
+
const att = await this.o.backend.fetchResetAttestation(row.group_id);
|
|
1970
|
+
if (!att) {
|
|
1971
|
+
this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
|
|
1972
|
+
return false;
|
|
1973
|
+
}
|
|
1974
|
+
if (att.signerIdentityId !== owner.identityId) {
|
|
1975
|
+
this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
|
|
1976
|
+
return false;
|
|
1977
|
+
}
|
|
1978
|
+
let content;
|
|
1979
|
+
try {
|
|
1980
|
+
content = JSON.parse(att.signedContent);
|
|
1981
|
+
} catch {
|
|
1982
|
+
this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
|
|
1983
|
+
return false;
|
|
1984
|
+
}
|
|
1985
|
+
if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
|
|
1986
|
+
this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
|
|
1987
|
+
return false;
|
|
1988
|
+
}
|
|
1989
|
+
const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
|
|
1990
|
+
if (!ok) {
|
|
1991
|
+
this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
|
|
1992
|
+
return false;
|
|
1993
|
+
}
|
|
1994
|
+
const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1995
|
+
if (!bundle) {
|
|
1996
|
+
this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
return await this.locks.run(channelId, async () => {
|
|
2000
|
+
const mgr = new MLSGroupManager();
|
|
2001
|
+
try {
|
|
2002
|
+
await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
|
|
2003
|
+
} catch (err) {
|
|
2004
|
+
this.recordDecryptError(row, err);
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
|
|
2008
|
+
this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
this.evictChannelState(channelId);
|
|
2012
|
+
await this.o.store.delete(channelId);
|
|
2013
|
+
await this.o.store.discardStaging(channelId);
|
|
2014
|
+
await this.o.store.addRetiredGroupId(channelId, heldGroupId);
|
|
2015
|
+
this.errors.delete(channelId);
|
|
2016
|
+
this.managers.set(channelId, mgr);
|
|
2017
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2018
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
|
|
2019
|
+
return true;
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
async receiveOnce(deviceId) {
|
|
2023
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2024
|
+
const appEntries = [];
|
|
2025
|
+
const handshakeAcked = [];
|
|
2026
|
+
const supersededChannelIds = [];
|
|
2027
|
+
const order = { welcome: 0, commit: 1, application: 2 };
|
|
2028
|
+
rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
|
|
2029
|
+
for (const row of rows) {
|
|
2030
|
+
try {
|
|
2031
|
+
if (row.message_type === "welcome") {
|
|
2032
|
+
const wcid = row.connect_channel_id;
|
|
2033
|
+
if (wcid) {
|
|
2034
|
+
const existing = await this.o.store.load(wcid);
|
|
2035
|
+
if (existing) {
|
|
2036
|
+
let heldGroupId = null;
|
|
2037
|
+
try {
|
|
2038
|
+
const mgr = await this.manager(wcid);
|
|
2039
|
+
heldGroupId = new TextDecoder().decode(mgr.groupId);
|
|
2040
|
+
} catch {
|
|
2041
|
+
heldGroupId = null;
|
|
2042
|
+
}
|
|
2043
|
+
if (heldGroupId !== null && heldGroupId === row.group_id) {
|
|
2044
|
+
handshakeAcked.push(row.queue_id);
|
|
2045
|
+
} else if (heldGroupId !== null && heldGroupId !== row.group_id) {
|
|
2046
|
+
const ok = await this.trySupersede(wcid, row, heldGroupId);
|
|
2047
|
+
if (ok)
|
|
2048
|
+
supersededChannelIds.push(wcid);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
} else if (row.message_type === "commit") {
|
|
2053
|
+
const cid = row.connect_channel_id;
|
|
2054
|
+
if (!cid)
|
|
2055
|
+
continue;
|
|
2056
|
+
await this.locks.run(cid, async () => {
|
|
2057
|
+
const mgr = await this.manager(cid);
|
|
2058
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
2059
|
+
await this.o.store.save(cid, mgr.exportState());
|
|
2060
|
+
});
|
|
2061
|
+
this.recordDecryptSuccess(cid);
|
|
2062
|
+
handshakeAcked.push(row.queue_id);
|
|
2063
|
+
} else {
|
|
2064
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
2065
|
+
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
2066
|
+
const aad = await buildAssociatedData({
|
|
2067
|
+
message_id: envelope.message_id,
|
|
2068
|
+
channel_id: envelope.channel_id,
|
|
2069
|
+
sender_id: envelope.sender_id,
|
|
2070
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
2071
|
+
replay_nonce: envelope.replay_nonce,
|
|
2072
|
+
policy_binding: envelope.policy_binding
|
|
2073
|
+
});
|
|
2074
|
+
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
2075
|
+
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
2076
|
+
this.recordDecryptSuccess(appChannelId);
|
|
2077
|
+
appEntries.push({
|
|
2078
|
+
queueId: row.queue_id,
|
|
2079
|
+
messageId: envelope.message_id,
|
|
2080
|
+
channelId: appChannelId,
|
|
2081
|
+
plaintext,
|
|
2082
|
+
envelope,
|
|
2083
|
+
receivedAt: Date.now(),
|
|
2084
|
+
retryCount: 0,
|
|
2085
|
+
expires_at: row.expires_at
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
this.recordDecryptError(row, err);
|
|
2090
|
+
const cid = row.connect_channel_id;
|
|
2091
|
+
if (cid && typeof row.epoch === "number") {
|
|
2092
|
+
let liveEpoch;
|
|
2093
|
+
try {
|
|
2094
|
+
liveEpoch = await this.currentEpoch(cid);
|
|
2095
|
+
} catch {
|
|
2096
|
+
liveEpoch = void 0;
|
|
2097
|
+
}
|
|
2098
|
+
const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
|
|
2099
|
+
if (permanentlyDead) {
|
|
2100
|
+
handshakeAcked.push(row.queue_id);
|
|
2101
|
+
this.permanentlySkipped++;
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
if (handshakeAcked.length)
|
|
2107
|
+
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
2108
|
+
if (supersededChannelIds.length) {
|
|
2109
|
+
Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
|
|
2110
|
+
}
|
|
2111
|
+
return appEntries;
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
|
|
2115
|
+
*
|
|
2116
|
+
* A committer that loses an epoch race (409) must process the peer's winning
|
|
2117
|
+
* commit to advance to the true current epoch before it retries. It does this
|
|
2118
|
+
* from INSIDE its own channel lock, so it must NOT call the public receiveOnce
|
|
2119
|
+
* (which would re-acquire the same channel lock for that channel's commit rows
|
|
2120
|
+
* and self-deadlock — the queue is not reentrant). This helper does the commit
|
|
2121
|
+
* half of receiveOnce for ONLY the committer's own channel, without taking the
|
|
2122
|
+
* lock (the caller already holds it). Application rows are left untouched — the
|
|
2123
|
+
* committer only needs to converge its handshake epoch — and welcome rows are
|
|
2124
|
+
* left for the join path, exactly as receiveOnce leaves them.
|
|
2125
|
+
*/
|
|
2126
|
+
async drainChannelCommitsUnlocked(deviceId, channelId) {
|
|
2127
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
2128
|
+
const acked = [];
|
|
2129
|
+
for (const row of rows) {
|
|
2130
|
+
if (row.message_type !== "commit")
|
|
2131
|
+
continue;
|
|
2132
|
+
if (row.connect_channel_id !== channelId)
|
|
2133
|
+
continue;
|
|
2134
|
+
try {
|
|
2135
|
+
const mgr = await this.manager(channelId);
|
|
2136
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
2137
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
2138
|
+
this.recordDecryptSuccess(channelId);
|
|
2139
|
+
acked.push(row.queue_id);
|
|
2140
|
+
} catch (err) {
|
|
2141
|
+
this.recordDecryptError(row, err);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (acked.length)
|
|
2145
|
+
await this.o.backend.ackDelivery(deviceId, acked);
|
|
2146
|
+
}
|
|
2147
|
+
/** Delegate so the receive pump can ack without reaching the private backend. */
|
|
2148
|
+
async ackDelivery(deviceId, queueIds) {
|
|
2149
|
+
await this.o.backend.ackDelivery(deviceId, queueIds);
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
// ../connect-sdk-ts/dist/transport/frame-registry.js
|
|
2154
|
+
var str = (v) => String(v ?? "");
|
|
2155
|
+
var FRAME_PARSERS = {
|
|
2156
|
+
mls_delivery: (d) => ({ count: typeof d?.count === "number" ? d.count : void 0 }),
|
|
2157
|
+
delivery_receipt: (d) => ({
|
|
2158
|
+
receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "delivered" }]
|
|
2159
|
+
}),
|
|
2160
|
+
read_receipt: (d) => ({
|
|
2161
|
+
receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "read" }]
|
|
2162
|
+
}),
|
|
2163
|
+
agent_status_change: (d) => ({
|
|
2164
|
+
presence: [
|
|
2165
|
+
{
|
|
2166
|
+
identityId: str(d?.identity_id),
|
|
2167
|
+
status: d?.status === "offline" ? "offline" : "online",
|
|
2168
|
+
lastSeen: d?.last_seen
|
|
2169
|
+
}
|
|
2170
|
+
]
|
|
2171
|
+
}),
|
|
2172
|
+
presence_snapshot: (d) => ({
|
|
2173
|
+
presence: (Array.isArray(d?.online) ? d.online : []).map((id) => ({
|
|
2174
|
+
identityId: str(id),
|
|
2175
|
+
status: "online"
|
|
2176
|
+
}))
|
|
2177
|
+
}),
|
|
2178
|
+
typing: (d) => ({ typing: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) } }),
|
|
2179
|
+
agent_status_keepalive: (d) => ({
|
|
2180
|
+
statusKeepalive: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) }
|
|
2181
|
+
}),
|
|
2182
|
+
// MD-3: a device asked to be Welcomed into a group. A surviving committer
|
|
2183
|
+
// device acts on this for own-identity (PoP-proven) requesters; the durable
|
|
2184
|
+
// state is the welcome_request row, this frame just wakes the handler.
|
|
2185
|
+
mls_welcome_requested: (d) => ({
|
|
2186
|
+
welcomeRequest: {
|
|
2187
|
+
groupId: str(d?.group_id),
|
|
2188
|
+
roomId: d?.room_id ? str(d.room_id) : void 0,
|
|
2189
|
+
requestingDeviceId: str(d?.requesting_device_id),
|
|
2190
|
+
requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
|
|
2191
|
+
freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
|
|
2192
|
+
requestId: d?.request_id ? str(d.request_id) : void 0
|
|
2193
|
+
}
|
|
2194
|
+
}),
|
|
2195
|
+
// Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
|
|
2196
|
+
// heal a guest whose ratchet tree diverged. Durable state is the
|
|
2197
|
+
// mls_guest_heal_request row; this frame is just the wakeup hint.
|
|
2198
|
+
mls_guest_heal_requested: (d) => ({
|
|
2199
|
+
guestHealRequest: {
|
|
2200
|
+
groupId: str(d?.group_id),
|
|
2201
|
+
channelId: str(d?.channel_id),
|
|
2202
|
+
enclaveId: str(d?.enclave_id),
|
|
2203
|
+
guestIdentityId: str(d?.guest_identity_id),
|
|
2204
|
+
guestDeviceId: str(d?.guest_device_id),
|
|
2205
|
+
freshKpId: str(d?.fresh_kp_id),
|
|
2206
|
+
requestId: str(d?.request_id)
|
|
2207
|
+
}
|
|
2208
|
+
})
|
|
2209
|
+
};
|
|
2210
|
+
var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
|
|
2211
|
+
function parseServerFrame(frame) {
|
|
2212
|
+
if (!frame || typeof frame !== "object")
|
|
2213
|
+
return void 0;
|
|
2214
|
+
const f = frame;
|
|
2215
|
+
const parser = typeof f.event === "string" ? FRAME_PARSERS[f.event] : void 0;
|
|
2216
|
+
return parser ? parser(f.data) : void 0;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// ../connect-sdk-ts/dist/transport/ws-pings.js
|
|
2220
|
+
function openDeliveryPings(o) {
|
|
2221
|
+
const base = o.backoffBaseMs ?? 500;
|
|
2222
|
+
const cap = o.backoffCapMs ?? 8e3;
|
|
2223
|
+
const rng = o.rng ?? Math.random;
|
|
2224
|
+
const maxQueue = o.maxQueue ?? 8;
|
|
2225
|
+
const stableResetMs = o.stableResetMs ?? 1e4;
|
|
2226
|
+
let closed = false;
|
|
2227
|
+
let attempt = 0;
|
|
2228
|
+
let sock;
|
|
2229
|
+
let backoffTimer;
|
|
2230
|
+
let stableTimer;
|
|
2231
|
+
const PRESENCE_PONG_MS = 3e4;
|
|
2232
|
+
let pongTimer;
|
|
2233
|
+
const stopPong = () => {
|
|
2234
|
+
if (pongTimer) {
|
|
2235
|
+
clearInterval(pongTimer);
|
|
2236
|
+
pongTimer = void 0;
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
const queue = [];
|
|
2240
|
+
let waiter;
|
|
2241
|
+
let iterStarted = false;
|
|
2242
|
+
const push = (p) => {
|
|
2243
|
+
if (waiter) {
|
|
2244
|
+
const w = waiter;
|
|
2245
|
+
waiter = void 0;
|
|
2246
|
+
w({ value: p, done: false });
|
|
2247
|
+
} else if (queue.length < maxQueue) {
|
|
2248
|
+
queue.push(p);
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
const end = () => {
|
|
2252
|
+
closed = true;
|
|
2253
|
+
if (backoffTimer)
|
|
2254
|
+
clearTimeout(backoffTimer);
|
|
2255
|
+
if (stableTimer)
|
|
2256
|
+
clearTimeout(stableTimer);
|
|
2257
|
+
stopPong();
|
|
2258
|
+
try {
|
|
2259
|
+
sock?.close();
|
|
2260
|
+
} catch {
|
|
2261
|
+
}
|
|
2262
|
+
if (waiter) {
|
|
2263
|
+
const w = waiter;
|
|
2264
|
+
waiter = void 0;
|
|
2265
|
+
w({ value: void 0, done: true });
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
const scheduleReconnect = () => {
|
|
2269
|
+
if (closed)
|
|
2270
|
+
return;
|
|
2271
|
+
attempt += 1;
|
|
2272
|
+
const ceil = Math.min(cap, base * 2 ** (attempt - 1));
|
|
2273
|
+
backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
|
|
2274
|
+
};
|
|
2275
|
+
async function connect() {
|
|
2276
|
+
if (closed)
|
|
2277
|
+
return;
|
|
2278
|
+
let token;
|
|
2279
|
+
try {
|
|
2280
|
+
token = await o.tokenProvider();
|
|
2281
|
+
} catch {
|
|
2282
|
+
scheduleReconnect();
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
if (closed)
|
|
2286
|
+
return;
|
|
2287
|
+
const sep = o.url.includes("?") ? "&" : "?";
|
|
2288
|
+
const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
|
|
2289
|
+
sock = ws;
|
|
2290
|
+
ws.onopen = () => {
|
|
2291
|
+
push({ reconnected: true });
|
|
2292
|
+
stableTimer = setTimeout(() => {
|
|
2293
|
+
attempt = 0;
|
|
2294
|
+
}, stableResetMs);
|
|
2295
|
+
stopPong();
|
|
2296
|
+
pongTimer = setInterval(() => {
|
|
2297
|
+
try {
|
|
2298
|
+
if (ws.readyState === 1)
|
|
2299
|
+
ws.send(JSON.stringify({ event: "pong" }));
|
|
2300
|
+
} catch {
|
|
2301
|
+
}
|
|
2302
|
+
}, PRESENCE_PONG_MS);
|
|
2303
|
+
};
|
|
2304
|
+
ws.onmessage = (e) => {
|
|
2305
|
+
try {
|
|
2306
|
+
const f = JSON.parse(String(e.data));
|
|
2307
|
+
const ping = parseServerFrame(f);
|
|
2308
|
+
if (ping)
|
|
2309
|
+
push(ping);
|
|
2310
|
+
} catch {
|
|
2311
|
+
}
|
|
2312
|
+
};
|
|
2313
|
+
ws.onerror = () => {
|
|
2314
|
+
};
|
|
2315
|
+
ws.onclose = (e) => {
|
|
2316
|
+
sock = void 0;
|
|
2317
|
+
if (stableTimer)
|
|
2318
|
+
clearTimeout(stableTimer);
|
|
2319
|
+
stopPong();
|
|
2320
|
+
if (closed)
|
|
2321
|
+
return;
|
|
2322
|
+
if (e.code === 4001) {
|
|
2323
|
+
end();
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
scheduleReconnect();
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
void connect();
|
|
2330
|
+
const pings = {
|
|
2331
|
+
[Symbol.asyncIterator]() {
|
|
2332
|
+
if (iterStarted)
|
|
2333
|
+
throw new Error("openDeliveryPings: single-use async iterable");
|
|
2334
|
+
iterStarted = true;
|
|
2335
|
+
return {
|
|
2336
|
+
next() {
|
|
2337
|
+
if (closed)
|
|
2338
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
2339
|
+
if (queue.length)
|
|
2340
|
+
return Promise.resolve({ value: queue.shift(), done: false });
|
|
2341
|
+
return new Promise((resolve) => {
|
|
2342
|
+
waiter = resolve;
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
const send = (frame) => {
|
|
2349
|
+
try {
|
|
2350
|
+
if (sock && sock.readyState === 1)
|
|
2351
|
+
sock.send(JSON.stringify(frame));
|
|
2352
|
+
} catch {
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
return { pings, close: end, send };
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// ../connect-sdk-ts/dist/transport/rest.js
|
|
2359
|
+
var PATHS = {
|
|
2360
|
+
register: "/api/v1/connect/agents/register",
|
|
2361
|
+
// VERIFY against MOT-136
|
|
2362
|
+
channelMembers: (id) => `/api/v1/connect/channels/${id}/members`,
|
|
2363
|
+
// VERIFY against MOT-139
|
|
2364
|
+
messages: "/api/v1/messages",
|
|
2365
|
+
// live (MOT-194)
|
|
2366
|
+
stream: "/api/v1/messages/stream",
|
|
2367
|
+
// MLS-gated (MOT-144)
|
|
2368
|
+
auditRecords: "/api/v1/audit/records"
|
|
2369
|
+
// MOT-151
|
|
2370
|
+
};
|
|
2371
|
+
var RestTransport = class {
|
|
2372
|
+
opts;
|
|
2373
|
+
f;
|
|
2374
|
+
ws;
|
|
2375
|
+
tokenProvider;
|
|
2376
|
+
/** The most recent device-wide WS send handle, captured on subscribe and
|
|
2377
|
+
* cleared on its close. Control frames (status keepalive) route through it;
|
|
2378
|
+
* a no-op when no subscription is active. */
|
|
2379
|
+
_controlSend = null;
|
|
2380
|
+
constructor(opts) {
|
|
2381
|
+
this.opts = opts;
|
|
2382
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
2383
|
+
this.ws = opts.wsImpl ?? globalThis.WebSocket;
|
|
2384
|
+
this.tokenProvider = opts.tokenProvider ?? (async () => opts.bearerToken);
|
|
2385
|
+
}
|
|
2386
|
+
headers() {
|
|
2387
|
+
return { "content-type": "application/json", authorization: `Bearer ${this.opts.bearerToken}` };
|
|
2388
|
+
}
|
|
2389
|
+
async register(req) {
|
|
2390
|
+
const res = await this.f(this.opts.baseUrl + PATHS.register, {
|
|
2391
|
+
method: "POST",
|
|
2392
|
+
headers: this.headers(),
|
|
2393
|
+
body: JSON.stringify({
|
|
2394
|
+
skill_forge_token: req.skillForgeToken,
|
|
2395
|
+
skill_id: req.skillId,
|
|
2396
|
+
public_key: req.publicKey,
|
|
2397
|
+
algo: req.algo,
|
|
2398
|
+
attested: req.attested,
|
|
2399
|
+
model: req.model
|
|
2400
|
+
})
|
|
2401
|
+
});
|
|
2402
|
+
if (!res.ok)
|
|
2403
|
+
throw new RegistrationError(`register failed: ${res.status}`);
|
|
2404
|
+
const j = await res.json();
|
|
2405
|
+
return {
|
|
2406
|
+
agentId: j.agent_id,
|
|
2407
|
+
tenantId: j.tenant_id,
|
|
2408
|
+
enclaveId: j.enclave_id,
|
|
2409
|
+
bindingId: j.binding_id,
|
|
2410
|
+
bindingVersion: j.binding_version,
|
|
2411
|
+
owningOrg: j.owning_org
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
async joinChannel(channelId, types, caps) {
|
|
2415
|
+
const res = await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
|
|
2416
|
+
method: "POST",
|
|
2417
|
+
headers: this.headers(),
|
|
2418
|
+
body: JSON.stringify({ declared_message_types: types, declared_capabilities: caps })
|
|
2419
|
+
});
|
|
2420
|
+
if (!res.ok)
|
|
2421
|
+
throw new Error(`joinChannel failed: ${res.status}`);
|
|
2422
|
+
}
|
|
2423
|
+
async leaveChannel(channelId) {
|
|
2424
|
+
await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
|
|
2425
|
+
method: "DELETE",
|
|
2426
|
+
headers: this.headers()
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
async publish(envelope) {
|
|
2430
|
+
const res = await this.f(this.opts.baseUrl + PATHS.messages, {
|
|
2431
|
+
method: "POST",
|
|
2432
|
+
headers: this.headers(),
|
|
2433
|
+
body: JSON.stringify(envelope)
|
|
2434
|
+
});
|
|
2435
|
+
if (res.status === 429) {
|
|
2436
|
+
const retry = Number(res.headers.get("retry-after") ?? "1");
|
|
2437
|
+
throw new RateLimitError("server rate limit", Number.isFinite(retry) ? retry : 1);
|
|
2438
|
+
}
|
|
2439
|
+
if (res.status === 400) {
|
|
2440
|
+
const stale = await staleEpochErrorFromResponse(res);
|
|
2441
|
+
if (stale)
|
|
2442
|
+
throw stale;
|
|
2443
|
+
}
|
|
2444
|
+
if (!res.ok)
|
|
2445
|
+
throw new Error(`publish failed: ${res.status}`);
|
|
2446
|
+
const j = await res.json();
|
|
2447
|
+
return {
|
|
2448
|
+
messageId: String(j.message_id),
|
|
2449
|
+
auditRecordId: String(j.audit_record_id),
|
|
2450
|
+
thisHash: String(j.this_hash),
|
|
2451
|
+
sequenceNumber: Number(j.sequence_number)
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
subscribeDeliveryPings(_channelId) {
|
|
2455
|
+
let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
|
|
2456
|
+
if (this.opts.connectIdentityId) {
|
|
2457
|
+
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
2458
|
+
}
|
|
2459
|
+
const handle = openDeliveryPings({ url: wsUrl, tokenProvider: this.tokenProvider, wsImpl: this.ws });
|
|
2460
|
+
this._controlSend = handle.send;
|
|
2461
|
+
const close = () => {
|
|
2462
|
+
if (this._controlSend === handle.send)
|
|
2463
|
+
this._controlSend = null;
|
|
2464
|
+
handle.close();
|
|
2465
|
+
};
|
|
2466
|
+
return { pings: handle.pings, close, send: handle.send };
|
|
2467
|
+
}
|
|
2468
|
+
sendControlFrame(_channelId, frame) {
|
|
2469
|
+
try {
|
|
2470
|
+
this._controlSend?.(frame);
|
|
2471
|
+
} catch {
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
async queryAudit(query) {
|
|
2475
|
+
const qs = new URLSearchParams(query).toString();
|
|
2476
|
+
const res = await this.f(`${this.opts.baseUrl}${PATHS.auditRecords}?${qs}`, {
|
|
2477
|
+
method: "GET",
|
|
2478
|
+
headers: this.headers()
|
|
2479
|
+
});
|
|
2480
|
+
if (res.status === 403)
|
|
2481
|
+
throw new AuditAccessDenied("audit query exceeded caller rights");
|
|
2482
|
+
if (!res.ok)
|
|
2483
|
+
throw new Error(`queryAudit failed: ${res.status}`);
|
|
2484
|
+
const j = await res.json();
|
|
2485
|
+
return (j.records ?? []).map((r) => ({
|
|
2486
|
+
recordId: String(r.record_id),
|
|
2487
|
+
sequenceNumber: Number(r.sequence_number),
|
|
2488
|
+
occurredAt: String(r.occurred_at),
|
|
2489
|
+
messageType: r.message_type ? String(r.message_type) : void 0,
|
|
2490
|
+
channelId: r.channel_id ? String(r.channel_id) : void 0
|
|
2491
|
+
}));
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
// ../connect-sdk-ts/dist/mls/rest-backend.js
|
|
2496
|
+
var RestMlsBackend = class {
|
|
2497
|
+
opts;
|
|
2498
|
+
f;
|
|
2499
|
+
constructor(opts) {
|
|
2500
|
+
this.opts = opts;
|
|
2501
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
2502
|
+
}
|
|
2503
|
+
headers() {
|
|
2504
|
+
return {
|
|
2505
|
+
"content-type": "application/json",
|
|
2506
|
+
authorization: `Bearer ${this.opts.bearerToken}`
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
async post(path, body) {
|
|
2510
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
2511
|
+
method: "POST",
|
|
2512
|
+
headers: this.headers(),
|
|
2513
|
+
body: JSON.stringify(body)
|
|
2514
|
+
});
|
|
2515
|
+
if (!res.ok)
|
|
2516
|
+
throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
|
|
2517
|
+
return res.status === 204 ? {} : await res.json();
|
|
2518
|
+
}
|
|
2519
|
+
async get(path) {
|
|
2520
|
+
const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
|
|
2521
|
+
if (!res.ok)
|
|
2522
|
+
throw new Error(`${path} failed: ${res.status}`);
|
|
2523
|
+
return await res.json();
|
|
2524
|
+
}
|
|
2525
|
+
async getRegisterChallenge() {
|
|
2526
|
+
const j = await this.post("/api/v1/connect/devices/register-challenge", {});
|
|
2527
|
+
return String(j.challenge);
|
|
2528
|
+
}
|
|
2529
|
+
async registerDevice(req) {
|
|
2530
|
+
const j = await this.post("/api/v1/connect/devices/register", req);
|
|
2531
|
+
return { device_id: String(j.device_id) };
|
|
2532
|
+
}
|
|
2533
|
+
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
2534
|
+
const j = await this.post("/api/v1/mls/key-packages", {
|
|
2535
|
+
key_package: keyPackageHex,
|
|
2536
|
+
device_id: deviceId
|
|
2537
|
+
});
|
|
2538
|
+
return String(j.id);
|
|
2539
|
+
}
|
|
2540
|
+
async getConnectChannelGroup(channelId) {
|
|
2541
|
+
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
|
|
2542
|
+
return {
|
|
2543
|
+
exists: Boolean(j.exists),
|
|
2544
|
+
group_id: j.group_id != null ? String(j.group_id) : null,
|
|
2545
|
+
current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
2549
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/init`, {
|
|
2550
|
+
member_device_ids: memberDeviceIds
|
|
2551
|
+
});
|
|
2552
|
+
return {
|
|
2553
|
+
group_id: String(j.group_id),
|
|
2554
|
+
current_epoch: Number(j.current_epoch),
|
|
2555
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
|
|
2556
|
+
// Back-compat: servers that predate the created field return nothing;
|
|
2557
|
+
// treat absence as true (they always created a fresh group).
|
|
2558
|
+
created: j.created === void 0 ? true : Boolean(j.created)
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
async reinitConnectChannelGroup(channelId, memberDeviceIds) {
|
|
2562
|
+
const committer = memberDeviceIds[0];
|
|
2563
|
+
if (!committer)
|
|
2564
|
+
throw new Error("reinitConnectChannelGroup: empty member set");
|
|
2565
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
2566
|
+
member_device_ids: memberDeviceIds,
|
|
2567
|
+
committer_device_id: committer
|
|
2568
|
+
});
|
|
2569
|
+
return {
|
|
2570
|
+
group_id: String(j.group_id),
|
|
2571
|
+
current_epoch: Number(j.current_epoch),
|
|
2572
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
|
|
2576
|
+
const committer = memberDeviceIds[0];
|
|
2577
|
+
if (!committer)
|
|
2578
|
+
throw new Error("resetReinitConnectChannelGroup: empty member set");
|
|
2579
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
2580
|
+
member_device_ids: memberDeviceIds,
|
|
2581
|
+
committer_device_id: committer,
|
|
2582
|
+
mode: "reset",
|
|
2583
|
+
new_group_id: att.newGroupId,
|
|
2584
|
+
reset_attestation_signature: att.signatureHex,
|
|
2585
|
+
reset_attestation_nonce: att.nonceHex,
|
|
2586
|
+
reset_attestation_content: att.signedContentHex
|
|
2587
|
+
});
|
|
2588
|
+
return {
|
|
2589
|
+
group_id: String(j.group_id),
|
|
2590
|
+
current_epoch: Number(j.current_epoch),
|
|
2591
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
async fetchKeyPackages(deviceIds) {
|
|
2595
|
+
const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
|
|
2596
|
+
return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
|
|
2597
|
+
}
|
|
2598
|
+
async fetchKeyPackageById(kpId) {
|
|
2599
|
+
try {
|
|
2600
|
+
const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
|
|
2601
|
+
return String(j.key_package);
|
|
2602
|
+
} catch {
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
async distribute(groupId, req) {
|
|
2607
|
+
await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
|
|
2608
|
+
...req,
|
|
2609
|
+
group_id: groupId
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
async pullDelivery(deviceId) {
|
|
2613
|
+
const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
|
|
2614
|
+
return j.messages ?? [];
|
|
2615
|
+
}
|
|
2616
|
+
async ackDelivery(deviceId, queueIds) {
|
|
2617
|
+
await this.post("/api/v1/mls/delivery/ack", {
|
|
2618
|
+
device_id: deviceId,
|
|
2619
|
+
queue_ids: queueIds
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
async requestWelcome(groupId, deviceId, freshKpId) {
|
|
2623
|
+
const body = { device_id: deviceId };
|
|
2624
|
+
if (freshKpId)
|
|
2625
|
+
body.fresh_kp_id = freshKpId;
|
|
2626
|
+
await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
|
|
2627
|
+
}
|
|
2628
|
+
async getCommitterWork(channelId) {
|
|
2629
|
+
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
|
|
2630
|
+
return {
|
|
2631
|
+
adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
|
|
2632
|
+
removes: Array.isArray(j.removes) ? j.removes.map(String) : []
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
async acquireCommitLease(channelId) {
|
|
2636
|
+
const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
|
|
2637
|
+
if (res.status === 409 || res.status === 503)
|
|
2638
|
+
return null;
|
|
2639
|
+
if (!res.ok)
|
|
2640
|
+
throw new Error(`commit-lease ${res.status}`);
|
|
2641
|
+
const j = await res.json();
|
|
2642
|
+
return String(j.token);
|
|
2643
|
+
}
|
|
2644
|
+
async releaseCommitLease(channelId, token) {
|
|
2645
|
+
await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
async fetchResetAttestation(groupId) {
|
|
2649
|
+
try {
|
|
2650
|
+
const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
|
|
2651
|
+
return {
|
|
2652
|
+
signedContent: String(j.signed_content),
|
|
2653
|
+
signatureHex: String(j.signature_hex),
|
|
2654
|
+
signerIdentityId: String(j.signer_identity_id)
|
|
2655
|
+
};
|
|
2656
|
+
} catch {
|
|
2657
|
+
return null;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
|
|
2662
|
+
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
2663
|
+
import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
|
|
2664
|
+
|
|
2665
|
+
// ../connect-sdk-ts/dist/receive/inbox-cipher.js
|
|
2666
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
2667
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha256";
|
|
2668
|
+
var SUITE = "xchacha20poly1305";
|
|
2669
|
+
var INFO = new TextEncoder().encode("agentvault-connect-inbox-v1");
|
|
2670
|
+
var EMPTY_AAD = new Uint8Array(0);
|
|
2671
|
+
var KeyedInboxCipher = class {
|
|
2672
|
+
key;
|
|
2673
|
+
constructor(key) {
|
|
2674
|
+
this.key = key;
|
|
2675
|
+
}
|
|
2676
|
+
async seal(plaintext) {
|
|
2677
|
+
return sealPayload({
|
|
2678
|
+
plaintext,
|
|
2679
|
+
key: this.key,
|
|
2680
|
+
associatedData: EMPTY_AAD,
|
|
2681
|
+
suite: SUITE
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
async open(sealed) {
|
|
2685
|
+
return openPayload({
|
|
2686
|
+
ciphertext: sealed,
|
|
2687
|
+
key: this.key,
|
|
2688
|
+
associatedData: EMPTY_AAD,
|
|
2689
|
+
suite: SUITE
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
var MLS_STORE_INFO = new TextEncoder().encode("agentvault-connect-mls-store-v1");
|
|
2694
|
+
function deriveMlsStoreKey(identityKeyMaterial) {
|
|
2695
|
+
return hkdf(sha2562, identityKeyMaterial, void 0, MLS_STORE_INFO, 32);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// ../connect-sdk-ts/dist/recovery/backup-crypto.js
|
|
2699
|
+
import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
|
|
2700
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha256";
|
|
2701
|
+
import { randomBytes as randomBytes2 } from "@noble/hashes/utils";
|
|
2702
|
+
var BACKUP_INFO = new TextEncoder().encode("connect-backup-v1");
|
|
2703
|
+
var RECOVERY_INFO = new TextEncoder().encode("connect-recovery-v1");
|
|
2704
|
+
|
|
2705
|
+
// ../connect-sdk-ts/dist/recovery/guest-recovery-crypto.js
|
|
2706
|
+
import { argon2idAsync } from "@noble/hashes/argon2";
|
|
2707
|
+
import { hkdf as hkdf3 } from "@noble/hashes/hkdf";
|
|
2708
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha256";
|
|
2709
|
+
var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
|
|
2710
|
+
|
|
2711
|
+
// ../connect-mls-store/dist/sqlite-store.js
|
|
2712
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2713
|
+
|
|
2714
|
+
// ../connect-mls-store/dist/schema.js
|
|
2715
|
+
var SCHEMA_VERSION = 3;
|
|
2716
|
+
function openSchema(db) {
|
|
2717
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
2718
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
2719
|
+
if (row.user_version === 0) {
|
|
2720
|
+
db.exec(`
|
|
2721
|
+
CREATE TABLE IF NOT EXISTS mls_group_state (
|
|
2722
|
+
identity_id TEXT NOT NULL,
|
|
2723
|
+
channel_id TEXT NOT NULL,
|
|
2724
|
+
sealed_main BLOB NOT NULL,
|
|
2725
|
+
sealed_staging BLOB,
|
|
2726
|
+
updated_at INTEGER NOT NULL,
|
|
2727
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
2728
|
+
);
|
|
2729
|
+
`);
|
|
2730
|
+
}
|
|
2731
|
+
if (row.user_version < 2) {
|
|
2732
|
+
db.exec(`
|
|
2733
|
+
CREATE TABLE IF NOT EXISTS mls_identity_pin (
|
|
2734
|
+
owner_identity_id TEXT PRIMARY KEY,
|
|
2735
|
+
public_key_hex TEXT NOT NULL,
|
|
2736
|
+
key_algo TEXT NOT NULL,
|
|
2737
|
+
pinned_at INTEGER NOT NULL
|
|
2738
|
+
);
|
|
2739
|
+
`);
|
|
2740
|
+
}
|
|
2741
|
+
if (row.user_version < 3) {
|
|
2742
|
+
db.exec(`
|
|
2743
|
+
CREATE TABLE IF NOT EXISTS mls_retired_group (
|
|
2744
|
+
channel_id TEXT NOT NULL,
|
|
2745
|
+
group_id TEXT NOT NULL,
|
|
2746
|
+
PRIMARY KEY (channel_id, group_id)
|
|
2747
|
+
);
|
|
2748
|
+
`);
|
|
2749
|
+
}
|
|
2750
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
// ../connect-mls-store/dist/errors.js
|
|
2754
|
+
var MlsStoreCorruptError = class extends Error {
|
|
2755
|
+
constructor(detail) {
|
|
2756
|
+
super(`MLS store corrupt: ${detail}`);
|
|
2757
|
+
this.name = "MlsStoreCorruptError";
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
var MlsStoreSealError = class extends Error {
|
|
2761
|
+
constructor(detail) {
|
|
2762
|
+
super(`MLS store seal/open failed: ${detail}`);
|
|
2763
|
+
this.name = "MlsStoreSealError";
|
|
2764
|
+
}
|
|
2765
|
+
};
|
|
2766
|
+
|
|
2767
|
+
// ../connect-mls-store/dist/sqlite-store.js
|
|
2768
|
+
var SqliteMlsStateStore = class {
|
|
2769
|
+
db;
|
|
2770
|
+
id;
|
|
2771
|
+
cipher;
|
|
2772
|
+
// Bundles stay in-memory (spec §4): private half not durably serializable.
|
|
2773
|
+
// Per-instance map → per-identity store instances are naturally isolated (§6.1).
|
|
2774
|
+
bundles = /* @__PURE__ */ new Map();
|
|
2775
|
+
// Last-published-KP-id: same in-memory-only treatment as bundles above (§4) —
|
|
2776
|
+
// a non-secret pointer, but this store's durability layer only covers group
|
|
2777
|
+
// state today; durable persistence is a future follow-up, not this task's scope.
|
|
2778
|
+
lastKpIds = /* @__PURE__ */ new Map();
|
|
2779
|
+
constructor(opts) {
|
|
2780
|
+
this.id = opts.identityId;
|
|
2781
|
+
this.cipher = opts.cipherFor(opts.identityId);
|
|
2782
|
+
try {
|
|
2783
|
+
this.db = new DatabaseSync(opts.dbPath);
|
|
2784
|
+
} catch (e) {
|
|
2785
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
openSchema(this.db);
|
|
2789
|
+
} catch (e) {
|
|
2790
|
+
this.db.close();
|
|
2791
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2792
|
+
}
|
|
2793
|
+
if (opts.dbPath !== ":memory:") {
|
|
2794
|
+
let r;
|
|
2795
|
+
try {
|
|
2796
|
+
r = this.db.prepare("PRAGMA integrity_check").get();
|
|
2797
|
+
} catch (e) {
|
|
2798
|
+
this.db.close();
|
|
2799
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2800
|
+
}
|
|
2801
|
+
if (r.integrity_check !== "ok") {
|
|
2802
|
+
this.db.close();
|
|
2803
|
+
throw new MlsStoreCorruptError(r.integrity_check);
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
async seal(state) {
|
|
2808
|
+
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
|
|
2809
|
+
}
|
|
2810
|
+
async unseal(blob) {
|
|
2811
|
+
let bytes;
|
|
2812
|
+
try {
|
|
2813
|
+
bytes = await this.cipher.open(blob);
|
|
2814
|
+
} catch (e) {
|
|
2815
|
+
throw new MlsStoreSealError(String(e?.message ?? e));
|
|
2816
|
+
}
|
|
2817
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
2818
|
+
}
|
|
2819
|
+
async load(channelId) {
|
|
2820
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
2821
|
+
if (!row)
|
|
2822
|
+
return null;
|
|
2823
|
+
return this.unseal(row.sealed_main);
|
|
2824
|
+
}
|
|
2825
|
+
async save(channelId, state) {
|
|
2826
|
+
const sealed = await this.seal(state);
|
|
2827
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
2828
|
+
VALUES (?,?,?,?)
|
|
2829
|
+
ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, sealed, Date.now());
|
|
2830
|
+
}
|
|
2831
|
+
async delete(channelId) {
|
|
2832
|
+
this.db.prepare("DELETE FROM mls_group_state WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
2833
|
+
}
|
|
2834
|
+
async saveStaging(channelId, state) {
|
|
2835
|
+
const sealed = await this.seal(state);
|
|
2836
|
+
this.db.prepare("UPDATE mls_group_state SET sealed_staging=?, updated_at=? WHERE identity_id=? AND channel_id=?").run(sealed, Date.now(), this.id, channelId);
|
|
2837
|
+
}
|
|
2838
|
+
async promoteStaging(channelId) {
|
|
2839
|
+
this.db.prepare(`UPDATE mls_group_state SET sealed_main=sealed_staging, sealed_staging=NULL, updated_at=?
|
|
2840
|
+
WHERE identity_id=? AND channel_id=? AND sealed_staging IS NOT NULL`).run(Date.now(), this.id, channelId);
|
|
2841
|
+
}
|
|
2842
|
+
async discardStaging(channelId) {
|
|
2843
|
+
this.db.prepare("UPDATE mls_group_state SET sealed_staging=NULL WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
2844
|
+
}
|
|
2845
|
+
async savePendingBundle(ref, bundle) {
|
|
2846
|
+
this.bundles.set(ref, bundle);
|
|
2847
|
+
}
|
|
2848
|
+
async loadPendingBundle(ref) {
|
|
2849
|
+
return this.bundles.get(ref) ?? null;
|
|
2850
|
+
}
|
|
2851
|
+
async deletePendingBundle(ref) {
|
|
2852
|
+
this.bundles.delete(ref);
|
|
2853
|
+
}
|
|
2854
|
+
async saveLastKpId(deviceId, id) {
|
|
2855
|
+
this.lastKpIds.set(deviceId, id);
|
|
2856
|
+
}
|
|
2857
|
+
async loadLastKpId(deviceId) {
|
|
2858
|
+
return this.lastKpIds.get(deviceId) ?? null;
|
|
2859
|
+
}
|
|
2860
|
+
async saveLastKpAndBundle(deviceId, id, bundle) {
|
|
2861
|
+
this.lastKpIds.set(deviceId, id);
|
|
2862
|
+
this.bundles.set(deviceId, bundle);
|
|
2863
|
+
}
|
|
2864
|
+
async savePinnedIdentityKey(identityId, key) {
|
|
2865
|
+
this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
|
|
2866
|
+
VALUES (?,?,?,?)
|
|
2867
|
+
ON CONFLICT(owner_identity_id) DO UPDATE SET
|
|
2868
|
+
public_key_hex=excluded.public_key_hex, key_algo=excluded.key_algo, pinned_at=excluded.pinned_at`).run(identityId, key.publicKeyHex, key.keyAlgo, Date.now());
|
|
2869
|
+
}
|
|
2870
|
+
async loadPinnedIdentityKey(identityId) {
|
|
2871
|
+
const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
|
|
2872
|
+
return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
|
|
2873
|
+
}
|
|
2874
|
+
async addRetiredGroupId(channelId, groupId) {
|
|
2875
|
+
this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
|
|
2876
|
+
}
|
|
2877
|
+
async loadRetiredGroupIds(channelId) {
|
|
2878
|
+
const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
|
|
2879
|
+
return rows.map((r) => r.group_id);
|
|
2880
|
+
}
|
|
2881
|
+
/** @internal test-only */
|
|
2882
|
+
_rawMainForTest(identityId, channelId) {
|
|
2883
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
|
|
2884
|
+
return row?.sealed_main;
|
|
2885
|
+
}
|
|
2886
|
+
close() {
|
|
2887
|
+
this.db.close();
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
|
|
2891
|
+
// src/bridge-agent.ts
|
|
2892
|
+
import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
|
|
2893
|
+
import { promises as fsp } from "node:fs";
|
|
2894
|
+
import { join, dirname } from "node:path";
|
|
2895
|
+
|
|
2896
|
+
// src/latency.ts
|
|
2897
|
+
var DEFAULT_CAPACITY = 1024;
|
|
2898
|
+
function round(n) {
|
|
2899
|
+
return Math.round(n * 100) / 100;
|
|
2900
|
+
}
|
|
2901
|
+
function percentile(sorted, p) {
|
|
2902
|
+
if (sorted.length === 0) return 0;
|
|
2903
|
+
const rank = Math.ceil(p / 100 * sorted.length);
|
|
2904
|
+
const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
|
|
2905
|
+
return sorted[idx];
|
|
2906
|
+
}
|
|
2907
|
+
var LatencyRecorder = class {
|
|
2908
|
+
capacity;
|
|
2909
|
+
// Per-stage ring buffer of recent samples (ms).
|
|
2910
|
+
buffers = /* @__PURE__ */ new Map();
|
|
2911
|
+
// Write cursor per stage (for the ring buffer wrap).
|
|
2912
|
+
cursors = /* @__PURE__ */ new Map();
|
|
2913
|
+
constructor(opts = {}) {
|
|
2914
|
+
this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
|
|
2915
|
+
}
|
|
2916
|
+
/** Record a single sample (ms) for a stage. O(1). */
|
|
2917
|
+
record(stage, ms) {
|
|
2918
|
+
let buf = this.buffers.get(stage);
|
|
2919
|
+
if (!buf) {
|
|
2920
|
+
buf = [];
|
|
2921
|
+
this.buffers.set(stage, buf);
|
|
2922
|
+
this.cursors.set(stage, 0);
|
|
2923
|
+
}
|
|
2924
|
+
if (buf.length < this.capacity) {
|
|
2925
|
+
buf.push(ms);
|
|
2926
|
+
} else {
|
|
2927
|
+
const cur = this.cursors.get(stage) ?? 0;
|
|
2928
|
+
buf[cur] = ms;
|
|
2929
|
+
this.cursors.set(stage, (cur + 1) % this.capacity);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Time an async fn, record its elapsed wall time under `stage`, return its
|
|
2934
|
+
* value. Records even when the fn throws (the failure still consumed time),
|
|
2935
|
+
* then re-throws — so a thrown error never loses the timing sample.
|
|
2936
|
+
*/
|
|
2937
|
+
async time(stage, fn) {
|
|
2938
|
+
const start = performance.now();
|
|
2939
|
+
try {
|
|
2940
|
+
return await fn();
|
|
2941
|
+
} finally {
|
|
2942
|
+
this.record(stage, performance.now() - start);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
/** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
|
|
2946
|
+
snapshot() {
|
|
2947
|
+
const out = {};
|
|
2948
|
+
for (const [stage, buf] of this.buffers) {
|
|
2949
|
+
if (buf.length === 0) continue;
|
|
2950
|
+
const sorted = [...buf].sort((a, b) => a - b);
|
|
2951
|
+
const sum = sorted.reduce((acc, n) => acc + n, 0);
|
|
2952
|
+
out[stage] = {
|
|
2953
|
+
count: sorted.length,
|
|
2954
|
+
p50: round(percentile(sorted, 50)),
|
|
2955
|
+
p95: round(percentile(sorted, 95)),
|
|
2956
|
+
p99: round(percentile(sorted, 99)),
|
|
2957
|
+
max: round(sorted[sorted.length - 1]),
|
|
2958
|
+
mean: round(sum / sorted.length)
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
return out;
|
|
2962
|
+
}
|
|
2963
|
+
};
|
|
2964
|
+
|
|
2965
|
+
// src/coordination.ts
|
|
2966
|
+
function parseMentions(text) {
|
|
2967
|
+
const mentions = [];
|
|
2968
|
+
const re = /@(\w[\w]*)/gi;
|
|
2969
|
+
let match;
|
|
2970
|
+
while ((match = re.exec(text)) !== null) {
|
|
2971
|
+
mentions.push(match[1].toLowerCase());
|
|
2972
|
+
}
|
|
2973
|
+
return mentions;
|
|
2974
|
+
}
|
|
2975
|
+
function ownRoomNames(agentName, accountId, roomDisplayName) {
|
|
2976
|
+
const names = /* @__PURE__ */ new Set();
|
|
2977
|
+
const add = (n) => {
|
|
2978
|
+
if (!n) return;
|
|
2979
|
+
const lower = n.toLowerCase();
|
|
2980
|
+
names.add(lower);
|
|
2981
|
+
names.add(lower.split(/[\s(]/)[0]);
|
|
2982
|
+
};
|
|
2983
|
+
add(agentName);
|
|
2984
|
+
add(accountId);
|
|
2985
|
+
add(roomDisplayName);
|
|
2986
|
+
return names;
|
|
2987
|
+
}
|
|
2988
|
+
function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
|
|
2989
|
+
const mentions = parseMentions(plaintext);
|
|
2990
|
+
if (mentions.length === 0) return true;
|
|
2991
|
+
if (mentions.includes("all") || mentions.includes("everyone")) return true;
|
|
2992
|
+
const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
|
|
2993
|
+
if (mentions.some((m) => ownNames.has(m))) return true;
|
|
2994
|
+
return false;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// src/rate-limit.ts
|
|
2998
|
+
var TokenBucketFloor = class {
|
|
2999
|
+
capacity;
|
|
3000
|
+
refillPerMs;
|
|
3001
|
+
now;
|
|
3002
|
+
buckets = /* @__PURE__ */ new Map();
|
|
3003
|
+
constructor(opts) {
|
|
3004
|
+
this.capacity = opts.capacity;
|
|
3005
|
+
this.refillPerMs = opts.refillPerMinute / 6e4;
|
|
3006
|
+
this.now = opts.now ?? Date.now;
|
|
3007
|
+
}
|
|
3008
|
+
refill(key) {
|
|
3009
|
+
const t = this.now();
|
|
3010
|
+
let b = this.buckets.get(key);
|
|
3011
|
+
if (!b) {
|
|
3012
|
+
b = { tokens: this.capacity, lastRefill: t };
|
|
3013
|
+
this.buckets.set(key, b);
|
|
3014
|
+
return b;
|
|
3015
|
+
}
|
|
3016
|
+
const elapsed = t - b.lastRefill;
|
|
3017
|
+
if (elapsed > 0) {
|
|
3018
|
+
b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerMs);
|
|
3019
|
+
b.lastRefill = t;
|
|
3020
|
+
}
|
|
3021
|
+
return b;
|
|
3022
|
+
}
|
|
3023
|
+
/** Consume one token. Returns true if allowed, false if the bucket is empty. */
|
|
3024
|
+
tryConsume(key) {
|
|
3025
|
+
if (this.capacity <= 0) return true;
|
|
3026
|
+
const b = this.refill(key);
|
|
3027
|
+
if (b.tokens >= 1) {
|
|
3028
|
+
b.tokens -= 1;
|
|
3029
|
+
return true;
|
|
3030
|
+
}
|
|
3031
|
+
return false;
|
|
3032
|
+
}
|
|
3033
|
+
/** Milliseconds until one token is available (0 if already available). */
|
|
3034
|
+
msUntilNextToken(key) {
|
|
3035
|
+
if (this.capacity <= 0) return 0;
|
|
3036
|
+
const b = this.refill(key);
|
|
3037
|
+
if (b.tokens >= 1) return 0;
|
|
3038
|
+
if (this.refillPerMs <= 0) return Infinity;
|
|
3039
|
+
return Math.ceil((1 - b.tokens) / this.refillPerMs);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
|
|
3043
|
+
// src/attachments.ts
|
|
3044
|
+
import { basename } from "node:path";
|
|
3045
|
+
function sanitizeFilename(name, fallback) {
|
|
3046
|
+
const base = basename(name ?? "");
|
|
3047
|
+
const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
|
|
3048
|
+
return cleaned.length > 0 ? cleaned : fallback;
|
|
3049
|
+
}
|
|
3050
|
+
var MIME_BY_EXT = {
|
|
3051
|
+
png: "image/png",
|
|
3052
|
+
jpg: "image/jpeg",
|
|
3053
|
+
jpeg: "image/jpeg",
|
|
3054
|
+
gif: "image/gif",
|
|
3055
|
+
webp: "image/webp",
|
|
3056
|
+
svg: "image/svg+xml",
|
|
3057
|
+
pdf: "application/pdf",
|
|
3058
|
+
txt: "text/plain",
|
|
3059
|
+
md: "text/markdown",
|
|
3060
|
+
json: "application/json",
|
|
3061
|
+
csv: "text/csv",
|
|
3062
|
+
html: "text/html",
|
|
3063
|
+
mp4: "video/mp4",
|
|
3064
|
+
mp3: "audio/mpeg",
|
|
3065
|
+
zip: "application/zip",
|
|
3066
|
+
wav: "audio/wav"
|
|
3067
|
+
};
|
|
3068
|
+
function mimeForName(name) {
|
|
3069
|
+
const dot = name.lastIndexOf(".");
|
|
3070
|
+
if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
|
|
3071
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
3072
|
+
}
|
|
3073
|
+
async function prepareAttachment(filePath, caption, channelId, deps) {
|
|
3074
|
+
const bytes = await deps.readFile(filePath);
|
|
3075
|
+
const name = basename(filePath);
|
|
3076
|
+
const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
|
|
3077
|
+
const blobId = await deps.uploadBlob(encryptedData, channelId);
|
|
3078
|
+
const body = {
|
|
3079
|
+
v: 1,
|
|
3080
|
+
type: "artifact_share",
|
|
3081
|
+
name,
|
|
3082
|
+
mime: mimeForName(name),
|
|
3083
|
+
size: bytes.length,
|
|
3084
|
+
blobId,
|
|
3085
|
+
fileKey: deps.bytesToBase64(fileKey),
|
|
3086
|
+
fileNonce: deps.bytesToBase64(fileNonce),
|
|
3087
|
+
digest
|
|
3088
|
+
};
|
|
3089
|
+
if (caption) body.text = caption;
|
|
3090
|
+
return body;
|
|
3091
|
+
}
|
|
3092
|
+
async function materializeAttachment(body, ctx, deps) {
|
|
3093
|
+
const ciphertext = await deps.downloadBlob(body.blobId);
|
|
3094
|
+
const sha2565 = deps.computeFileDigest(ciphertext);
|
|
3095
|
+
if (sha2565 !== body.digest) {
|
|
3096
|
+
throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
|
|
3097
|
+
}
|
|
3098
|
+
const plain = deps.decryptFile(
|
|
3099
|
+
ciphertext,
|
|
3100
|
+
deps.base64ToBytes(body.fileKey),
|
|
3101
|
+
deps.base64ToBytes(body.fileNonce)
|
|
3102
|
+
);
|
|
3103
|
+
const safeName = sanitizeFilename(body.name, ctx.messageId);
|
|
3104
|
+
const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
|
|
3105
|
+
const path = `${dir}/${ctx.messageId}-${safeName}`;
|
|
3106
|
+
await deps.mkdirp(dir);
|
|
3107
|
+
await deps.writeFile(path, plain);
|
|
3108
|
+
return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// src/bridge-agent.ts
|
|
3112
|
+
var DEFAULT_MAX_BUFFER = 1e3;
|
|
3113
|
+
var CHANNEL_META_TTL_MS = 5 * 6e4;
|
|
3114
|
+
var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
|
|
3115
|
+
var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
|
|
3116
|
+
var ChannelPendingError = class extends Error {
|
|
3117
|
+
constructor(channelId) {
|
|
3118
|
+
super(`channel ${channelId} is not yet admitted (awaiting Welcome)`);
|
|
3119
|
+
this.channelId = channelId;
|
|
3120
|
+
this.name = "ChannelPendingError";
|
|
3121
|
+
}
|
|
3122
|
+
};
|
|
3123
|
+
var RateLimitedError = class extends Error {
|
|
3124
|
+
constructor(channelId, retryAfterMs) {
|
|
3125
|
+
super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
|
|
3126
|
+
this.channelId = channelId;
|
|
3127
|
+
this.retryAfterMs = retryAfterMs;
|
|
3128
|
+
this.name = "RateLimitedError";
|
|
3129
|
+
}
|
|
3130
|
+
};
|
|
3131
|
+
var InvalidPathError = class extends Error {
|
|
3132
|
+
constructor(filePath, cause) {
|
|
3133
|
+
super(`cannot read attachment file: ${filePath}`);
|
|
3134
|
+
this.filePath = filePath;
|
|
3135
|
+
this.name = "InvalidPathError";
|
|
3136
|
+
if (cause !== void 0) this.cause = cause;
|
|
3137
|
+
}
|
|
3138
|
+
};
|
|
3139
|
+
var UnknownActionRunError = class extends Error {
|
|
3140
|
+
constructor(runId) {
|
|
3141
|
+
super(`unknown or already-settled agent action run: ${runId}`);
|
|
3142
|
+
this.runId = runId;
|
|
3143
|
+
this.name = "UnknownActionRunError";
|
|
3144
|
+
}
|
|
3145
|
+
};
|
|
3146
|
+
var BRIDGE_VERSION = "0.0.0";
|
|
3147
|
+
function hexToBytes3(h) {
|
|
3148
|
+
return new Uint8Array(Buffer.from(h, "hex"));
|
|
3149
|
+
}
|
|
3150
|
+
var BridgeAgent = class _BridgeAgent {
|
|
3151
|
+
constructor(cfg, deps = {}) {
|
|
3152
|
+
this.cfg = cfg;
|
|
3153
|
+
const seed = hexToBytes3(cfg.signingPrivateKey);
|
|
3154
|
+
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
3155
|
+
this.transport = deps.transport ?? new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
|
|
3156
|
+
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
3157
|
+
this.store = new SqliteMlsStateStore({
|
|
3158
|
+
dbPath: cfg.storePath,
|
|
3159
|
+
identityId: cfg.identityId,
|
|
3160
|
+
cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed))
|
|
3161
|
+
});
|
|
3162
|
+
this.provider = new MlsGroupKeyProvider({
|
|
3163
|
+
store: this.store,
|
|
3164
|
+
backend: this.backend,
|
|
3165
|
+
deviceId: cfg.deviceId,
|
|
3166
|
+
identity: new TextEncoder().encode(cfg.identityId),
|
|
3167
|
+
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
3168
|
+
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
3169
|
+
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
3170
|
+
onDecryptError: (row, err) => {
|
|
3171
|
+
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
3172
|
+
console.warn(
|
|
3173
|
+
`[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`
|
|
3174
|
+
);
|
|
3175
|
+
},
|
|
3176
|
+
// Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
|
|
3177
|
+
// calls this before honoring an owner-authored supersede. It must return
|
|
3178
|
+
// a value ONLY when the structural owner is unambiguous AND a pin exists
|
|
3179
|
+
// for it — never trust a bare in-memory cache without reconfirming
|
|
3180
|
+
// against the persisted pin (see resolvePinnedOwnerHook below).
|
|
3181
|
+
onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId)
|
|
3182
|
+
});
|
|
3183
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
3184
|
+
this.latency = deps.latency ?? new LatencyRecorder();
|
|
3185
|
+
this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
3186
|
+
this.rateFloor = new TokenBucketFloor({
|
|
3187
|
+
capacity: cfg.rateLimitCapacity ?? 20,
|
|
3188
|
+
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
3189
|
+
});
|
|
3190
|
+
this.actionClock = deps.actionThrottleNow ?? Date.now;
|
|
3191
|
+
this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
3192
|
+
this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
|
|
3193
|
+
const self = this;
|
|
3194
|
+
this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
|
|
3195
|
+
const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
3196
|
+
const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
|
|
3197
|
+
if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
|
|
3198
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
3199
|
+
};
|
|
3200
|
+
this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
|
|
3201
|
+
const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
3202
|
+
const res = await self.fetchImpl(url, {
|
|
3203
|
+
method: "POST",
|
|
3204
|
+
headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
|
|
3205
|
+
body: ciphertext
|
|
3206
|
+
});
|
|
3207
|
+
if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
|
|
3208
|
+
const j = await res.json();
|
|
3209
|
+
return String(j.attachment_id);
|
|
3210
|
+
};
|
|
3211
|
+
this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
|
|
3212
|
+
this.action = {
|
|
3213
|
+
begin: (channelId) => this.actionBegin(channelId),
|
|
3214
|
+
start: (opts) => this.actionStart(opts),
|
|
3215
|
+
update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
|
|
3216
|
+
finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
|
|
3217
|
+
settle: (runId, opts) => this.actionSettle(runId, opts),
|
|
3218
|
+
wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3221
|
+
transport;
|
|
3222
|
+
provider;
|
|
3223
|
+
backend;
|
|
3224
|
+
// stored for peek-then-join in ingestOnce
|
|
3225
|
+
custody;
|
|
3226
|
+
fetchImpl;
|
|
3227
|
+
joinState = /* @__PURE__ */ new Map();
|
|
3228
|
+
/**
|
|
3229
|
+
* Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
|
|
3230
|
+
* by the multi-agent room @mention filter at ingest. Populated/refreshed from a
|
|
3231
|
+
* SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
|
|
3232
|
+
* be resolved (network error, or not present in the listing) stays UNKNOWN → the
|
|
3233
|
+
* filter fails OPEN.
|
|
3234
|
+
*
|
|
3235
|
+
* `is_dm` is immutable for a given channel_id (no misclassification risk), so the
|
|
3236
|
+
* TTL exists only to keep `name` fresh after a channel rename — an entry older
|
|
3237
|
+
* than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
|
|
3238
|
+
*/
|
|
3239
|
+
channelMeta = /* @__PURE__ */ new Map();
|
|
3240
|
+
/**
|
|
3241
|
+
* Phase 2 (sender attribution): per-channel roster cache
|
|
3242
|
+
* (channel_id → identity_id → { name, is_agent }) resolved from the GET
|
|
3243
|
+
* members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
|
|
3244
|
+
* unresolved channel (network error, or no entry) leaves sender_name
|
|
3245
|
+
* undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
|
|
3246
|
+
* per channel on a miss.
|
|
3247
|
+
*/
|
|
3248
|
+
channelRoster = /* @__PURE__ */ new Map();
|
|
3249
|
+
/**
|
|
3250
|
+
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
3251
|
+
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
3252
|
+
* the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
|
|
3253
|
+
* means the DM roster shape was not exactly one non-self, non-agent
|
|
3254
|
+
* counterpart, OR the freshly-fetched key no longer matches an existing pin —
|
|
3255
|
+
* either way the hook must fail closed (return null) for that channel.
|
|
3256
|
+
*/
|
|
3257
|
+
channelOwner = /* @__PURE__ */ new Map();
|
|
3258
|
+
buffer = [];
|
|
3259
|
+
maxBuffer;
|
|
3260
|
+
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
3261
|
+
droppedMessages = 0;
|
|
3262
|
+
/**
|
|
3263
|
+
* Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
|
|
3264
|
+
* ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
|
|
3265
|
+
* most room traffic is distinguishable from an idle/wedged one.
|
|
3266
|
+
*/
|
|
3267
|
+
filteredRoomMessages = 0;
|
|
3268
|
+
/** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
|
|
3269
|
+
rateLimitedSends = 0;
|
|
3270
|
+
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
3271
|
+
welcomeRequestsSent = 0;
|
|
3272
|
+
/** 2026-07-03 latency triage: cumulative delivery pings received on the WS
|
|
3273
|
+
* stream. 0 while messages still flow = the stream is dead and inbound is
|
|
3274
|
+
* riding the 25s safety floor (surfaced on health()/metrics). */
|
|
3275
|
+
pingsReceived = 0;
|
|
3276
|
+
/** True once close() begins — gates the ping-stream-death stderr warning so
|
|
3277
|
+
* a normal shutdown doesn't cry wolf. */
|
|
3278
|
+
closingDown = false;
|
|
3279
|
+
/**
|
|
3280
|
+
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
3281
|
+
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
3282
|
+
* counter is incremented. Surfaced on health()/metrics().
|
|
3283
|
+
*/
|
|
3284
|
+
failedAttachments = 0;
|
|
3285
|
+
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
3286
|
+
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
3287
|
+
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
3288
|
+
static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
|
|
3289
|
+
/**
|
|
3290
|
+
* Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
|
|
3291
|
+
* only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
|
|
3292
|
+
* rows — an idle-but-wiped channel therefore loses its NEXT message (the
|
|
3293
|
+
* very message that would have proven it diverged). selfHealSweep() closes
|
|
3294
|
+
* that gap by proactively walking every non-DM member channel at boot and
|
|
3295
|
+
* on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
|
|
3296
|
+
* back to the delivery-row-driven Task 11 behaviour only.
|
|
3297
|
+
*/
|
|
3298
|
+
selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
|
|
3299
|
+
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
3300
|
+
static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
|
|
3301
|
+
selfHealTimer;
|
|
3302
|
+
rateFloor;
|
|
3303
|
+
/**
|
|
3304
|
+
* agent.action.* run state, keyed by runId.
|
|
3305
|
+
*
|
|
3306
|
+
* Review M3 (bug fix): the ORIGINAL comment here said "deliberately
|
|
3307
|
+
* unbounded" — but nothing ever bounded it either. Only actionSettle()
|
|
3308
|
+
* deleted an entry; a begun-but-never-settled run (agent crash, bug,
|
|
3309
|
+
* dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
|
|
3310
|
+
* bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
|
|
3311
|
+
* lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
|
|
3312
|
+
* mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
|
|
3313
|
+
*/
|
|
3314
|
+
actionRuns = /* @__PURE__ */ new Map();
|
|
3315
|
+
/** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
|
|
3316
|
+
* evicts the oldest (LRU by startedAt) to make room — never grows past
|
|
3317
|
+
* this regardless of how many runs are begun and abandoned. */
|
|
3318
|
+
static MAX_UNSETTLED_ACTION_RUNS = 50;
|
|
3319
|
+
/** Review M3: an unsettled run older than this (by startedAt, relative to
|
|
3320
|
+
* actionClock()) is swept away on the next actionBegin(), independent of
|
|
3321
|
+
* the count-based cap — bounds memory even when begin() calls are rare. */
|
|
3322
|
+
static ACTION_RUN_STALE_MS = 60 * 6e4;
|
|
3323
|
+
// 60 minutes
|
|
3324
|
+
/** Review I3/M3: injected clock (defaults Date.now) for the action-partial
|
|
3325
|
+
* token buckets AND the stale-run sweep — a single seam so tests can
|
|
3326
|
+
* simulate elapsed time deterministically. */
|
|
3327
|
+
actionClock;
|
|
3328
|
+
/**
|
|
3329
|
+
* Per-run token bucket for `agent_action` PARTIAL frames (Global
|
|
3330
|
+
* Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
|
|
3331
|
+
* `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
|
|
3332
|
+
* capacity" — partials instead COALESCE: a throttled call just leaves its
|
|
3333
|
+
* change in `dirty` for the next allowed send to pick up).
|
|
3334
|
+
*
|
|
3335
|
+
* Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
|
|
3336
|
+
* backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
|
|
3337
|
+
* the settled frame + the agent's real reply + reportStatus all compete
|
|
3338
|
+
* for that one shared per-sender budget, so a long run's settled frame or
|
|
3339
|
+
* reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
|
|
3340
|
+
* partials alone use at most ~half the identity's send budget, leaving
|
|
3341
|
+
* headroom for the settled frame / reply / status traffic riding the SAME
|
|
3342
|
+
* cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
|
|
3343
|
+
* `settled` frames bypass this bucket entirely — they always send, exactly
|
|
3344
|
+
* once, per run.
|
|
3345
|
+
*/
|
|
3346
|
+
actionRateFloor;
|
|
3347
|
+
/**
|
|
3348
|
+
* Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
|
|
3349
|
+
* the per-run bucket above. Without this, N concurrent runs each get their
|
|
3350
|
+
* OWN ~30/min budget, so the partial send rate multiplies with agent
|
|
3351
|
+
* concurrency and can still blow past the shared 60/min identity edge cap
|
|
3352
|
+
* (each run individually "has headroom" but the SUM doesn't). Same
|
|
3353
|
+
* capacity/refill as the per-run bucket — it caps the aggregate, not any
|
|
3354
|
+
* single run.
|
|
3355
|
+
*/
|
|
3356
|
+
globalActionRateFloor;
|
|
3357
|
+
/** Public `agent.action.*` surface — assigned once at the end of the constructor. */
|
|
3358
|
+
action;
|
|
3359
|
+
waiters = [];
|
|
3360
|
+
pingHandle;
|
|
3361
|
+
safetyTimer;
|
|
3362
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3363
|
+
startedAt = 0;
|
|
3364
|
+
store;
|
|
3365
|
+
latency;
|
|
3366
|
+
/** Track 5: injectable blob download fn (default: fetch-based). */
|
|
3367
|
+
downloadBlobImpl;
|
|
3368
|
+
/** Track 5: injectable blob upload fn (default: fetch-based). */
|
|
3369
|
+
uploadBlobImpl;
|
|
3370
|
+
/** Track 5: directory where received attachments are saved. */
|
|
3371
|
+
attachmentsDirImpl;
|
|
3372
|
+
/**
|
|
3373
|
+
* Enforce the inbound-buffer cap with a DROP-OLDEST policy.
|
|
3374
|
+
*
|
|
3375
|
+
* The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
|
|
3376
|
+
* decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
|
|
3377
|
+
* messages at the FRONT — those re-buffered messages were consumed earlier, so
|
|
3378
|
+
* they are by definition OLDER than whatever already sits in the buffer. In
|
|
3379
|
+
* every push path the newest messages therefore live at the back. To honour
|
|
3380
|
+
* "the most-recent survive" we always shed from the FRONT, then record the
|
|
3381
|
+
* shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
|
|
3382
|
+
*/
|
|
3383
|
+
capBuffer() {
|
|
3384
|
+
if (this.maxBuffer <= 0) return;
|
|
3385
|
+
const overflow = this.buffer.length - this.maxBuffer;
|
|
3386
|
+
if (overflow > 0) {
|
|
3387
|
+
this.buffer.splice(0, overflow);
|
|
3388
|
+
this.droppedMessages += overflow;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async start() {
|
|
3392
|
+
this.startedAt = Date.now();
|
|
3393
|
+
await this.provider.publishKeyPackage();
|
|
3394
|
+
this.startReceivePump();
|
|
3395
|
+
if (this.selfHealSweepEnabled) void this.selfHealSweep();
|
|
3396
|
+
}
|
|
3397
|
+
async reconcileJoin(channelId) {
|
|
3398
|
+
if (this.joinState.get(channelId) === "JOINED") return true;
|
|
3399
|
+
try {
|
|
3400
|
+
await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
|
|
3401
|
+
this.joinState.set(channelId, "JOINED");
|
|
3402
|
+
return true;
|
|
3403
|
+
} catch {
|
|
3404
|
+
this.joinState.set(channelId, "PENDING");
|
|
3405
|
+
return false;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* The held-KP re-Welcome signal (Task 11), shared by both the reactive
|
|
3410
|
+
* divergence branch in ingestOnce() and the proactive selfHealSweep()
|
|
3411
|
+
* (Task 12). Guarantees a HELD bundle backs the signalled KP:
|
|
3412
|
+
* getLastPublishedKpId returns an id even after the pending bundle was
|
|
3413
|
+
* consumed/lost, which would make the owner seal an un-openable Welcome.
|
|
3414
|
+
* publishKeyPackage is a no-op (returns the same id) while a bundle IS
|
|
3415
|
+
* held, and mints a fresh held KP (delete-and-replace) when none is held.
|
|
3416
|
+
* Callers are responsible for their own cooldown/rate-limit gate and
|
|
3417
|
+
* try/catch — this method does neither.
|
|
3418
|
+
*/
|
|
3419
|
+
async signalHeldKpWelcome(groupId) {
|
|
3420
|
+
await this.provider.publishKeyPackage();
|
|
3421
|
+
const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
|
|
3422
|
+
await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
|
|
3423
|
+
this.welcomeRequestsSent++;
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
|
|
3427
|
+
* branch only fires reactively — off a delivery ROW that already arrived
|
|
3428
|
+
* for a diverged channel. A wiped-but-IDLE channel (no traffic since the
|
|
3429
|
+
* wipe) never produces that row, so its next real message would be the
|
|
3430
|
+
* first thing lost. This sweep walks every non-DM member channel (fired
|
|
3431
|
+
* once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS) and, for any with no
|
|
3432
|
+
* persisted state even after a reconcile attempt, resolves group_id via
|
|
3433
|
+
* the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
|
|
3434
|
+
* which would side-effect a member_device_ids union + commit) and applies
|
|
3435
|
+
* the same held-KP signal — respecting the existing per-channel cooldown.
|
|
3436
|
+
*
|
|
3437
|
+
* Each channel is wrapped in its own try/catch: `listChannels` filters at
|
|
3438
|
+
* the identity level while `requestWelcome` (via request_welcome) gates at
|
|
3439
|
+
* the device level, so a channel where this identity is a member but this
|
|
3440
|
+
* DEVICE never held a leaf can 403 on the signal call. That's harmless and
|
|
3441
|
+
* must not abort the sweep for every other channel.
|
|
3442
|
+
*/
|
|
3443
|
+
async selfHealSweep() {
|
|
3444
|
+
let chans;
|
|
3445
|
+
try {
|
|
3446
|
+
chans = await this.listChannels();
|
|
3447
|
+
} catch {
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
for (const c of chans) {
|
|
3451
|
+
if (c.is_dm) continue;
|
|
3452
|
+
try {
|
|
3453
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
3454
|
+
await this.reconcileJoin(c.channel_id);
|
|
3455
|
+
if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
|
|
3456
|
+
const g = await this.backend.getConnectChannelGroup(c.channel_id);
|
|
3457
|
+
if (!g?.group_id) continue;
|
|
3458
|
+
const now = performance.now();
|
|
3459
|
+
const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
|
|
3460
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
3461
|
+
this.lastWelcomeRequest.set(c.channel_id, now);
|
|
3462
|
+
await this.signalHeldKpWelcome(g.group_id);
|
|
3463
|
+
} catch {
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
/**
|
|
3468
|
+
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
3469
|
+
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
3470
|
+
* room and a2a traffic on the same channel are tracked separately.
|
|
3471
|
+
*/
|
|
3472
|
+
enforceRateFloor(kind, channelId) {
|
|
3473
|
+
const key = `${kind}:${channelId}`;
|
|
3474
|
+
if (!this.rateFloor.tryConsume(key)) {
|
|
3475
|
+
this.rateLimitedSends++;
|
|
3476
|
+
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
async send(channelId, text, messageId) {
|
|
3480
|
+
const meta = this.freshMeta(channelId);
|
|
3481
|
+
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
3482
|
+
const joined = await this.reconcileJoin(channelId);
|
|
3483
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
3484
|
+
const id = messageId ?? crypto.randomUUID();
|
|
3485
|
+
const sendStart = performance.now();
|
|
3486
|
+
await sendWithSelfHeal(
|
|
3487
|
+
this.provider,
|
|
3488
|
+
channelId,
|
|
3489
|
+
(epoch) => this.latency.time(
|
|
3490
|
+
"send.encrypt",
|
|
3491
|
+
() => buildOutboundEnvelope({
|
|
3492
|
+
custody: this.custody,
|
|
3493
|
+
groupKey: this.provider,
|
|
3494
|
+
messageId: id,
|
|
3495
|
+
channelId,
|
|
3496
|
+
enclaveId: this.cfg.enclaveId,
|
|
3497
|
+
senderId: this.cfg.identityId,
|
|
3498
|
+
senderType: "agent",
|
|
3499
|
+
type: "text",
|
|
3500
|
+
bodyPlaintext: JSON.stringify({ text }),
|
|
3501
|
+
bindingId: "",
|
|
3502
|
+
bindingVersion: "0.0.0",
|
|
3503
|
+
agentSkillId: null,
|
|
3504
|
+
epoch,
|
|
3505
|
+
traceparent: ZERO_TRACEPARENT,
|
|
3506
|
+
references: [],
|
|
3507
|
+
stage5Outcome: "allow",
|
|
3508
|
+
stage5Evaluators: []
|
|
3509
|
+
})
|
|
3510
|
+
),
|
|
3511
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
3512
|
+
);
|
|
3513
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
3514
|
+
return { message_id: id, status: "sent" };
|
|
3515
|
+
}
|
|
3516
|
+
async sendA2A(channelId, a2a, messageId) {
|
|
3517
|
+
this.enforceRateFloor("a2a", channelId);
|
|
3518
|
+
const joined = await this.reconcileJoin(channelId);
|
|
3519
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
3520
|
+
const id = messageId ?? crypto.randomUUID();
|
|
3521
|
+
const sendStart = performance.now();
|
|
3522
|
+
await sendWithSelfHeal(
|
|
3523
|
+
this.provider,
|
|
3524
|
+
channelId,
|
|
3525
|
+
(epoch) => this.latency.time(
|
|
3526
|
+
"send.encrypt",
|
|
3527
|
+
() => buildOutboundEnvelope({
|
|
3528
|
+
custody: this.custody,
|
|
3529
|
+
groupKey: this.provider,
|
|
3530
|
+
messageId: id,
|
|
3531
|
+
channelId,
|
|
3532
|
+
enclaveId: this.cfg.enclaveId,
|
|
3533
|
+
senderId: this.cfg.identityId,
|
|
3534
|
+
senderType: "agent",
|
|
3535
|
+
type: "a2a",
|
|
3536
|
+
bodyPlaintext: JSON.stringify({ type: "a2a", a2a_version: a2a.a2a_version, kind: a2a.kind, object: a2a.object }),
|
|
3537
|
+
bindingId: "",
|
|
3538
|
+
bindingVersion: "0.0.0",
|
|
3539
|
+
agentSkillId: null,
|
|
3540
|
+
epoch,
|
|
3541
|
+
traceparent: ZERO_TRACEPARENT,
|
|
3542
|
+
references: [],
|
|
3543
|
+
stage5Outcome: "allow",
|
|
3544
|
+
stage5Evaluators: []
|
|
3545
|
+
})
|
|
3546
|
+
),
|
|
3547
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
3548
|
+
);
|
|
3549
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
3550
|
+
return { message_id: id, status: "sent" };
|
|
3551
|
+
}
|
|
3552
|
+
sendDeps() {
|
|
3553
|
+
return {
|
|
3554
|
+
uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
|
|
3555
|
+
encryptFile,
|
|
3556
|
+
bytesToBase64: bytesToBase642,
|
|
3557
|
+
readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
async sendAttachment(channelId, filePath, caption) {
|
|
3561
|
+
this.enforceRateFloor("attachment", channelId);
|
|
3562
|
+
const joined = await this.reconcileJoin(channelId);
|
|
3563
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
3564
|
+
let body;
|
|
3565
|
+
try {
|
|
3566
|
+
body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
|
|
3567
|
+
} catch (e) {
|
|
3568
|
+
if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
|
|
3569
|
+
throw new InvalidPathError(filePath, e);
|
|
3570
|
+
}
|
|
3571
|
+
throw e;
|
|
3572
|
+
}
|
|
3573
|
+
const id = crypto.randomUUID();
|
|
3574
|
+
const sendStart = performance.now();
|
|
3575
|
+
await sendWithSelfHeal(
|
|
3576
|
+
this.provider,
|
|
3577
|
+
channelId,
|
|
3578
|
+
(epoch) => this.latency.time(
|
|
3579
|
+
"send.encrypt",
|
|
3580
|
+
() => buildOutboundEnvelope({
|
|
3581
|
+
custody: this.custody,
|
|
3582
|
+
groupKey: this.provider,
|
|
3583
|
+
messageId: id,
|
|
3584
|
+
channelId,
|
|
3585
|
+
enclaveId: this.cfg.enclaveId,
|
|
3586
|
+
senderId: this.cfg.identityId,
|
|
3587
|
+
senderType: "agent",
|
|
3588
|
+
type: "artifact_share",
|
|
3589
|
+
bodyPlaintext: JSON.stringify(body),
|
|
3590
|
+
bindingId: "",
|
|
3591
|
+
bindingVersion: "0.0.0",
|
|
3592
|
+
agentSkillId: null,
|
|
3593
|
+
epoch,
|
|
3594
|
+
traceparent: ZERO_TRACEPARENT,
|
|
3595
|
+
references: [],
|
|
3596
|
+
stage5Outcome: "allow",
|
|
3597
|
+
stage5Evaluators: []
|
|
3598
|
+
})
|
|
3599
|
+
),
|
|
3600
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
3601
|
+
);
|
|
3602
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
3603
|
+
return { message_id: id, status: "sent" };
|
|
3604
|
+
}
|
|
3605
|
+
async listChannels() {
|
|
3606
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
3607
|
+
const res = await this.fetchImpl(url, {
|
|
3608
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
3609
|
+
});
|
|
3610
|
+
if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
|
|
3611
|
+
const rows = await res.json();
|
|
3612
|
+
return rows.map((r) => ({
|
|
3613
|
+
channel_id: r.channel_id,
|
|
3614
|
+
name: r.name ?? null,
|
|
3615
|
+
is_dm: r.is_dm,
|
|
3616
|
+
joined: true
|
|
3617
|
+
}));
|
|
3618
|
+
}
|
|
3619
|
+
/** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
|
|
3620
|
+
freshMeta(channelId) {
|
|
3621
|
+
const hit = this.channelMeta.get(channelId);
|
|
3622
|
+
if (!hit) return void 0;
|
|
3623
|
+
if (Date.now() - hit.at >= CHANNEL_META_TTL_MS) return void 0;
|
|
3624
|
+
return { is_dm: hit.is_dm, name: hit.name };
|
|
3625
|
+
}
|
|
3626
|
+
/**
|
|
3627
|
+
* One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
|
|
3628
|
+
* ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
|
|
3629
|
+
* burst of K unknown channels fires a single listChannels() instead of K. Any
|
|
3630
|
+
* listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
|
|
3631
|
+
* blocks on the network.
|
|
3632
|
+
*/
|
|
3633
|
+
async refreshChannelMeta() {
|
|
3634
|
+
try {
|
|
3635
|
+
const now = Date.now();
|
|
3636
|
+
const channels = await this.listChannels();
|
|
3637
|
+
for (const c of channels) {
|
|
3638
|
+
this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
|
|
3639
|
+
}
|
|
3640
|
+
} catch {
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
3644
|
+
freshRoster(channelId) {
|
|
3645
|
+
const hit = this.channelRoster.get(channelId);
|
|
3646
|
+
if (!hit) return void 0;
|
|
3647
|
+
if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
|
|
3648
|
+
return hit.members;
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
3652
|
+
* GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
|
|
3653
|
+
* cycle per channel on a cache miss. Fail-soft: any error leaves the cache
|
|
3654
|
+
* as-is so the affected senders stay unresolved (sender_name undefined) —
|
|
3655
|
+
* attribution must NEVER block or drop a message.
|
|
3656
|
+
*/
|
|
3657
|
+
async refreshChannelRoster(channelId) {
|
|
3658
|
+
try {
|
|
3659
|
+
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
|
|
3660
|
+
channelId
|
|
3661
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
3662
|
+
const res = await this.fetchImpl(url, {
|
|
3663
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
3664
|
+
});
|
|
3665
|
+
if (!res.ok) return;
|
|
3666
|
+
const rows = await res.json();
|
|
3667
|
+
const members = /* @__PURE__ */ new Map();
|
|
3668
|
+
for (const r of rows) {
|
|
3669
|
+
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
3670
|
+
}
|
|
3671
|
+
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
3672
|
+
await this.resolveAndPinOwner(channelId, rows);
|
|
3673
|
+
} catch {
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
|
|
3678
|
+
* NEVER read the server's `role` field to pick the owner. In a DM the owner
|
|
3679
|
+
* is the SINGLE non-self, non-agent counterpart; any other roster shape
|
|
3680
|
+
* (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
|
|
3681
|
+
* supersede eligibility). On the first clean sighting of an unambiguous
|
|
3682
|
+
* owner with no existing pin, TOFU-pin its public key. On a later sighting
|
|
3683
|
+
* where a pin already exists and the freshly-fetched key differs, this is an
|
|
3684
|
+
* anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
|
|
3685
|
+
* this must never throw into the roster refresh path.
|
|
3686
|
+
*/
|
|
3687
|
+
async resolveAndPinOwner(channelId, rows) {
|
|
3688
|
+
try {
|
|
3689
|
+
const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
|
|
3690
|
+
if (counterparts.length !== 1) {
|
|
3691
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
const owner = counterparts[0];
|
|
3695
|
+
const publicKeyHex = owner.public_key ?? "";
|
|
3696
|
+
if (!publicKeyHex) {
|
|
3697
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
3698
|
+
return;
|
|
3699
|
+
}
|
|
3700
|
+
this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
|
|
3701
|
+
const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
|
|
3702
|
+
if (!existing) {
|
|
3703
|
+
await this.store.savePinnedIdentityKey(owner.identity_id, {
|
|
3704
|
+
publicKeyHex,
|
|
3705
|
+
keyAlgo: owner.key_algo ?? "ed25519"
|
|
3706
|
+
});
|
|
3707
|
+
} else if (existing.publicKeyHex !== publicKeyHex) {
|
|
3708
|
+
console.warn(
|
|
3709
|
+
`[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
|
|
3710
|
+
);
|
|
3711
|
+
this.channelOwner.set(channelId, "anomalous");
|
|
3712
|
+
}
|
|
3713
|
+
} catch {
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
|
|
3718
|
+
* SDK's step-0 guard calls this before honoring an owner-authored supersede;
|
|
3719
|
+
* returning null makes it fail closed. Returns a value ONLY when (a) the
|
|
3720
|
+
* structural owner cache has an unambiguous entry for this channel AND (b) a
|
|
3721
|
+
* persisted pin still exists and matches it — a bare in-memory cache hit is
|
|
3722
|
+
* never trusted on its own, it is always reconfirmed against the store.
|
|
3723
|
+
*/
|
|
3724
|
+
async resolvePinnedOwnerHook(channelId) {
|
|
3725
|
+
const cached = this.channelOwner.get(channelId);
|
|
3726
|
+
if (!cached || cached === "anomalous") return null;
|
|
3727
|
+
const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
|
|
3728
|
+
if (!pin || pin.publicKeyHex !== cached.publicKeyHex) return null;
|
|
3729
|
+
return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
|
|
3730
|
+
}
|
|
3731
|
+
async reportStatus(channelId, state, detail) {
|
|
3732
|
+
const joined = await this.reconcileJoin(channelId);
|
|
3733
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
3734
|
+
await sendWithSelfHeal(
|
|
3735
|
+
this.provider,
|
|
3736
|
+
channelId,
|
|
3737
|
+
(epoch) => buildOutboundEnvelope({
|
|
3738
|
+
custody: this.custody,
|
|
3739
|
+
groupKey: this.provider,
|
|
3740
|
+
messageId: crypto.randomUUID(),
|
|
3741
|
+
channelId,
|
|
3742
|
+
enclaveId: this.cfg.enclaveId,
|
|
3743
|
+
senderId: this.cfg.identityId,
|
|
3744
|
+
senderType: "agent",
|
|
3745
|
+
type: "system_event",
|
|
3746
|
+
bodyPlaintext: JSON.stringify({
|
|
3747
|
+
v: 1,
|
|
3748
|
+
type: "agent_status",
|
|
3749
|
+
state,
|
|
3750
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3751
|
+
...detail ? { detail } : {}
|
|
3752
|
+
}),
|
|
3753
|
+
bindingId: "",
|
|
3754
|
+
bindingVersion: "0.0.0",
|
|
3755
|
+
agentSkillId: null,
|
|
3756
|
+
epoch,
|
|
3757
|
+
traceparent: ZERO_TRACEPARENT,
|
|
3758
|
+
references: [],
|
|
3759
|
+
stage5Outcome: "allow",
|
|
3760
|
+
stage5Evaluators: []
|
|
3761
|
+
}),
|
|
3762
|
+
(envelope) => this.transport.publish(envelope)
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
/** `runId` must exist and not have settled yet — settle() is terminal. */
|
|
3766
|
+
requireActionRun(runId) {
|
|
3767
|
+
const run = this.actionRuns.get(runId);
|
|
3768
|
+
if (!run || run.settled) throw new UnknownActionRunError(runId);
|
|
3769
|
+
return run;
|
|
3770
|
+
}
|
|
3771
|
+
/**
|
|
3772
|
+
* Build + send one `agent_action` E2E body over `system_event` (mirrors
|
|
3773
|
+
* sendA2A/reportStatus's envelope construction — same self-heal seam). Used
|
|
3774
|
+
* for BOTH partial and settled frames; the caller decides whether a send is
|
|
3775
|
+
* throttle-gated (partials, via flushActionPartial) or unconditional
|
|
3776
|
+
* (settled, always exactly once).
|
|
3777
|
+
*/
|
|
3778
|
+
async sendActionFrame(channelId, body) {
|
|
3779
|
+
const joined = await this.reconcileJoin(channelId);
|
|
3780
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
3781
|
+
await sendWithSelfHeal(
|
|
3782
|
+
this.provider,
|
|
3783
|
+
channelId,
|
|
3784
|
+
(epoch) => buildOutboundEnvelope({
|
|
3785
|
+
custody: this.custody,
|
|
3786
|
+
groupKey: this.provider,
|
|
3787
|
+
messageId: crypto.randomUUID(),
|
|
3788
|
+
channelId,
|
|
3789
|
+
enclaveId: this.cfg.enclaveId,
|
|
3790
|
+
senderId: this.cfg.identityId,
|
|
3791
|
+
senderType: "agent",
|
|
3792
|
+
type: "system_event",
|
|
3793
|
+
bodyPlaintext: JSON.stringify(body),
|
|
3794
|
+
bindingId: "",
|
|
3795
|
+
bindingVersion: "0.0.0",
|
|
3796
|
+
agentSkillId: null,
|
|
3797
|
+
epoch,
|
|
3798
|
+
traceparent: ZERO_TRACEPARENT,
|
|
3799
|
+
references: [],
|
|
3800
|
+
stage5Outcome: "allow",
|
|
3801
|
+
stage5Evaluators: []
|
|
3802
|
+
}),
|
|
3803
|
+
(envelope) => this.transport.publish(envelope)
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
3808
|
+
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
3809
|
+
* no-op too — the change stays in `dirty` and rides along with whatever the
|
|
3810
|
+
* NEXT allowed send captures (a later start/update/finish call, or settle(),
|
|
3811
|
+
* which always flushes regardless of the bucket). This is the coalescing:
|
|
3812
|
+
* a burst of rapid calls collapses into however many sends the ~1/sec floor
|
|
3813
|
+
* actually allows, each carrying every node changed since the last send.
|
|
3814
|
+
*/
|
|
3815
|
+
async flushActionPartial(runId, run) {
|
|
3816
|
+
if (run.dirty.size === 0) return;
|
|
3817
|
+
if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
|
|
3818
|
+
if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
|
|
3819
|
+
const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
|
|
3820
|
+
run.dirty.clear();
|
|
3821
|
+
await this.sendActionFrame(run.channelId, {
|
|
3822
|
+
v: 1,
|
|
3823
|
+
type: "agent_action",
|
|
3824
|
+
agentActionVersion: "1",
|
|
3825
|
+
phase: "partial",
|
|
3826
|
+
runId,
|
|
3827
|
+
nodes
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
/** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
|
|
3831
|
+
* (relative to `now`). Lazy — invoked on every actionBegin() rather than
|
|
3832
|
+
* a background timer, mirroring the client store's orphan-TTL pattern. */
|
|
3833
|
+
sweepStaleActionRuns(now) {
|
|
3834
|
+
for (const [runId, run] of this.actionRuns) {
|
|
3835
|
+
if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
actionBegin(channelId) {
|
|
3839
|
+
const now = this.actionClock();
|
|
3840
|
+
this.sweepStaleActionRuns(now);
|
|
3841
|
+
if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
|
|
3842
|
+
let oldestId;
|
|
3843
|
+
let oldestAt = Infinity;
|
|
3844
|
+
for (const [id, r] of this.actionRuns) {
|
|
3845
|
+
if (r.startedAt < oldestAt) {
|
|
3846
|
+
oldestAt = r.startedAt;
|
|
3847
|
+
oldestId = id;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
if (oldestId !== void 0) this.actionRuns.delete(oldestId);
|
|
3851
|
+
}
|
|
3852
|
+
const runId = crypto.randomUUID();
|
|
3853
|
+
this.actionRuns.set(runId, {
|
|
3854
|
+
channelId,
|
|
3855
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
3856
|
+
dirty: /* @__PURE__ */ new Set(),
|
|
3857
|
+
startedAt: now,
|
|
3858
|
+
settled: false
|
|
3859
|
+
});
|
|
3860
|
+
return runId;
|
|
3861
|
+
}
|
|
3862
|
+
async actionStart(opts) {
|
|
3863
|
+
const run = this.requireActionRun(opts.runId);
|
|
3864
|
+
const nodeId = crypto.randomUUID();
|
|
3865
|
+
const node = {
|
|
3866
|
+
id: nodeId,
|
|
3867
|
+
type: opts.type,
|
|
3868
|
+
title: opts.title,
|
|
3869
|
+
status: "running",
|
|
3870
|
+
ts: Date.now(),
|
|
3871
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
3872
|
+
};
|
|
3873
|
+
run.nodes.set(nodeId, node);
|
|
3874
|
+
run.dirty.add(nodeId);
|
|
3875
|
+
await this.flushActionPartial(opts.runId, run);
|
|
3876
|
+
return nodeId;
|
|
3877
|
+
}
|
|
3878
|
+
async actionUpdate(runId, nodeId, patch) {
|
|
3879
|
+
const run = this.requireActionRun(runId);
|
|
3880
|
+
const existing = run.nodes.get(nodeId);
|
|
3881
|
+
if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
|
|
3882
|
+
const updated = {
|
|
3883
|
+
...existing,
|
|
3884
|
+
...patch.status !== void 0 ? { status: patch.status } : {},
|
|
3885
|
+
...patch.detail !== void 0 ? { detail: patch.detail } : {},
|
|
3886
|
+
ts: Date.now()
|
|
3887
|
+
};
|
|
3888
|
+
run.nodes.set(nodeId, updated);
|
|
3889
|
+
run.dirty.add(nodeId);
|
|
3890
|
+
await this.flushActionPartial(runId, run);
|
|
3891
|
+
}
|
|
3892
|
+
async actionFinish(runId, nodeId, patch) {
|
|
3893
|
+
await this.actionUpdate(runId, nodeId, patch);
|
|
3894
|
+
}
|
|
3895
|
+
async actionSettle(runId, opts = {}) {
|
|
3896
|
+
const run = this.requireActionRun(runId);
|
|
3897
|
+
run.settled = true;
|
|
3898
|
+
run.dirty.clear();
|
|
3899
|
+
const nodes = [...run.nodes.values()];
|
|
3900
|
+
await this.sendActionFrame(run.channelId, {
|
|
3901
|
+
v: 1,
|
|
3902
|
+
type: "agent_action",
|
|
3903
|
+
agentActionVersion: "1",
|
|
3904
|
+
phase: "settled",
|
|
3905
|
+
runId,
|
|
3906
|
+
startedAt: run.startedAt,
|
|
3907
|
+
endedAt: Date.now(),
|
|
3908
|
+
...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
|
|
3909
|
+
nodes
|
|
3910
|
+
});
|
|
3911
|
+
this.actionRuns.delete(runId);
|
|
3912
|
+
}
|
|
3913
|
+
/**
|
|
3914
|
+
* Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
|
|
3915
|
+
* invocation so a bare agent gets a start→finish node pair without hand-
|
|
3916
|
+
* rolling the begin/start/update/finish calls itself. Success → status
|
|
3917
|
+
* "done"; a thrown error → status "error" with the message captured in
|
|
3918
|
+
* `detail.error`, then the original error is rethrown (wrap never swallows).
|
|
3919
|
+
*/
|
|
3920
|
+
async actionWrap(runId, fn, opts) {
|
|
3921
|
+
const nodeId = await this.actionStart({
|
|
3922
|
+
runId,
|
|
3923
|
+
type: opts.type,
|
|
3924
|
+
title: opts.title,
|
|
3925
|
+
...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
|
|
3926
|
+
});
|
|
3927
|
+
try {
|
|
3928
|
+
const result = await fn();
|
|
3929
|
+
await this.actionFinish(runId, nodeId, { status: "done" });
|
|
3930
|
+
return result;
|
|
3931
|
+
} catch (err) {
|
|
3932
|
+
await this.actionFinish(runId, nodeId, {
|
|
3933
|
+
status: "error",
|
|
3934
|
+
detail: { error: err instanceof Error ? err.message : String(err) }
|
|
3935
|
+
});
|
|
3936
|
+
throw err;
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
startReceivePump() {
|
|
3940
|
+
const sub = this.transport.subscribeDeliveryPings("");
|
|
3941
|
+
this.pingHandle = { close: sub.close };
|
|
3942
|
+
void (async () => {
|
|
3943
|
+
try {
|
|
3944
|
+
for await (const ping of sub.pings) {
|
|
3945
|
+
this.pingsReceived++;
|
|
3946
|
+
if (ping?.reconnected) {
|
|
3947
|
+
process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
|
|
3948
|
+
}
|
|
3949
|
+
await this.ingestOnce();
|
|
3950
|
+
}
|
|
3951
|
+
} catch {
|
|
3952
|
+
}
|
|
3953
|
+
if (!this.closingDown) {
|
|
3954
|
+
process.stderr.write(
|
|
3955
|
+
"[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
|
|
3956
|
+
);
|
|
3957
|
+
}
|
|
3958
|
+
})();
|
|
3959
|
+
this.safetyTimer = setInterval(() => {
|
|
3960
|
+
void this.ingestOnce();
|
|
3961
|
+
}, 25e3);
|
|
3962
|
+
if (this.selfHealSweepEnabled) {
|
|
3963
|
+
this.selfHealTimer = setInterval(() => {
|
|
3964
|
+
void this.selfHealSweep();
|
|
3965
|
+
}, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS);
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
async ingestOnce() {
|
|
3969
|
+
const cycleStart = performance.now();
|
|
3970
|
+
try {
|
|
3971
|
+
const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
|
|
3972
|
+
const channelIds = /* @__PURE__ */ new Set();
|
|
3973
|
+
for (const row of rows) {
|
|
3974
|
+
if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
|
|
3975
|
+
}
|
|
3976
|
+
for (const cid of channelIds) {
|
|
3977
|
+
if (this.joinState.get(cid) !== "JOINED") {
|
|
3978
|
+
await this.reconcileJoin(cid);
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
for (const cid of channelIds) {
|
|
3982
|
+
if (this.freshRoster(cid) === void 0) {
|
|
3983
|
+
await this.refreshChannelRoster(cid);
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
for (const row of rows) {
|
|
3987
|
+
const cid = row.connect_channel_id;
|
|
3988
|
+
const gid = row.group_id;
|
|
3989
|
+
if (!cid || !gid) continue;
|
|
3990
|
+
if (await this.provider.hasPersistedChannelState(cid)) continue;
|
|
3991
|
+
const now = performance.now();
|
|
3992
|
+
const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
|
|
3993
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
3994
|
+
this.lastWelcomeRequest.set(cid, now);
|
|
3995
|
+
try {
|
|
3996
|
+
await this.signalHeldKpWelcome(gid);
|
|
3997
|
+
} catch {
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
} catch {
|
|
4001
|
+
}
|
|
4002
|
+
let entries;
|
|
4003
|
+
try {
|
|
4004
|
+
entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
|
|
4005
|
+
} catch {
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
for (const cid of entries.supersededChannelIds ?? []) {
|
|
4009
|
+
this.joinState.set(cid, "JOINED");
|
|
4010
|
+
}
|
|
4011
|
+
if (!entries.length) return;
|
|
4012
|
+
let metaRefreshed = false;
|
|
4013
|
+
if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
|
|
4014
|
+
await this.refreshChannelMeta();
|
|
4015
|
+
metaRefreshed = true;
|
|
4016
|
+
}
|
|
4017
|
+
const rosterRefreshed = /* @__PURE__ */ new Set();
|
|
4018
|
+
for (const m of entries) {
|
|
4019
|
+
if (rosterRefreshed.has(m.channelId)) continue;
|
|
4020
|
+
rosterRefreshed.add(m.channelId);
|
|
4021
|
+
if (this.freshRoster(m.channelId) === void 0) {
|
|
4022
|
+
await this.refreshChannelRoster(m.channelId);
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
const acked = [];
|
|
4026
|
+
for (const m of entries) {
|
|
4027
|
+
try {
|
|
4028
|
+
const parsed = JSON.parse(
|
|
4029
|
+
new TextDecoder().decode(m.plaintext)
|
|
4030
|
+
);
|
|
4031
|
+
if (parsed.type && parsed.type !== "text") {
|
|
4032
|
+
if (parsed.type === "artifact_share") {
|
|
4033
|
+
try {
|
|
4034
|
+
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
4035
|
+
const senderType = m.envelope.sender_type;
|
|
4036
|
+
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
4037
|
+
const meta = await materializeAttachment(
|
|
4038
|
+
parsed,
|
|
4039
|
+
{ attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
|
|
4040
|
+
{
|
|
4041
|
+
downloadBlob: this.downloadBlobImpl,
|
|
4042
|
+
decryptFile,
|
|
4043
|
+
computeFileDigest,
|
|
4044
|
+
base64ToBytes: base64ToBytes2,
|
|
4045
|
+
mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
|
|
4046
|
+
writeFile: (p, data) => fsp.writeFile(p, data)
|
|
4047
|
+
}
|
|
4048
|
+
);
|
|
4049
|
+
this.buffer.push({
|
|
4050
|
+
channel_id: m.channelId,
|
|
4051
|
+
message_id: m.messageId,
|
|
4052
|
+
sender_id: m.envelope.sender_id,
|
|
4053
|
+
text: typeof parsed.text === "string" ? parsed.text : "",
|
|
4054
|
+
ts: m.receivedAt,
|
|
4055
|
+
sender_name: senderName,
|
|
4056
|
+
sender_is_agent: senderIsAgent,
|
|
4057
|
+
attachments: [meta]
|
|
4058
|
+
});
|
|
4059
|
+
this.capBuffer();
|
|
4060
|
+
} catch (e) {
|
|
4061
|
+
this.failedAttachments++;
|
|
4062
|
+
process.stderr.write(
|
|
4063
|
+
`[bridge] attachment failed on ${m.messageId}: ${e.message}
|
|
4064
|
+
`
|
|
4065
|
+
);
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
acked.push(m.queueId);
|
|
4069
|
+
continue;
|
|
4070
|
+
}
|
|
4071
|
+
if (typeof parsed.text === "string") {
|
|
4072
|
+
const meta = this.freshMeta(m.channelId);
|
|
4073
|
+
const senderType = m.envelope.sender_type;
|
|
4074
|
+
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
4075
|
+
let shouldBuffer = true;
|
|
4076
|
+
if (meta && meta.is_dm === false) {
|
|
4077
|
+
shouldBuffer = shouldProcessRoomMessage(
|
|
4078
|
+
parsed.text,
|
|
4079
|
+
this.cfg.agentName ?? this.cfg.identityId,
|
|
4080
|
+
this.cfg.identityId,
|
|
4081
|
+
{ senderIsAgent, roomDisplayName: meta.name ?? void 0 }
|
|
4082
|
+
);
|
|
4083
|
+
if (!shouldBuffer) this.filteredRoomMessages++;
|
|
4084
|
+
}
|
|
4085
|
+
if (shouldBuffer) {
|
|
4086
|
+
const senderName = this.freshRoster(m.channelId)?.get(
|
|
4087
|
+
m.envelope.sender_id
|
|
4088
|
+
)?.name;
|
|
4089
|
+
this.buffer.push({
|
|
4090
|
+
channel_id: m.channelId,
|
|
4091
|
+
message_id: m.messageId,
|
|
4092
|
+
sender_id: m.envelope.sender_id,
|
|
4093
|
+
text: parsed.text,
|
|
4094
|
+
ts: m.receivedAt,
|
|
4095
|
+
sender_name: senderName,
|
|
4096
|
+
sender_is_agent: senderIsAgent
|
|
4097
|
+
});
|
|
4098
|
+
this.capBuffer();
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
acked.push(m.queueId);
|
|
4102
|
+
} catch {
|
|
4103
|
+
acked.push(m.queueId);
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
if (acked.length) {
|
|
4107
|
+
try {
|
|
4108
|
+
await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
|
|
4109
|
+
} catch {
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
this.latency.record("recv.cycle", performance.now() - cycleStart);
|
|
4113
|
+
if (this.buffer.length) {
|
|
4114
|
+
for (const l of this.listeners) l();
|
|
4115
|
+
const w = this.waiters.splice(0);
|
|
4116
|
+
for (const r of w) r();
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
async drain(channelId, waitSeconds) {
|
|
4120
|
+
let got = this.takeBuffered(channelId);
|
|
4121
|
+
if (got.length || waitSeconds <= 0) return got;
|
|
4122
|
+
const cap = Math.min(waitSeconds, 50) * 1e3;
|
|
4123
|
+
await new Promise((resolve) => {
|
|
4124
|
+
let fired = false;
|
|
4125
|
+
let timer;
|
|
4126
|
+
const wake = () => {
|
|
4127
|
+
if (fired) return;
|
|
4128
|
+
fired = true;
|
|
4129
|
+
clearTimeout(timer);
|
|
4130
|
+
const i = this.waiters.indexOf(wake);
|
|
4131
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
4132
|
+
resolve();
|
|
4133
|
+
};
|
|
4134
|
+
timer = setTimeout(wake, cap);
|
|
4135
|
+
this.waiters.push(wake);
|
|
4136
|
+
});
|
|
4137
|
+
return this.takeBuffered(channelId);
|
|
4138
|
+
}
|
|
4139
|
+
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
4140
|
+
subscribe(listener) {
|
|
4141
|
+
this.listeners.add(listener);
|
|
4142
|
+
return () => this.listeners.delete(listener);
|
|
4143
|
+
}
|
|
4144
|
+
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
4145
|
+
takeBuffered(channelId) {
|
|
4146
|
+
if (channelId === void 0) return this.buffer.splice(0);
|
|
4147
|
+
const keep = [];
|
|
4148
|
+
const out = [];
|
|
4149
|
+
for (const m of this.buffer) (m.channel_id === channelId ? out : keep).push(m);
|
|
4150
|
+
this.buffer.length = 0;
|
|
4151
|
+
this.buffer.push(...keep);
|
|
4152
|
+
return out;
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
4156
|
+
*
|
|
4157
|
+
* The re-buffered messages are older un-acked deliveries, so they go at the
|
|
4158
|
+
* FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
|
|
4159
|
+
* the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
|
|
4160
|
+
* sheds from the front, preserving the newest maxBuffer messages across the
|
|
4161
|
+
* combined [requeued-older..., existing-newer...] buffer.
|
|
4162
|
+
*/
|
|
4163
|
+
requeueFront(msgs) {
|
|
4164
|
+
if (msgs.length) {
|
|
4165
|
+
this.buffer.unshift(...msgs);
|
|
4166
|
+
this.capBuffer();
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
/**
|
|
4170
|
+
* Poke subscribers + waiters to drain the buffer now. Used after a
|
|
4171
|
+
* disconnect re-buffers un-acked messages so a still-connected consumer
|
|
4172
|
+
* (e.g. a replacement WS) redelivers them without waiting for new inbound.
|
|
4173
|
+
* Mirrors the notify tail of ingestOnce.
|
|
4174
|
+
*/
|
|
4175
|
+
wakeListeners() {
|
|
4176
|
+
for (const l of this.listeners) l();
|
|
4177
|
+
const w = this.waiters.splice(0);
|
|
4178
|
+
for (const r of w) r();
|
|
4179
|
+
}
|
|
4180
|
+
health() {
|
|
4181
|
+
let joined = 0;
|
|
4182
|
+
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
4183
|
+
return {
|
|
4184
|
+
status: "ok",
|
|
4185
|
+
identity_id: this.cfg.identityId,
|
|
4186
|
+
joined_channels: joined,
|
|
4187
|
+
buffered: this.buffer.length,
|
|
4188
|
+
dropped: this.droppedMessages,
|
|
4189
|
+
filtered_room: this.filteredRoomMessages,
|
|
4190
|
+
rate_limited: this.rateLimitedSends,
|
|
4191
|
+
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
4192
|
+
failed_attachments: this.failedAttachments,
|
|
4193
|
+
delivery_pings: this.pingsReceived,
|
|
4194
|
+
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
4195
|
+
version: BRIDGE_VERSION
|
|
4196
|
+
};
|
|
4197
|
+
}
|
|
4198
|
+
/**
|
|
4199
|
+
* Loopback observability snapshot served at GET /v1/metrics.
|
|
4200
|
+
*
|
|
4201
|
+
* `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
|
|
4202
|
+
* locally and monotonically — send.encrypt / send.publish / send.total (the
|
|
4203
|
+
* sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
|
|
4204
|
+
* recipient half). Excludes the agent's LLM time entirely.
|
|
4205
|
+
*
|
|
4206
|
+
* `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
|
|
4207
|
+
* provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
|
|
4208
|
+
* channel appears only once it has had a decrypt error; `stuck:true` means
|
|
4209
|
+
* repeated failures with no intervening successful decrypt.
|
|
4210
|
+
*
|
|
4211
|
+
* `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
|
|
4212
|
+
* bounded-buffer cap. A daemon silently dropping messages under backpressure is
|
|
4213
|
+
* exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
|
|
4214
|
+
* on health()) rather than swallowed.
|
|
4215
|
+
*
|
|
4216
|
+
* `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
|
|
4217
|
+
* dropped by the @mention filter. A silently-filtered room message produces no
|
|
4218
|
+
* other signal, so an agent correctly filtering 95% of room traffic would look
|
|
4219
|
+
* identical to an idle/wedged one without this counter.
|
|
4220
|
+
*
|
|
4221
|
+
* `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
|
|
4222
|
+
* acked-and-dropped because they were provably past-epoch undecryptable
|
|
4223
|
+
* (forward-secrecy secret gone), so a self-healed poison row is observable
|
|
4224
|
+
* here instead of silently dropped.
|
|
4225
|
+
*/
|
|
4226
|
+
metrics() {
|
|
4227
|
+
return {
|
|
4228
|
+
stages: this.latency.snapshot(),
|
|
4229
|
+
errors: this.provider.errorStats(),
|
|
4230
|
+
dropped: this.droppedMessages,
|
|
4231
|
+
filteredRoomMessages: this.filteredRoomMessages,
|
|
4232
|
+
rateLimitedSends: this.rateLimitedSends,
|
|
4233
|
+
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
4234
|
+
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
4235
|
+
failedAttachments: this.failedAttachments
|
|
4236
|
+
};
|
|
4237
|
+
}
|
|
4238
|
+
async close() {
|
|
4239
|
+
this.closingDown = true;
|
|
4240
|
+
this.pingHandle?.close();
|
|
4241
|
+
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
4242
|
+
if (this.selfHealTimer) clearInterval(this.selfHealTimer);
|
|
4243
|
+
this.store.close();
|
|
4244
|
+
}
|
|
4245
|
+
};
|
|
4246
|
+
|
|
4247
|
+
// src/config.ts
|
|
4248
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4249
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
4250
|
+
var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
|
|
4251
|
+
function loadConfig(opts) {
|
|
4252
|
+
const raw = readFileSync(opts.credentialPath, "utf8");
|
|
4253
|
+
try {
|
|
4254
|
+
const mode = statSync(opts.credentialPath).mode & 511;
|
|
4255
|
+
if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
|
|
4256
|
+
`);
|
|
4257
|
+
} catch {
|
|
4258
|
+
}
|
|
4259
|
+
let json2;
|
|
4260
|
+
try {
|
|
4261
|
+
json2 = JSON.parse(raw);
|
|
4262
|
+
} catch {
|
|
4263
|
+
throw new Error("credential file is not valid JSON");
|
|
4264
|
+
}
|
|
4265
|
+
for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
|
|
4266
|
+
const storePath = opts.storePath ?? join2(dirname2(opts.credentialPath), "bridge-state.db");
|
|
4267
|
+
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;
|
|
4268
|
+
const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
|
|
4269
|
+
const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
|
|
4270
|
+
return {
|
|
4271
|
+
apiKey: json2.api_key,
|
|
4272
|
+
identityId: json2.identity_id,
|
|
4273
|
+
deviceId: json2.device_id,
|
|
4274
|
+
signingPrivateKey: json2.signing_private_key,
|
|
4275
|
+
enclaveId: json2.enclave_id,
|
|
4276
|
+
apiBase: json2.api_base,
|
|
4277
|
+
storePath,
|
|
4278
|
+
agentName,
|
|
4279
|
+
rateLimitCapacity,
|
|
4280
|
+
rateLimitRefillPerMinute
|
|
4281
|
+
};
|
|
4282
|
+
}
|
|
4283
|
+
function arg(argv, flag) {
|
|
4284
|
+
const i = argv.indexOf(flag);
|
|
4285
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
4286
|
+
}
|
|
4287
|
+
function args(argv, flag) {
|
|
4288
|
+
const out = [];
|
|
4289
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === flag && argv[i + 1]) out.push(argv[i + 1]);
|
|
4290
|
+
return out;
|
|
4291
|
+
}
|
|
4292
|
+
function parseDaemonArgs(argv, env) {
|
|
4293
|
+
const tlsCert = arg(argv, "--tls-cert") ?? env.SECURECOMS_TLS_CERT;
|
|
4294
|
+
const tlsKey = arg(argv, "--tls-key") ?? env.SECURECOMS_TLS_KEY;
|
|
4295
|
+
return {
|
|
4296
|
+
port: Number(arg(argv, "--port") ?? env.SECURECOMS_PORT ?? 7077),
|
|
4297
|
+
bind: arg(argv, "--bind") ?? env.SECURECOMS_BIND ?? "127.0.0.1",
|
|
4298
|
+
tokenFile: arg(argv, "--token-file") ?? env.SECURECOMS_TOKEN_FILE,
|
|
4299
|
+
token: arg(argv, "--token") ?? env.SECURECOMS_LOCAL_TOKEN,
|
|
4300
|
+
tls: Boolean(tlsCert && tlsKey),
|
|
4301
|
+
tlsCert,
|
|
4302
|
+
tlsKey,
|
|
4303
|
+
allowOrigins: args(argv, "--allow-origin")
|
|
4304
|
+
};
|
|
4305
|
+
}
|
|
4306
|
+
function isLoopback(host) {
|
|
4307
|
+
return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host);
|
|
4308
|
+
}
|
|
4309
|
+
function validateBind(bind, tls) {
|
|
4310
|
+
if (!isLoopback(bind) && !tls) {
|
|
4311
|
+
throw new Error(
|
|
4312
|
+
`refusing non-loopback bind '${bind}' without TLS \u2014 supply --tls-cert and --tls-key for cross-host, or bind a loopback address`
|
|
4313
|
+
);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
// src/http-server.ts
|
|
4318
|
+
import { createServer as createHttp } from "node:http";
|
|
4319
|
+
import { createServer as createHttps } from "node:https";
|
|
4320
|
+
import { z } from "zod";
|
|
4321
|
+
|
|
4322
|
+
// src/auth.ts
|
|
4323
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
|
|
4324
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
4325
|
+
function resolveToken(opts) {
|
|
4326
|
+
if (opts.token) return opts.token;
|
|
4327
|
+
const f = opts.tokenFile;
|
|
4328
|
+
if (f && existsSync(f)) return readFileSync2(f, "utf8").trim();
|
|
4329
|
+
const tok = randomBytes3(32).toString("base64url");
|
|
4330
|
+
if (f) writeFileSync(f, tok, { mode: 384 });
|
|
4331
|
+
return tok;
|
|
4332
|
+
}
|
|
4333
|
+
function tokenMatches(provided, expected) {
|
|
4334
|
+
if (!provided) return false;
|
|
4335
|
+
const a = Buffer.from(provided);
|
|
4336
|
+
const b = Buffer.from(expected);
|
|
4337
|
+
if (a.length !== b.length) return false;
|
|
4338
|
+
return timingSafeEqual(a, b);
|
|
4339
|
+
}
|
|
4340
|
+
function bearerFrom(header) {
|
|
4341
|
+
if (!header) return void 0;
|
|
4342
|
+
const m = /^Bearer (.+)$/i.exec(header.trim());
|
|
4343
|
+
return m ? m[1] : void 0;
|
|
4344
|
+
}
|
|
4345
|
+
function hostnameOf(hostHeader) {
|
|
4346
|
+
if (!hostHeader) return void 0;
|
|
4347
|
+
if (hostHeader.startsWith("[")) return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
|
|
4348
|
+
const i = hostHeader.lastIndexOf(":");
|
|
4349
|
+
return i > 0 ? hostHeader.slice(0, i) : hostHeader;
|
|
4350
|
+
}
|
|
4351
|
+
function originHostAllowed(headers, allowOrigins) {
|
|
4352
|
+
const host = hostnameOf(headers.host);
|
|
4353
|
+
if (host && !isLoopback(host) && !isLoopback(`[${host}]`)) return false;
|
|
4354
|
+
const origin = headers.origin;
|
|
4355
|
+
if (!origin) return true;
|
|
4356
|
+
if (allowOrigins.includes(origin)) return true;
|
|
4357
|
+
try {
|
|
4358
|
+
return isLoopback(new URL(origin).hostname);
|
|
4359
|
+
} catch {
|
|
4360
|
+
return false;
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
// src/errors.ts
|
|
4365
|
+
var BRIDGE_ERRORS = {
|
|
4366
|
+
unauthorized: { http: 401, message: "missing or invalid bearer token" },
|
|
4367
|
+
forbidden_origin: { http: 403, message: "origin or host not allowed" },
|
|
4368
|
+
not_found: { http: 404, message: "no such route" },
|
|
4369
|
+
invalid_request: { http: 400, message: "request failed validation" },
|
|
4370
|
+
invalid_json: { http: 400, message: "request body is not valid JSON" },
|
|
4371
|
+
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
4372
|
+
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
4373
|
+
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
4374
|
+
unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
|
|
4375
|
+
internal: { http: 500, message: "internal error" }
|
|
4376
|
+
};
|
|
4377
|
+
function errorBody(code, detail) {
|
|
4378
|
+
const body = { error: code, message: BRIDGE_ERRORS[code].message };
|
|
4379
|
+
if (detail !== void 0) body.detail = detail;
|
|
4380
|
+
return body;
|
|
4381
|
+
}
|
|
4382
|
+
function httpStatus(code) {
|
|
4383
|
+
return BRIDGE_ERRORS[code].http;
|
|
4384
|
+
}
|
|
4385
|
+
function wsErrorFrame(code, detail) {
|
|
4386
|
+
return { type: "error", ...errorBody(code, detail) };
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
// src/http-server.ts
|
|
4390
|
+
function writeSse(res, msgs) {
|
|
4391
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
4392
|
+
if (res.destroyed || res.writableEnded) return msgs.slice(i);
|
|
4393
|
+
try {
|
|
4394
|
+
res.write(`event: message
|
|
4395
|
+
data: ${JSON.stringify(msgs[i])}
|
|
4396
|
+
|
|
4397
|
+
`);
|
|
4398
|
+
} catch {
|
|
4399
|
+
return msgs.slice(i);
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
return [];
|
|
4403
|
+
}
|
|
4404
|
+
function capabilities(opts) {
|
|
4405
|
+
return {
|
|
4406
|
+
api_version: "1",
|
|
4407
|
+
receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
|
|
4408
|
+
send_types: ["text", "agent_status", "a2a"],
|
|
4409
|
+
webhook_push: false
|
|
4410
|
+
// B3: A2A-style push-notification config (ciphertext/wake-only)
|
|
4411
|
+
};
|
|
4412
|
+
}
|
|
4413
|
+
var SEND_CAP = 64 * 1024;
|
|
4414
|
+
var OTHER_CAP = 8 * 1024;
|
|
4415
|
+
var a2aSchema = z.object({
|
|
4416
|
+
a2a_version: z.string().min(1),
|
|
4417
|
+
kind: z.enum(["message", "task", "artifact"]),
|
|
4418
|
+
object: z.record(z.unknown())
|
|
4419
|
+
});
|
|
4420
|
+
var sendAttachmentSchema = z.object({
|
|
4421
|
+
channel_id: z.string().min(1),
|
|
4422
|
+
path: z.string().min(1),
|
|
4423
|
+
caption: z.string().optional()
|
|
4424
|
+
});
|
|
4425
|
+
var sendSchema = z.object({
|
|
4426
|
+
channel_id: z.string().min(1),
|
|
4427
|
+
text: z.string().optional(),
|
|
4428
|
+
idempotency_key: z.string().optional(),
|
|
4429
|
+
a2a: a2aSchema.optional()
|
|
4430
|
+
}).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
|
|
4431
|
+
message: "send requires either text or a2a"
|
|
4432
|
+
});
|
|
4433
|
+
var statusSchema = z.object({
|
|
4434
|
+
channel_id: z.string().min(1),
|
|
4435
|
+
state: z.enum(["idle", "working", "stalled", "error"]),
|
|
4436
|
+
detail: z.string().optional()
|
|
4437
|
+
});
|
|
4438
|
+
function json(res, status, body) {
|
|
4439
|
+
const s = JSON.stringify(body);
|
|
4440
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
4441
|
+
res.end(s);
|
|
4442
|
+
}
|
|
4443
|
+
function jsonError(res, code, detail) {
|
|
4444
|
+
json(res, httpStatus(code), errorBody(code, detail));
|
|
4445
|
+
}
|
|
4446
|
+
function readBody(req, cap) {
|
|
4447
|
+
return new Promise((resolve, reject) => {
|
|
4448
|
+
let size = 0;
|
|
4449
|
+
let done = false;
|
|
4450
|
+
const chunks = [];
|
|
4451
|
+
const onData = (c) => {
|
|
4452
|
+
if (done) return;
|
|
4453
|
+
size += c.length;
|
|
4454
|
+
if (size > cap) {
|
|
4455
|
+
done = true;
|
|
4456
|
+
req.removeListener("data", onData);
|
|
4457
|
+
req.pause();
|
|
4458
|
+
reject(new Error("body_too_large"));
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
chunks.push(c);
|
|
4462
|
+
};
|
|
4463
|
+
req.on("data", onData);
|
|
4464
|
+
req.on("end", () => {
|
|
4465
|
+
if (!done) {
|
|
4466
|
+
done = true;
|
|
4467
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
4468
|
+
}
|
|
4469
|
+
});
|
|
4470
|
+
req.on("error", (e) => {
|
|
4471
|
+
if (!done) {
|
|
4472
|
+
done = true;
|
|
4473
|
+
reject(e);
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
});
|
|
4477
|
+
}
|
|
4478
|
+
function createHttpServer(agent, opts) {
|
|
4479
|
+
let closeSse = () => {
|
|
4480
|
+
};
|
|
4481
|
+
let sse = null;
|
|
4482
|
+
const tearDownSse = () => {
|
|
4483
|
+
if (!sse) return;
|
|
4484
|
+
sse.unsubscribe();
|
|
4485
|
+
clearInterval(sse.keepalive);
|
|
4486
|
+
try {
|
|
4487
|
+
sse.res.end();
|
|
4488
|
+
} catch {
|
|
4489
|
+
}
|
|
4490
|
+
sse = null;
|
|
4491
|
+
};
|
|
4492
|
+
closeSse = tearDownSse;
|
|
4493
|
+
const handler = async (req, res) => {
|
|
4494
|
+
try {
|
|
4495
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
4496
|
+
const path = url.pathname;
|
|
4497
|
+
const method = req.method ?? "GET";
|
|
4498
|
+
if (method === "GET" && path === "/healthz") {
|
|
4499
|
+
return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
|
|
4500
|
+
}
|
|
4501
|
+
if (!tokenMatches(bearerFrom(req.headers.authorization), opts.token)) {
|
|
4502
|
+
return jsonError(res, "unauthorized");
|
|
4503
|
+
}
|
|
4504
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
4505
|
+
return jsonError(res, "forbidden_origin");
|
|
4506
|
+
}
|
|
4507
|
+
if (method === "GET" && path === "/v1/channels") {
|
|
4508
|
+
return json(res, 200, await agent.listChannels());
|
|
4509
|
+
}
|
|
4510
|
+
if (method === "GET" && path === "/v1/metrics") {
|
|
4511
|
+
return json(res, 200, agent.metrics());
|
|
4512
|
+
}
|
|
4513
|
+
if (method === "POST" && path === "/v1/send") {
|
|
4514
|
+
const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
|
|
4515
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
4516
|
+
const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
|
|
4517
|
+
return json(res, 200, r);
|
|
4518
|
+
}
|
|
4519
|
+
if (method === "POST" && path === "/v1/send-attachment") {
|
|
4520
|
+
const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
4521
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
4522
|
+
const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
|
|
4523
|
+
return json(res, 200, r);
|
|
4524
|
+
}
|
|
4525
|
+
if (method === "POST" && path === "/v1/status") {
|
|
4526
|
+
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
4527
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
4528
|
+
await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
|
|
4529
|
+
return json(res, 200, { ok: true });
|
|
4530
|
+
}
|
|
4531
|
+
if (method === "GET" && path === "/v1/messages") {
|
|
4532
|
+
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
4533
|
+
const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
|
|
4534
|
+
return json(res, 200, await agent.drain(channel, wait));
|
|
4535
|
+
}
|
|
4536
|
+
if (method === "GET" && path === "/v1/events") {
|
|
4537
|
+
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
4538
|
+
tearDownSse();
|
|
4539
|
+
res.writeHead(200, {
|
|
4540
|
+
"content-type": "text/event-stream",
|
|
4541
|
+
"cache-control": "no-cache",
|
|
4542
|
+
connection: "keep-alive"
|
|
4543
|
+
});
|
|
4544
|
+
res.write(":connected\n\n");
|
|
4545
|
+
const drainToClient = () => {
|
|
4546
|
+
const left = writeSse(res, agent.takeBuffered(channel));
|
|
4547
|
+
if (left.length) agent.requeueFront(left);
|
|
4548
|
+
};
|
|
4549
|
+
const unsubscribe = agent.subscribe(drainToClient);
|
|
4550
|
+
const keepalive = setInterval(() => {
|
|
4551
|
+
try {
|
|
4552
|
+
res.write(":keepalive\n\n");
|
|
4553
|
+
} catch {
|
|
4554
|
+
}
|
|
4555
|
+
}, 15e3);
|
|
4556
|
+
sse = { res, unsubscribe, keepalive };
|
|
4557
|
+
drainToClient();
|
|
4558
|
+
req.on("close", () => {
|
|
4559
|
+
if (sse?.res === res) tearDownSse();
|
|
4560
|
+
});
|
|
4561
|
+
return;
|
|
4562
|
+
}
|
|
4563
|
+
return jsonError(res, "not_found");
|
|
4564
|
+
} catch (e) {
|
|
4565
|
+
if (e.message === "body_too_large") {
|
|
4566
|
+
if (!res.headersSent) {
|
|
4567
|
+
res.writeHead(httpStatus("body_too_large"), { "content-type": "application/json", connection: "close" });
|
|
4568
|
+
}
|
|
4569
|
+
res.end(JSON.stringify(errorBody("body_too_large")));
|
|
4570
|
+
return;
|
|
4571
|
+
}
|
|
4572
|
+
if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
|
|
4573
|
+
if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
|
|
4574
|
+
if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
|
|
4575
|
+
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
4576
|
+
process.stderr.write(`[bridge] request error: ${e?.message ?? e}
|
|
4577
|
+
`);
|
|
4578
|
+
return jsonError(res, "internal");
|
|
4579
|
+
}
|
|
4580
|
+
};
|
|
4581
|
+
const server = opts.tls ? createHttps({ cert: opts.tls.cert, key: opts.tls.key }, (req, res) => void handler(req, res)) : createHttp((req, res) => void handler(req, res));
|
|
4582
|
+
return { server, closeAllSseStreams: () => closeSse() };
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
// src/ws-server.ts
|
|
4586
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
4587
|
+
var PING_INTERVAL_MS = 3e4;
|
|
4588
|
+
function tokenFromUpgrade(req) {
|
|
4589
|
+
try {
|
|
4590
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
4591
|
+
const q = url.searchParams.get("token");
|
|
4592
|
+
if (q) return q;
|
|
4593
|
+
} catch {
|
|
4594
|
+
}
|
|
4595
|
+
return bearerFrom(req.headers.authorization);
|
|
4596
|
+
}
|
|
4597
|
+
function authedUpgrade(req, opts) {
|
|
4598
|
+
if (!tokenMatches(tokenFromUpgrade(req), opts.token)) return false;
|
|
4599
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
4600
|
+
return false;
|
|
4601
|
+
}
|
|
4602
|
+
return true;
|
|
4603
|
+
}
|
|
4604
|
+
function attachAgentWebSocket(server, agent, opts) {
|
|
4605
|
+
const wss = new WebSocketServer({ server, path: "/v1/ws" });
|
|
4606
|
+
const keepalive = setInterval(() => {
|
|
4607
|
+
for (const client of wss.clients) {
|
|
4608
|
+
const sock = client;
|
|
4609
|
+
if (sock.isAlive === false) {
|
|
4610
|
+
sock.terminate();
|
|
4611
|
+
continue;
|
|
4612
|
+
}
|
|
4613
|
+
sock.isAlive = false;
|
|
4614
|
+
try {
|
|
4615
|
+
sock.ping();
|
|
4616
|
+
} catch {
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
}, PING_INTERVAL_MS);
|
|
4620
|
+
if (typeof keepalive.unref === "function") keepalive.unref();
|
|
4621
|
+
let current = null;
|
|
4622
|
+
wss.on("connection", (sock, req) => {
|
|
4623
|
+
if (!authedUpgrade(req, opts)) {
|
|
4624
|
+
sock.close(1008, "unauthorized");
|
|
4625
|
+
return;
|
|
4626
|
+
}
|
|
4627
|
+
if (current && current.sock !== sock) {
|
|
4628
|
+
const prior = current;
|
|
4629
|
+
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
4630
|
+
prior.sock.close(1e3, "replaced by newer connection");
|
|
4631
|
+
}
|
|
4632
|
+
prior.cleanup();
|
|
4633
|
+
}
|
|
4634
|
+
sock.isAlive = true;
|
|
4635
|
+
sock.on("pong", () => {
|
|
4636
|
+
sock.isAlive = true;
|
|
4637
|
+
});
|
|
4638
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
4639
|
+
const flush = () => {
|
|
4640
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
4641
|
+
const batch = agent.takeBuffered();
|
|
4642
|
+
for (let i = 0; i < batch.length; i++) {
|
|
4643
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
4644
|
+
agent.requeueFront(batch.slice(i));
|
|
4645
|
+
return;
|
|
4646
|
+
}
|
|
4647
|
+
const frame = { type: "message", ...batch[i] };
|
|
4648
|
+
try {
|
|
4649
|
+
sock.send(JSON.stringify(frame));
|
|
4650
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
4651
|
+
} catch {
|
|
4652
|
+
agent.requeueFront(batch.slice(i));
|
|
4653
|
+
return;
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
};
|
|
4657
|
+
const unsubscribe = agent.subscribe(flush);
|
|
4658
|
+
let cleaned = false;
|
|
4659
|
+
const cleanup = () => {
|
|
4660
|
+
if (cleaned) return;
|
|
4661
|
+
cleaned = true;
|
|
4662
|
+
unsubscribe();
|
|
4663
|
+
if (inflight.size) {
|
|
4664
|
+
agent.requeueFront([...inflight.values()]);
|
|
4665
|
+
inflight.clear();
|
|
4666
|
+
agent.wakeListeners();
|
|
4667
|
+
}
|
|
4668
|
+
if (current?.sock === sock) current = null;
|
|
4669
|
+
};
|
|
4670
|
+
current = { sock, cleanup };
|
|
4671
|
+
sock.on("close", cleanup);
|
|
4672
|
+
sock.on("error", cleanup);
|
|
4673
|
+
sock.on("message", (data) => {
|
|
4674
|
+
void handleInbound(sock, agent, data, inflight);
|
|
4675
|
+
});
|
|
4676
|
+
flush();
|
|
4677
|
+
});
|
|
4678
|
+
return {
|
|
4679
|
+
close() {
|
|
4680
|
+
clearInterval(keepalive);
|
|
4681
|
+
for (const client of wss.clients) {
|
|
4682
|
+
try {
|
|
4683
|
+
client.terminate();
|
|
4684
|
+
} catch {
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
wss.close();
|
|
4688
|
+
}
|
|
4689
|
+
};
|
|
4690
|
+
}
|
|
4691
|
+
function sendJson(sock, body) {
|
|
4692
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
4693
|
+
try {
|
|
4694
|
+
sock.send(JSON.stringify(body));
|
|
4695
|
+
} catch {
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
async function handleInbound(sock, agent, data, inflight) {
|
|
4699
|
+
let msg;
|
|
4700
|
+
try {
|
|
4701
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
4702
|
+
} catch {
|
|
4703
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
if (typeof msg !== "object" || msg === null) {
|
|
4707
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4708
|
+
return;
|
|
4709
|
+
}
|
|
4710
|
+
const m = msg;
|
|
4711
|
+
if (m.type === "ack") {
|
|
4712
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
4713
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4714
|
+
return;
|
|
4715
|
+
}
|
|
4716
|
+
inflight.delete(m.message_id);
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
if (m.type === "send") {
|
|
4720
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
4721
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4722
|
+
return;
|
|
4723
|
+
}
|
|
4724
|
+
if (m.a2a && typeof m.a2a === "object") {
|
|
4725
|
+
const a = m.a2a;
|
|
4726
|
+
if (typeof a.a2a_version !== "string" || !a.a2a_version || a.kind !== "message" && a.kind !== "task" && a.kind !== "artifact" || typeof a.object !== "object" || a.object === null) {
|
|
4727
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4728
|
+
return;
|
|
4729
|
+
}
|
|
4730
|
+
try {
|
|
4731
|
+
const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
|
|
4732
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
4733
|
+
} catch (e) {
|
|
4734
|
+
if (e instanceof ChannelPendingError) {
|
|
4735
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
4736
|
+
} else if (e instanceof RateLimitedError) {
|
|
4737
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
4738
|
+
} else {
|
|
4739
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
return;
|
|
4743
|
+
}
|
|
4744
|
+
if (typeof m.text !== "string" || !m.text) {
|
|
4745
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4746
|
+
return;
|
|
4747
|
+
}
|
|
4748
|
+
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : void 0;
|
|
4749
|
+
try {
|
|
4750
|
+
const r = await agent.send(m.channel_id, m.text, idempotencyKey);
|
|
4751
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
4752
|
+
} catch (e) {
|
|
4753
|
+
if (e instanceof ChannelPendingError) {
|
|
4754
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
4755
|
+
} else if (e instanceof RateLimitedError) {
|
|
4756
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
4757
|
+
} else {
|
|
4758
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return;
|
|
4762
|
+
}
|
|
4763
|
+
if (m.type === "status") {
|
|
4764
|
+
const states = ["idle", "working", "stalled", "error"];
|
|
4765
|
+
if (typeof m.channel_id !== "string" || !m.channel_id || typeof m.state !== "string" || !states.includes(m.state)) {
|
|
4766
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4767
|
+
return;
|
|
4768
|
+
}
|
|
4769
|
+
const detail = typeof m.detail === "string" ? m.detail : void 0;
|
|
4770
|
+
try {
|
|
4771
|
+
await agent.reportStatus(m.channel_id, m.state, detail);
|
|
4772
|
+
sendJson(sock, { type: "ok" });
|
|
4773
|
+
} catch (e) {
|
|
4774
|
+
if (e instanceof ChannelPendingError) {
|
|
4775
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
4776
|
+
} else if (e instanceof RateLimitedError) {
|
|
4777
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
4778
|
+
} else {
|
|
4779
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
return;
|
|
4783
|
+
}
|
|
4784
|
+
const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
|
|
4785
|
+
const NODE_STATUSES = ["pending", "running", "done", "error"];
|
|
4786
|
+
const actionError = (e) => {
|
|
4787
|
+
sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
|
|
4788
|
+
};
|
|
4789
|
+
if (m.type === "action_begin") {
|
|
4790
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
4791
|
+
sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
|
|
4792
|
+
return;
|
|
4793
|
+
}
|
|
4794
|
+
try {
|
|
4795
|
+
sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
|
|
4796
|
+
} catch (e) {
|
|
4797
|
+
actionError(e);
|
|
4798
|
+
}
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
if (m.type === "action_start") {
|
|
4802
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
|
|
4803
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
|
|
4804
|
+
return;
|
|
4805
|
+
}
|
|
4806
|
+
try {
|
|
4807
|
+
const nodeId = await agent.action.start({
|
|
4808
|
+
runId: m.run_id,
|
|
4809
|
+
type: m.node_type,
|
|
4810
|
+
title: m.title,
|
|
4811
|
+
parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
|
|
4812
|
+
});
|
|
4813
|
+
sendJson(sock, { type: "action_started", node_id: nodeId });
|
|
4814
|
+
} catch (e) {
|
|
4815
|
+
actionError(e);
|
|
4816
|
+
}
|
|
4817
|
+
return;
|
|
4818
|
+
}
|
|
4819
|
+
if (m.type === "action_update" || m.type === "action_finish") {
|
|
4820
|
+
if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
|
|
4821
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
|
|
4822
|
+
return;
|
|
4823
|
+
}
|
|
4824
|
+
try {
|
|
4825
|
+
if (m.type === "action_finish") {
|
|
4826
|
+
if (m.status !== "done" && m.status !== "error") {
|
|
4827
|
+
sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
|
|
4828
|
+
return;
|
|
4829
|
+
}
|
|
4830
|
+
await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
4831
|
+
} else {
|
|
4832
|
+
if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
|
|
4833
|
+
sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
|
|
4834
|
+
return;
|
|
4835
|
+
}
|
|
4836
|
+
await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
|
|
4837
|
+
}
|
|
4838
|
+
sendJson(sock, { type: "ok" });
|
|
4839
|
+
} catch (e) {
|
|
4840
|
+
actionError(e);
|
|
4841
|
+
}
|
|
4842
|
+
return;
|
|
4843
|
+
}
|
|
4844
|
+
if (m.type === "action_settle") {
|
|
4845
|
+
if (typeof m.run_id !== "string" || !m.run_id) {
|
|
4846
|
+
sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
|
|
4847
|
+
return;
|
|
4848
|
+
}
|
|
4849
|
+
try {
|
|
4850
|
+
await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
|
|
4851
|
+
sendJson(sock, { type: "action_settled", run_id: m.run_id });
|
|
4852
|
+
} catch (e) {
|
|
4853
|
+
actionError(e);
|
|
4854
|
+
}
|
|
4855
|
+
return;
|
|
4856
|
+
}
|
|
4857
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
4858
|
+
}
|
|
4859
|
+
export {
|
|
4860
|
+
BRIDGE_ERRORS,
|
|
4861
|
+
BRIDGE_VERSION,
|
|
4862
|
+
BridgeAgent,
|
|
4863
|
+
ChannelPendingError,
|
|
4864
|
+
RateLimitedError,
|
|
4865
|
+
attachAgentWebSocket,
|
|
4866
|
+
capabilities,
|
|
4867
|
+
createHttpServer,
|
|
4868
|
+
errorBody,
|
|
4869
|
+
httpStatus,
|
|
4870
|
+
isLoopback,
|
|
4871
|
+
loadConfig,
|
|
4872
|
+
originHostAllowed,
|
|
4873
|
+
parseDaemonArgs,
|
|
4874
|
+
resolveToken,
|
|
4875
|
+
tokenMatches,
|
|
4876
|
+
validateBind,
|
|
4877
|
+
writeSse,
|
|
4878
|
+
wsErrorFrame
|
|
4879
|
+
};
|