@securecoms/agent-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +378 -0
- package/dist/attachments.d.ts +48 -0
- package/dist/attachments.js +49 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +56 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +3963 -0
- package/dist/bridge-agent.d.ts +259 -0
- package/dist/bridge-agent.js +831 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +74 -0
- package/dist/coordination.d.ts +24 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3646 -0
- package/dist/latency.d.ts +26 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.d.ts +17 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.d.ts +23 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.d.ts +28 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.d.ts +4 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/resolve-bin.d.ts +35 -0
- package/dist/service/systemd.d.ts +4 -0
- package/dist/service/systemd.js +25 -0
- package/dist/ws-server.d.ts +42 -0
- package/dist/ws-server.js +254 -0
- package/package.json +48 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,3963 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config.ts
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
|
|
7
|
+
function loadConfig(opts) {
|
|
8
|
+
const raw = readFileSync(opts.credentialPath, "utf8");
|
|
9
|
+
try {
|
|
10
|
+
const mode = statSync(opts.credentialPath).mode & 511;
|
|
11
|
+
if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
|
|
12
|
+
`);
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
let json2;
|
|
16
|
+
try {
|
|
17
|
+
json2 = JSON.parse(raw);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error("credential file is not valid JSON");
|
|
20
|
+
}
|
|
21
|
+
for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
|
|
22
|
+
const storePath = opts.storePath ?? join(dirname(opts.credentialPath), "bridge-state.db");
|
|
23
|
+
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;
|
|
24
|
+
const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
|
|
25
|
+
const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
|
|
26
|
+
return {
|
|
27
|
+
apiKey: json2.api_key,
|
|
28
|
+
identityId: json2.identity_id,
|
|
29
|
+
deviceId: json2.device_id,
|
|
30
|
+
signingPrivateKey: json2.signing_private_key,
|
|
31
|
+
enclaveId: json2.enclave_id,
|
|
32
|
+
apiBase: json2.api_base,
|
|
33
|
+
storePath,
|
|
34
|
+
agentName,
|
|
35
|
+
rateLimitCapacity,
|
|
36
|
+
rateLimitRefillPerMinute
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function arg(argv, flag) {
|
|
40
|
+
const i = argv.indexOf(flag);
|
|
41
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
42
|
+
}
|
|
43
|
+
function args(argv, flag) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === flag && argv[i + 1]) out.push(argv[i + 1]);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function parseDaemonArgs(argv, env) {
|
|
49
|
+
const tlsCert = arg(argv, "--tls-cert") ?? env.SECURECOMS_TLS_CERT;
|
|
50
|
+
const tlsKey = arg(argv, "--tls-key") ?? env.SECURECOMS_TLS_KEY;
|
|
51
|
+
return {
|
|
52
|
+
port: Number(arg(argv, "--port") ?? env.SECURECOMS_PORT ?? 7077),
|
|
53
|
+
bind: arg(argv, "--bind") ?? env.SECURECOMS_BIND ?? "127.0.0.1",
|
|
54
|
+
tokenFile: arg(argv, "--token-file") ?? env.SECURECOMS_TOKEN_FILE,
|
|
55
|
+
token: arg(argv, "--token") ?? env.SECURECOMS_LOCAL_TOKEN,
|
|
56
|
+
tls: Boolean(tlsCert && tlsKey),
|
|
57
|
+
tlsCert,
|
|
58
|
+
tlsKey,
|
|
59
|
+
allowOrigins: args(argv, "--allow-origin")
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function isLoopback(host) {
|
|
63
|
+
return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host);
|
|
64
|
+
}
|
|
65
|
+
function validateBind(bind, tls) {
|
|
66
|
+
if (!isLoopback(bind) && !tls) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`refusing non-loopback bind '${bind}' without TLS \u2014 supply --tls-cert and --tls-key for cross-host, or bind a loopback address`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ../connect-core/dist/envelope/types.js
|
|
74
|
+
var MESSAGE_TYPES = [
|
|
75
|
+
"text",
|
|
76
|
+
"decision_request",
|
|
77
|
+
"decision_response",
|
|
78
|
+
"status_alert",
|
|
79
|
+
"artifact_share",
|
|
80
|
+
"action_confirmation",
|
|
81
|
+
"heartbeat",
|
|
82
|
+
"system_event",
|
|
83
|
+
"a2a"
|
|
84
|
+
];
|
|
85
|
+
var AV_ENVELOPE_VERSION = "1.0";
|
|
86
|
+
|
|
87
|
+
// ../connect-core/dist/envelope/validate.js
|
|
88
|
+
import _Ajv2020 from "ajv/dist/2020.js";
|
|
89
|
+
import _addFormats from "ajv-formats";
|
|
90
|
+
|
|
91
|
+
// ../connect-core/dist/envelope/schemas.generated.js
|
|
92
|
+
var envelopeSchema = {
|
|
93
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
94
|
+
"$id": "https://agentvault.dev/connect/schemas/envelope-1.0.json",
|
|
95
|
+
"title": "AgentVaultEnvelope",
|
|
96
|
+
"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).",
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"required": [
|
|
100
|
+
"av_version",
|
|
101
|
+
"message_id",
|
|
102
|
+
"channel_id",
|
|
103
|
+
"enclave_id",
|
|
104
|
+
"message_type",
|
|
105
|
+
"sender_id",
|
|
106
|
+
"sender_type",
|
|
107
|
+
"identity_proof",
|
|
108
|
+
"trace_context",
|
|
109
|
+
"policy_binding",
|
|
110
|
+
"mls_group_epoch",
|
|
111
|
+
"replay_nonce",
|
|
112
|
+
"payload_encrypted",
|
|
113
|
+
"references"
|
|
114
|
+
],
|
|
115
|
+
"properties": {
|
|
116
|
+
"av_version": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"pattern": "^[0-9]+\\.[0-9]+$",
|
|
119
|
+
"description": "Envelope wire version (major.minor). Compatibility is enforced in code, not by this pattern."
|
|
120
|
+
},
|
|
121
|
+
"message_id": {
|
|
122
|
+
"type": "string",
|
|
123
|
+
"format": "uuid"
|
|
124
|
+
},
|
|
125
|
+
"channel_id": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"format": "uuid"
|
|
128
|
+
},
|
|
129
|
+
"enclave_id": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"format": "uuid"
|
|
132
|
+
},
|
|
133
|
+
"message_type": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"enum": [
|
|
136
|
+
"text",
|
|
137
|
+
"decision_request",
|
|
138
|
+
"decision_response",
|
|
139
|
+
"status_alert",
|
|
140
|
+
"artifact_share",
|
|
141
|
+
"action_confirmation",
|
|
142
|
+
"heartbeat",
|
|
143
|
+
"system_event",
|
|
144
|
+
"a2a"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"sender_id": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"format": "uuid"
|
|
150
|
+
},
|
|
151
|
+
"sender_type": {
|
|
152
|
+
"type": "string",
|
|
153
|
+
"enum": [
|
|
154
|
+
"human",
|
|
155
|
+
"agent",
|
|
156
|
+
"guest"
|
|
157
|
+
]
|
|
158
|
+
},
|
|
159
|
+
"identity_proof": {
|
|
160
|
+
"type": "object",
|
|
161
|
+
"additionalProperties": false,
|
|
162
|
+
"required": [
|
|
163
|
+
"signature",
|
|
164
|
+
"algo",
|
|
165
|
+
"attested",
|
|
166
|
+
"canonicalization"
|
|
167
|
+
],
|
|
168
|
+
"properties": {
|
|
169
|
+
"signature": {
|
|
170
|
+
"$ref": "#/$defs/base64_nonempty"
|
|
171
|
+
},
|
|
172
|
+
"algo": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"enum": [
|
|
175
|
+
"ed25519",
|
|
176
|
+
"ecdsa_p384"
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
"attested": {
|
|
180
|
+
"type": "boolean"
|
|
181
|
+
},
|
|
182
|
+
"canonicalization": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"const": "RFC8785"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"trace_context": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"required": [
|
|
192
|
+
"traceparent"
|
|
193
|
+
],
|
|
194
|
+
"properties": {
|
|
195
|
+
"traceparent": {
|
|
196
|
+
"type": "string",
|
|
197
|
+
"pattern": "^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$",
|
|
198
|
+
"description": "W3C TraceContext traceparent: version-traceid-spanid-flags."
|
|
199
|
+
},
|
|
200
|
+
"tracestate": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"maxLength": 512
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
"policy_binding": {
|
|
207
|
+
"type": "object",
|
|
208
|
+
"additionalProperties": false,
|
|
209
|
+
"required": [
|
|
210
|
+
"binding_id",
|
|
211
|
+
"version",
|
|
212
|
+
"agent_skill_id",
|
|
213
|
+
"delegation_id",
|
|
214
|
+
"client_stage5_outcome",
|
|
215
|
+
"client_stage5_evaluators"
|
|
216
|
+
],
|
|
217
|
+
"properties": {
|
|
218
|
+
"binding_id": {
|
|
219
|
+
"type": "string",
|
|
220
|
+
"format": "uuid"
|
|
221
|
+
},
|
|
222
|
+
"version": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
|
225
|
+
"description": "Policy binding semver."
|
|
226
|
+
},
|
|
227
|
+
"agent_skill_id": {
|
|
228
|
+
"type": [
|
|
229
|
+
"string",
|
|
230
|
+
"null"
|
|
231
|
+
]
|
|
232
|
+
},
|
|
233
|
+
"delegation_id": {
|
|
234
|
+
"oneOf": [
|
|
235
|
+
{
|
|
236
|
+
"type": "string",
|
|
237
|
+
"format": "uuid"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"type": "null"
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
},
|
|
244
|
+
"client_stage5_outcome": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"enum": [
|
|
247
|
+
"allow",
|
|
248
|
+
"flag"
|
|
249
|
+
]
|
|
250
|
+
},
|
|
251
|
+
"client_stage5_evaluators": {
|
|
252
|
+
"type": "array",
|
|
253
|
+
"items": {
|
|
254
|
+
"type": "string"
|
|
255
|
+
},
|
|
256
|
+
"maxItems": 64
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
"mls_group_epoch": {
|
|
261
|
+
"type": "integer",
|
|
262
|
+
"minimum": 0,
|
|
263
|
+
"description": "Public DS-tracked epoch counter; the server rejects messages where this is below the channel's current epoch (mls.stale_epoch)."
|
|
264
|
+
},
|
|
265
|
+
"replay_nonce": {
|
|
266
|
+
"$ref": "#/$defs/base64_nonempty"
|
|
267
|
+
},
|
|
268
|
+
"payload_encrypted": {
|
|
269
|
+
"$ref": "#/$defs/base64"
|
|
270
|
+
},
|
|
271
|
+
"references": {
|
|
272
|
+
"type": "array",
|
|
273
|
+
"maxItems": 64,
|
|
274
|
+
"items": {
|
|
275
|
+
"type": "object",
|
|
276
|
+
"additionalProperties": false,
|
|
277
|
+
"required": [
|
|
278
|
+
"type",
|
|
279
|
+
"message_id"
|
|
280
|
+
],
|
|
281
|
+
"properties": {
|
|
282
|
+
"type": {
|
|
283
|
+
"type": "string",
|
|
284
|
+
"enum": [
|
|
285
|
+
"reply_to",
|
|
286
|
+
"recall",
|
|
287
|
+
"quote",
|
|
288
|
+
"reaction"
|
|
289
|
+
]
|
|
290
|
+
},
|
|
291
|
+
"message_id": {
|
|
292
|
+
"type": "string",
|
|
293
|
+
"format": "uuid"
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"mentions": {
|
|
299
|
+
"type": "array",
|
|
300
|
+
"maxItems": 256,
|
|
301
|
+
"items": {
|
|
302
|
+
"type": "string",
|
|
303
|
+
"format": "uuid"
|
|
304
|
+
},
|
|
305
|
+
"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."
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"$defs": {
|
|
309
|
+
"base64": {
|
|
310
|
+
"type": "string",
|
|
311
|
+
"pattern": "^$|^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
|
|
312
|
+
"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."
|
|
313
|
+
},
|
|
314
|
+
"base64_nonempty": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
|
|
317
|
+
"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."
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
var payloadsSchema = {
|
|
322
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
323
|
+
"$id": "https://agentvault.dev/connect/schemas/payloads-1.0.json",
|
|
324
|
+
"title": "ConnectPayloads",
|
|
325
|
+
"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.",
|
|
326
|
+
"$defs": {
|
|
327
|
+
"text": {
|
|
328
|
+
"type": "object",
|
|
329
|
+
"required": [
|
|
330
|
+
"body"
|
|
331
|
+
],
|
|
332
|
+
"properties": {
|
|
333
|
+
"body": {
|
|
334
|
+
"type": "string",
|
|
335
|
+
"maxLength": 1048576
|
|
336
|
+
},
|
|
337
|
+
"format": {
|
|
338
|
+
"type": "string",
|
|
339
|
+
"enum": [
|
|
340
|
+
"plain",
|
|
341
|
+
"markdown"
|
|
342
|
+
]
|
|
343
|
+
},
|
|
344
|
+
"mentions": {
|
|
345
|
+
"type": "array",
|
|
346
|
+
"items": {
|
|
347
|
+
"type": "string",
|
|
348
|
+
"format": "uuid"
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
"decision_request": {
|
|
354
|
+
"type": "object",
|
|
355
|
+
"required": [
|
|
356
|
+
"decision_id",
|
|
357
|
+
"prompt",
|
|
358
|
+
"options"
|
|
359
|
+
],
|
|
360
|
+
"properties": {
|
|
361
|
+
"decision_id": {
|
|
362
|
+
"type": "string",
|
|
363
|
+
"format": "uuid"
|
|
364
|
+
},
|
|
365
|
+
"prompt": {
|
|
366
|
+
"type": "string"
|
|
367
|
+
},
|
|
368
|
+
"options": {
|
|
369
|
+
"type": "array",
|
|
370
|
+
"items": {
|
|
371
|
+
"type": "string"
|
|
372
|
+
},
|
|
373
|
+
"minItems": 1
|
|
374
|
+
},
|
|
375
|
+
"deadline": {
|
|
376
|
+
"type": "string",
|
|
377
|
+
"format": "date-time"
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
"decision_response": {
|
|
382
|
+
"type": "object",
|
|
383
|
+
"required": [
|
|
384
|
+
"decision_id",
|
|
385
|
+
"choice"
|
|
386
|
+
],
|
|
387
|
+
"properties": {
|
|
388
|
+
"decision_id": {
|
|
389
|
+
"type": "string",
|
|
390
|
+
"format": "uuid"
|
|
391
|
+
},
|
|
392
|
+
"choice": {
|
|
393
|
+
"type": "string"
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
"status_alert": {
|
|
398
|
+
"type": "object",
|
|
399
|
+
"required": [
|
|
400
|
+
"severity",
|
|
401
|
+
"message"
|
|
402
|
+
],
|
|
403
|
+
"properties": {
|
|
404
|
+
"severity": {
|
|
405
|
+
"type": "string",
|
|
406
|
+
"enum": [
|
|
407
|
+
"info",
|
|
408
|
+
"warning",
|
|
409
|
+
"critical"
|
|
410
|
+
]
|
|
411
|
+
},
|
|
412
|
+
"message": {
|
|
413
|
+
"type": "string"
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
"artifact_share": {
|
|
418
|
+
"type": "object",
|
|
419
|
+
"required": [
|
|
420
|
+
"artifact_type",
|
|
421
|
+
"file_id"
|
|
422
|
+
],
|
|
423
|
+
"properties": {
|
|
424
|
+
"artifact_type": {
|
|
425
|
+
"type": "string"
|
|
426
|
+
},
|
|
427
|
+
"file_id": {
|
|
428
|
+
"type": "string",
|
|
429
|
+
"format": "uuid"
|
|
430
|
+
},
|
|
431
|
+
"filename": {
|
|
432
|
+
"type": "string"
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
"action_confirmation": {
|
|
437
|
+
"type": "object",
|
|
438
|
+
"required": [
|
|
439
|
+
"action"
|
|
440
|
+
],
|
|
441
|
+
"properties": {
|
|
442
|
+
"action": {
|
|
443
|
+
"type": "string"
|
|
444
|
+
},
|
|
445
|
+
"target_identity": {
|
|
446
|
+
"type": "string",
|
|
447
|
+
"format": "uuid"
|
|
448
|
+
},
|
|
449
|
+
"outcome": {
|
|
450
|
+
"type": "string",
|
|
451
|
+
"enum": [
|
|
452
|
+
"approved",
|
|
453
|
+
"denied",
|
|
454
|
+
"pending"
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
"heartbeat": {
|
|
460
|
+
"type": "object",
|
|
461
|
+
"properties": {
|
|
462
|
+
"client_time": {
|
|
463
|
+
"type": "string",
|
|
464
|
+
"format": "date-time"
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
"system_event": {
|
|
469
|
+
"type": "object",
|
|
470
|
+
"required": [
|
|
471
|
+
"event"
|
|
472
|
+
],
|
|
473
|
+
"properties": {
|
|
474
|
+
"event": {
|
|
475
|
+
"type": "string"
|
|
476
|
+
},
|
|
477
|
+
"detail": {
|
|
478
|
+
"type": "object"
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
"a2a": {
|
|
483
|
+
"type": "object",
|
|
484
|
+
"required": [
|
|
485
|
+
"a2a_version",
|
|
486
|
+
"kind",
|
|
487
|
+
"object"
|
|
488
|
+
],
|
|
489
|
+
"properties": {
|
|
490
|
+
"a2a_version": {
|
|
491
|
+
"type": "string"
|
|
492
|
+
},
|
|
493
|
+
"kind": {
|
|
494
|
+
"type": "string",
|
|
495
|
+
"enum": [
|
|
496
|
+
"message",
|
|
497
|
+
"task",
|
|
498
|
+
"artifact"
|
|
499
|
+
]
|
|
500
|
+
},
|
|
501
|
+
"object": {
|
|
502
|
+
"type": "object"
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// ../connect-core/dist/envelope/validate.js
|
|
510
|
+
var Ajv2020 = _Ajv2020.default ?? _Ajv2020;
|
|
511
|
+
var addFormats = _addFormats.default ?? _addFormats;
|
|
512
|
+
var ajv = new Ajv2020({ allErrors: true, strict: true });
|
|
513
|
+
addFormats(ajv);
|
|
514
|
+
var envelopeValidator = ajv.compile(envelopeSchema);
|
|
515
|
+
var payloadDefs = payloadsSchema.$defs;
|
|
516
|
+
var payloadValidators = /* @__PURE__ */ new Map();
|
|
517
|
+
for (const t of MESSAGE_TYPES) {
|
|
518
|
+
const def = payloadDefs[t];
|
|
519
|
+
if (!def) {
|
|
520
|
+
throw new Error(`payloads.schema.json is missing a $defs schema for message_type "${t}"`);
|
|
521
|
+
}
|
|
522
|
+
payloadValidators.set(t, ajv.compile(def));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ../connect-core/dist/envelope/sign.js
|
|
526
|
+
import { canonicalize } from "@agentvault/crypto/canonical";
|
|
527
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
528
|
+
import { p384 } from "@noble/curves/p384";
|
|
529
|
+
|
|
530
|
+
// ../connect-core/dist/base64.js
|
|
531
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
532
|
+
var LOOKUP = (() => {
|
|
533
|
+
const t = new Int16Array(256).fill(-1);
|
|
534
|
+
for (let i = 0; i < ALPHABET.length; i++)
|
|
535
|
+
t[ALPHABET.charCodeAt(i)] = i;
|
|
536
|
+
return t;
|
|
537
|
+
})();
|
|
538
|
+
function bytesToBase64(bytes) {
|
|
539
|
+
let out = "";
|
|
540
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
541
|
+
const b0 = bytes[i];
|
|
542
|
+
const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
|
543
|
+
const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
544
|
+
out += ALPHABET[b0 >> 2];
|
|
545
|
+
out += ALPHABET[(b0 & 3) << 4 | b1 >> 4];
|
|
546
|
+
out += i + 1 < bytes.length ? ALPHABET[(b1 & 15) << 2 | b2 >> 6] : "=";
|
|
547
|
+
out += i + 2 < bytes.length ? ALPHABET[b2 & 63] : "=";
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
function base64ToBytes(s) {
|
|
552
|
+
const clean = s.replace(/=+$/, "");
|
|
553
|
+
const len = clean.length;
|
|
554
|
+
const outLen = Math.floor(len * 6 / 8);
|
|
555
|
+
const out = new Uint8Array(outLen);
|
|
556
|
+
let bits = 0;
|
|
557
|
+
let acc = 0;
|
|
558
|
+
let o = 0;
|
|
559
|
+
for (let i = 0; i < len; i++) {
|
|
560
|
+
const v = LOOKUP[clean.charCodeAt(i)];
|
|
561
|
+
if (v < 0)
|
|
562
|
+
throw new Error("base64ToBytes: invalid character");
|
|
563
|
+
acc = acc << 6 | v;
|
|
564
|
+
bits += 6;
|
|
565
|
+
if (bits >= 8) {
|
|
566
|
+
bits -= 8;
|
|
567
|
+
out[o++] = acc >> bits & 255;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ../connect-core/dist/envelope/sign.js
|
|
574
|
+
async function canonicalEnvelopeBytes(envelope) {
|
|
575
|
+
const { signature: _signature, ...proofWithoutSignature } = envelope.identity_proof;
|
|
576
|
+
const toSign = { ...envelope, identity_proof: proofWithoutSignature };
|
|
577
|
+
return canonicalize(toSign);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ../connect-core/dist/envelope/aead.js
|
|
581
|
+
import { gcm } from "@noble/ciphers/aes";
|
|
582
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
583
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
584
|
+
import { canonicalize as canonicalize2 } from "@agentvault/crypto/canonical";
|
|
585
|
+
var APPLICATION_KEY_BYTES = 32;
|
|
586
|
+
var REPLAY_NONCE_BYTES = 24;
|
|
587
|
+
var NONCE_BYTES = {
|
|
588
|
+
xchacha20poly1305: 24,
|
|
589
|
+
aes256gcm: 12
|
|
590
|
+
};
|
|
591
|
+
function concatBytes(...parts) {
|
|
592
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
593
|
+
const out = new Uint8Array(total);
|
|
594
|
+
let off = 0;
|
|
595
|
+
for (const p of parts) {
|
|
596
|
+
out.set(p, off);
|
|
597
|
+
off += p.length;
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
function epochToBytes(epoch) {
|
|
602
|
+
const b = new Uint8Array(8);
|
|
603
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(epoch), false);
|
|
604
|
+
return b;
|
|
605
|
+
}
|
|
606
|
+
function buildExporterContext(enclaveId, channelId, epoch) {
|
|
607
|
+
const enc = new TextEncoder();
|
|
608
|
+
return concatBytes(enc.encode(enclaveId), enc.encode(channelId), epochToBytes(epoch));
|
|
609
|
+
}
|
|
610
|
+
function cipherFor(suite, key, nonce, aad) {
|
|
611
|
+
return suite === "aes256gcm" ? gcm(key, nonce, aad) : xchacha20poly1305(key, nonce, aad);
|
|
612
|
+
}
|
|
613
|
+
function assertKey(key) {
|
|
614
|
+
if (key.length !== APPLICATION_KEY_BYTES) {
|
|
615
|
+
throw new Error(`application key must be ${APPLICATION_KEY_BYTES} bytes (the MLS exporter output); got ${key.length}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function sealPayload(p) {
|
|
619
|
+
assertKey(p.key);
|
|
620
|
+
const nonce = p.nonce ?? randomBytes(NONCE_BYTES[p.suite]);
|
|
621
|
+
const ciphertext = cipherFor(p.suite, p.key, nonce, p.associatedData).encrypt(p.plaintext);
|
|
622
|
+
return concatBytes(nonce, ciphertext);
|
|
623
|
+
}
|
|
624
|
+
function openPayload(p) {
|
|
625
|
+
assertKey(p.key);
|
|
626
|
+
const nlen = NONCE_BYTES[p.suite];
|
|
627
|
+
if (p.ciphertext.length < nlen) {
|
|
628
|
+
throw new Error("openPayload: ciphertext shorter than the AEAD nonce");
|
|
629
|
+
}
|
|
630
|
+
const nonce = p.ciphertext.subarray(0, nlen);
|
|
631
|
+
const body = p.ciphertext.subarray(nlen);
|
|
632
|
+
return cipherFor(p.suite, p.key, nonce, p.associatedData).decrypt(body);
|
|
633
|
+
}
|
|
634
|
+
async function buildAssociatedData(fields) {
|
|
635
|
+
return canonicalize2({
|
|
636
|
+
message_id: fields.message_id,
|
|
637
|
+
channel_id: fields.channel_id,
|
|
638
|
+
sender_id: fields.sender_id,
|
|
639
|
+
mls_group_epoch: fields.mls_group_epoch,
|
|
640
|
+
replay_nonce: fields.replay_nonce,
|
|
641
|
+
policy_binding: fields.policy_binding
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
function generateReplayNonce() {
|
|
645
|
+
return bytesToBase64(randomBytes(REPLAY_NONCE_BYTES));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ../connect-core/dist/sdk-dto/validate.js
|
|
649
|
+
import _Ajv20202 from "ajv/dist/2020.js";
|
|
650
|
+
import _addFormats2 from "ajv-formats";
|
|
651
|
+
|
|
652
|
+
// ../connect-core/dist/sdk-dto/schemas.generated.js
|
|
653
|
+
var modelDescriptorSchema = {
|
|
654
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
655
|
+
"$id": "agentvault.connect.sdk.model_descriptor",
|
|
656
|
+
"type": "object",
|
|
657
|
+
"additionalProperties": false,
|
|
658
|
+
"required": [
|
|
659
|
+
"provider",
|
|
660
|
+
"name"
|
|
661
|
+
],
|
|
662
|
+
"properties": {
|
|
663
|
+
"provider": {
|
|
664
|
+
"type": "string",
|
|
665
|
+
"minLength": 1
|
|
666
|
+
},
|
|
667
|
+
"name": {
|
|
668
|
+
"type": "string",
|
|
669
|
+
"minLength": 1
|
|
670
|
+
},
|
|
671
|
+
"version": {
|
|
672
|
+
"type": "string"
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
var registerSchema = {
|
|
677
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
678
|
+
"$id": "agentvault.connect.sdk.register_request",
|
|
679
|
+
"type": "object",
|
|
680
|
+
"additionalProperties": false,
|
|
681
|
+
"required": [
|
|
682
|
+
"skill_id",
|
|
683
|
+
"public_key",
|
|
684
|
+
"algo",
|
|
685
|
+
"model"
|
|
686
|
+
],
|
|
687
|
+
"properties": {
|
|
688
|
+
"skill_id": {
|
|
689
|
+
"type": "string",
|
|
690
|
+
"minLength": 1
|
|
691
|
+
},
|
|
692
|
+
"public_key": {
|
|
693
|
+
"type": "string",
|
|
694
|
+
"minLength": 1
|
|
695
|
+
},
|
|
696
|
+
"algo": {
|
|
697
|
+
"type": "string",
|
|
698
|
+
"enum": [
|
|
699
|
+
"ed25519",
|
|
700
|
+
"ecdsa_p384"
|
|
701
|
+
]
|
|
702
|
+
},
|
|
703
|
+
"attested": {
|
|
704
|
+
"type": "boolean"
|
|
705
|
+
},
|
|
706
|
+
"model": {
|
|
707
|
+
"$ref": "agentvault.connect.sdk.model_descriptor"
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
var auditQuerySchema = {
|
|
712
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
713
|
+
"$id": "agentvault.connect.sdk.audit_query",
|
|
714
|
+
"type": "object",
|
|
715
|
+
"additionalProperties": false,
|
|
716
|
+
"required": [
|
|
717
|
+
"agent_id"
|
|
718
|
+
],
|
|
719
|
+
"properties": {
|
|
720
|
+
"agent_id": {
|
|
721
|
+
"type": "string",
|
|
722
|
+
"format": "uuid"
|
|
723
|
+
},
|
|
724
|
+
"from": {
|
|
725
|
+
"type": "string",
|
|
726
|
+
"format": "date-time"
|
|
727
|
+
},
|
|
728
|
+
"to": {
|
|
729
|
+
"type": "string",
|
|
730
|
+
"format": "date-time"
|
|
731
|
+
},
|
|
732
|
+
"channel_id": {
|
|
733
|
+
"type": "string",
|
|
734
|
+
"format": "uuid"
|
|
735
|
+
},
|
|
736
|
+
"message_type": {
|
|
737
|
+
"type": "string"
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// ../connect-core/dist/sdk-dto/validate.js
|
|
743
|
+
var Ajv20202 = _Ajv20202.default ?? _Ajv20202;
|
|
744
|
+
var addFormats2 = _addFormats2.default ?? _addFormats2;
|
|
745
|
+
var ajv2 = new Ajv20202({ allErrors: true, strict: true });
|
|
746
|
+
addFormats2(ajv2);
|
|
747
|
+
ajv2.addSchema(modelDescriptorSchema, "agentvault.connect.sdk.model_descriptor");
|
|
748
|
+
var modelValidator = ajv2.getSchema("agentvault.connect.sdk.model_descriptor");
|
|
749
|
+
var registerValidator = ajv2.compile(registerSchema);
|
|
750
|
+
var auditQueryValidator = ajv2.compile(auditQuerySchema);
|
|
751
|
+
|
|
752
|
+
// ../connect-clients-core/dist/stage5/evaluators.js
|
|
753
|
+
var ALLOW = { outcome: "allow" };
|
|
754
|
+
var PHI_PATTERNS = [/\b\d{3}-\d{2}-\d{4}\b/, /\bMRN[:#]?\s*\d{6,}\b/i];
|
|
755
|
+
var IP_PATTERNS = [
|
|
756
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
757
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
758
|
+
/\bconfidential\b/i
|
|
759
|
+
];
|
|
760
|
+
var RELEASABILITY_PATTERNS = [/\bNOFORN\b/, /\bdo not (?:forward|share)\b/i];
|
|
761
|
+
function patternEvaluator(id, patterns, reason) {
|
|
762
|
+
return {
|
|
763
|
+
id,
|
|
764
|
+
async evaluate(plaintext) {
|
|
765
|
+
return patterns.some((p) => p.test(plaintext)) ? { outcome: "flag", reason } : ALLOW;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
var phiEvaluator = patternEvaluator("phi", PHI_PATTERNS, "phi.detected");
|
|
770
|
+
var ipEvaluator = patternEvaluator("ip", IP_PATTERNS, "ip.detected");
|
|
771
|
+
var releasabilityEvaluator = patternEvaluator("releasability", RELEASABILITY_PATTERNS, "releasability.hint");
|
|
772
|
+
|
|
773
|
+
// ../connect-sdk-ts/dist/custody/software.js
|
|
774
|
+
import { ed25519 as ed255192 } from "@noble/curves/ed25519";
|
|
775
|
+
import { p384 as p3842 } from "@noble/curves/p384";
|
|
776
|
+
var SoftwareKeyCustody = class _SoftwareKeyCustody {
|
|
777
|
+
algo;
|
|
778
|
+
priv;
|
|
779
|
+
pub;
|
|
780
|
+
attested = false;
|
|
781
|
+
constructor(algo, priv, pub, quiet = false) {
|
|
782
|
+
this.algo = algo;
|
|
783
|
+
this.priv = priv;
|
|
784
|
+
this.pub = pub;
|
|
785
|
+
if (!quiet) {
|
|
786
|
+
console.warn("[AgentVault] using a software key (dev/CI only) -- envelopes are not attested. Use kms-hosted or byok custody in production.");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
static generate(algo) {
|
|
790
|
+
if (algo === "ed25519") {
|
|
791
|
+
const priv2 = ed255192.utils.randomPrivateKey();
|
|
792
|
+
return new _SoftwareKeyCustody(algo, priv2, ed255192.getPublicKey(priv2));
|
|
793
|
+
}
|
|
794
|
+
const priv = p3842.utils.randomPrivateKey();
|
|
795
|
+
return new _SoftwareKeyCustody(algo, priv, p3842.getPublicKey(priv));
|
|
796
|
+
}
|
|
797
|
+
/** Construct from an existing private key (e.g. a CI fixture).
|
|
798
|
+
* `quiet` suppresses the dev banner for browser flows (start-fresh) where
|
|
799
|
+
* the software key IS the production custody and the warning is noise. */
|
|
800
|
+
static fromPrivateKey(algo, priv, opts) {
|
|
801
|
+
const pub = algo === "ed25519" ? ed255192.getPublicKey(priv) : p3842.getPublicKey(priv);
|
|
802
|
+
return new _SoftwareKeyCustody(algo, priv, pub, opts?.quiet === true);
|
|
803
|
+
}
|
|
804
|
+
async sign(canonicalBytes) {
|
|
805
|
+
if (this.algo === "ed25519")
|
|
806
|
+
return ed255192.sign(canonicalBytes, this.priv);
|
|
807
|
+
return p3842.sign(canonicalBytes, this.priv, { prehash: true }).toCompactRawBytes();
|
|
808
|
+
}
|
|
809
|
+
publicKey() {
|
|
810
|
+
return this.pub;
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// ../connect-sdk-ts/dist/errors.js
|
|
815
|
+
var ConnectSdkError = class extends Error {
|
|
816
|
+
constructor(message) {
|
|
817
|
+
super(message);
|
|
818
|
+
this.name = new.target.name;
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
var RegistrationError = class extends ConnectSdkError {
|
|
822
|
+
};
|
|
823
|
+
var RateLimitError = class extends ConnectSdkError {
|
|
824
|
+
retryAfterSec;
|
|
825
|
+
constructor(message, retryAfterSec) {
|
|
826
|
+
super(message);
|
|
827
|
+
this.retryAfterSec = retryAfterSec;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
var AuditAccessDenied = class extends ConnectSdkError {
|
|
831
|
+
};
|
|
832
|
+
var StaleEpochError = class extends ConnectSdkError {
|
|
833
|
+
clientEpoch;
|
|
834
|
+
serverEpoch;
|
|
835
|
+
constructor(clientEpoch, serverEpoch) {
|
|
836
|
+
super(`mls.stale_epoch: client_epoch=${clientEpoch} server_epoch=${serverEpoch}`);
|
|
837
|
+
this.clientEpoch = clientEpoch;
|
|
838
|
+
this.serverEpoch = serverEpoch;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
async function staleEpochErrorFromResponse(res) {
|
|
842
|
+
if (res.status !== 400)
|
|
843
|
+
return null;
|
|
844
|
+
let body;
|
|
845
|
+
try {
|
|
846
|
+
body = await res.json();
|
|
847
|
+
} catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
const detail = body?.detail;
|
|
851
|
+
const d = detail;
|
|
852
|
+
if (!d || d.error !== "mls.stale_epoch")
|
|
853
|
+
return null;
|
|
854
|
+
return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ../connect-sdk-ts/dist/envelope-builder.js
|
|
858
|
+
async function buildOutboundEnvelope(p) {
|
|
859
|
+
const suite = p.suite ?? p.groupKey.suite;
|
|
860
|
+
const key = await p.groupKey.exporterKey(p.enclaveId, p.channelId, p.epoch);
|
|
861
|
+
const replayNonce = p.replayNonce ?? generateReplayNonce();
|
|
862
|
+
const policyBinding = {
|
|
863
|
+
binding_id: p.bindingId,
|
|
864
|
+
version: p.bindingVersion,
|
|
865
|
+
agent_skill_id: p.agentSkillId,
|
|
866
|
+
delegation_id: null,
|
|
867
|
+
client_stage5_outcome: p.stage5Outcome,
|
|
868
|
+
client_stage5_evaluators: p.stage5Evaluators
|
|
869
|
+
};
|
|
870
|
+
const aad = await buildAssociatedData({
|
|
871
|
+
message_id: p.messageId,
|
|
872
|
+
channel_id: p.channelId,
|
|
873
|
+
sender_id: p.senderId,
|
|
874
|
+
mls_group_epoch: p.epoch,
|
|
875
|
+
replay_nonce: replayNonce,
|
|
876
|
+
policy_binding: policyBinding
|
|
877
|
+
});
|
|
878
|
+
const sealed = sealPayload({
|
|
879
|
+
plaintext: new TextEncoder().encode(p.bodyPlaintext),
|
|
880
|
+
key,
|
|
881
|
+
associatedData: aad,
|
|
882
|
+
suite,
|
|
883
|
+
nonce: p.aeadNonce
|
|
884
|
+
});
|
|
885
|
+
const envelope = {
|
|
886
|
+
av_version: AV_ENVELOPE_VERSION,
|
|
887
|
+
message_id: p.messageId,
|
|
888
|
+
channel_id: p.channelId,
|
|
889
|
+
enclave_id: p.enclaveId,
|
|
890
|
+
message_type: p.type,
|
|
891
|
+
sender_id: p.senderId,
|
|
892
|
+
sender_type: p.senderType,
|
|
893
|
+
identity_proof: {
|
|
894
|
+
signature: "",
|
|
895
|
+
algo: p.custody.algo,
|
|
896
|
+
attested: p.custody.attested,
|
|
897
|
+
canonicalization: "RFC8785"
|
|
898
|
+
},
|
|
899
|
+
trace_context: { traceparent: p.traceparent },
|
|
900
|
+
policy_binding: policyBinding,
|
|
901
|
+
mls_group_epoch: p.epoch,
|
|
902
|
+
replay_nonce: replayNonce,
|
|
903
|
+
payload_encrypted: bytesToBase64(sealed),
|
|
904
|
+
references: p.references
|
|
905
|
+
};
|
|
906
|
+
if (p.mentions && p.mentions.length > 0)
|
|
907
|
+
envelope.mentions = p.mentions;
|
|
908
|
+
const canonical = await canonicalEnvelopeBytes(envelope);
|
|
909
|
+
envelope.identity_proof.signature = bytesToBase64(await p.custody.sign(canonical));
|
|
910
|
+
return envelope;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ../connect-sdk-ts/dist/groupkey/send-self-heal.js
|
|
914
|
+
async function sendWithSelfHeal(session, channelId, build, publish) {
|
|
915
|
+
await session.reconcileForSend(channelId);
|
|
916
|
+
const epoch = await session.currentEpoch(channelId);
|
|
917
|
+
const envelope = await build(epoch);
|
|
918
|
+
try {
|
|
919
|
+
return await publish(envelope);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
if (!(err instanceof StaleEpochError))
|
|
922
|
+
throw err;
|
|
923
|
+
await session.reconcileForSend(channelId);
|
|
924
|
+
const retryEpoch = await session.currentEpoch(channelId);
|
|
925
|
+
const retryEnvelope = await build(retryEpoch);
|
|
926
|
+
return await publish(retryEnvelope);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ../connect-sdk-ts/dist/groupkey/stub.js
|
|
931
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
932
|
+
|
|
933
|
+
// ../connect-sdk-ts/dist/spans/names.js
|
|
934
|
+
var SPAN = {
|
|
935
|
+
MESSAGE_SENT: "agentvault.connect.message.sent",
|
|
936
|
+
MESSAGE_QUEUED_OFFLINE: "agentvault.connect.message.queued_offline",
|
|
937
|
+
POLICY_RESCAN: "agentvault.connect.policy.rescan",
|
|
938
|
+
POLICY_EVAL_DELAYED_FLAG: "agentvault.connect.policy.eval.delayed_flag",
|
|
939
|
+
AUDIT_QUERY: "agentvault.connect.audit.query",
|
|
940
|
+
AGENT_REGISTERED: "agentvault.connect.agent.registered",
|
|
941
|
+
AGENT_JOINED: "agentvault.connect.agent.joined",
|
|
942
|
+
AGENT_CAPABILITIES_DECLARED: "agentvault.connect.agent.capabilities_declared",
|
|
943
|
+
AGENT_CAPABILITY_INVOKED: "agentvault.connect.agent.capability_invoked",
|
|
944
|
+
AGENT_TOOL_CALLED: "agentvault.connect.agent.tool_called",
|
|
945
|
+
ACTION_CONFIRMATION_REQUESTED: "agentvault.connect.action_confirmation.requested",
|
|
946
|
+
ACTION_CONFIRMATION_RECEIVED: "agentvault.connect.action_confirmation.received",
|
|
947
|
+
MODEL_DESCRIPTOR_CHANGED: "agentvault.connect.model.descriptor_changed",
|
|
948
|
+
AGENT_STATUS_REPORTED: "agentvault.connect.agent.status_reported"
|
|
949
|
+
};
|
|
950
|
+
var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
|
|
951
|
+
SPAN.AGENT_REGISTERED,
|
|
952
|
+
SPAN.ACTION_CONFIRMATION_REQUESTED,
|
|
953
|
+
SPAN.ACTION_CONFIRMATION_RECEIVED
|
|
954
|
+
]);
|
|
955
|
+
|
|
956
|
+
// ../connect-sdk-ts/dist/spans/emitter.js
|
|
957
|
+
var KNOWN = new Set(Object.values(SPAN));
|
|
958
|
+
|
|
959
|
+
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
960
|
+
import { MLSGroupManager, bytesToHex, hexToBytes } from "@agentvault/crypto";
|
|
961
|
+
|
|
962
|
+
// ../connect-sdk-ts/dist/groupkey/operation-queue.js
|
|
963
|
+
var MlsOperationQueue = class {
|
|
964
|
+
chains = /* @__PURE__ */ new Map();
|
|
965
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
966
|
+
async run(channelId, op) {
|
|
967
|
+
const prev = this.chains.get(channelId) ?? Promise.resolve();
|
|
968
|
+
const next = prev.then(op, op);
|
|
969
|
+
this.chains.set(channelId, next);
|
|
970
|
+
this.inFlight.set(channelId, (this.inFlight.get(channelId) ?? 0) + 1);
|
|
971
|
+
try {
|
|
972
|
+
return await next;
|
|
973
|
+
} finally {
|
|
974
|
+
const remaining = (this.inFlight.get(channelId) ?? 1) - 1;
|
|
975
|
+
if (remaining === 0) {
|
|
976
|
+
this.inFlight.delete(channelId);
|
|
977
|
+
if (this.chains.get(channelId) === next) {
|
|
978
|
+
this.chains.delete(channelId);
|
|
979
|
+
}
|
|
980
|
+
} else {
|
|
981
|
+
this.inFlight.set(channelId, remaining);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/** Test / diagnostic only. Number of in-flight + pending ops for a channel. */
|
|
986
|
+
chainSize(channelId) {
|
|
987
|
+
return this.inFlight.get(channelId) ?? 0;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
// ../connect-sdk-ts/dist/groupkey/mls.js
|
|
992
|
+
var LABEL = "agentvault-connect-v1";
|
|
993
|
+
var DEFAULT_STUCK_THRESHOLD = 2;
|
|
994
|
+
var BootstrapRaceLostError = class extends Error {
|
|
995
|
+
constructor() {
|
|
996
|
+
super("bootstrap race lost: another device created the group; join instead");
|
|
997
|
+
this.name = "BootstrapRaceLostError";
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var LeafRosterMismatchError = class extends Error {
|
|
1001
|
+
constructor() {
|
|
1002
|
+
super("removeDeviceFromLiveGroup: leaf/roster length mismatch (stale roster \u2014 re-fetch and retry)");
|
|
1003
|
+
this.name = "LeafRosterMismatchError";
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
var HealAddAfterRemoveError = class extends Error {
|
|
1007
|
+
constructor(cause) {
|
|
1008
|
+
super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
|
|
1009
|
+
this.name = "HealAddAfterRemoveError";
|
|
1010
|
+
this.cause = cause;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
var MlsGroupKeyProvider = class {
|
|
1014
|
+
o;
|
|
1015
|
+
suite;
|
|
1016
|
+
managers = /* @__PURE__ */ new Map();
|
|
1017
|
+
/**
|
|
1018
|
+
* Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
|
|
1019
|
+
* manager-mutating operation on a channel takes this lock keyed by channelId,
|
|
1020
|
+
* so at most one runs per channel at a time; different channels still run in
|
|
1021
|
+
* parallel. This is the fix for the manager-mutation interleave Sigil's review
|
|
1022
|
+
* of the self-heal-on-send fix surfaced: the device-wide receive pump, the
|
|
1023
|
+
* committer ops, and reconcileForSend could all mutate one channel's cached
|
|
1024
|
+
* MLSGroupManager concurrently.
|
|
1025
|
+
*
|
|
1026
|
+
* Granularity (load-bearing): receiveOnce is DEVICE-wide — it pulls one queue
|
|
1027
|
+
* and routes commit/application rows to MANY channels' managers. It therefore
|
|
1028
|
+
* takes the lock PER ROW, keyed by that row's channel, rather than one
|
|
1029
|
+
* provider-wide lock — so a commit on channel A never serializes against a
|
|
1030
|
+
* decrypt on channel B. The committer ops take the lock for their WHOLE
|
|
1031
|
+
* resolve→commit→distribute→persist window. Because the lock is not reentrant,
|
|
1032
|
+
* the committers' own conflict-recovery drain runs the UNLOCKED core
|
|
1033
|
+
* (receiveOnceUnlocked, scoped to the committer's own channel — it already
|
|
1034
|
+
* holds that channel's lock).
|
|
1035
|
+
*/
|
|
1036
|
+
locks = new MlsOperationQueue();
|
|
1037
|
+
/**
|
|
1038
|
+
* Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
|
|
1039
|
+
* of channels this device touches (channels are bounded; an entry is created
|
|
1040
|
+
* only on a real decrypt error), so it cannot grow without limit. Read via
|
|
1041
|
+
* {@link errorStats}; mutated only through {@link recordDecryptError} /
|
|
1042
|
+
* {@link recordDecryptSuccess}.
|
|
1043
|
+
*/
|
|
1044
|
+
errors = /* @__PURE__ */ new Map();
|
|
1045
|
+
stuckThreshold;
|
|
1046
|
+
/** Cumulative count of past-epoch application rows permanently skipped (acked
|
|
1047
|
+
* and drained) because forward-secrecy has deleted their epoch's secrets. Read
|
|
1048
|
+
* via {@link permanentlySkippedCount}. */
|
|
1049
|
+
permanentlySkipped = 0;
|
|
1050
|
+
constructor(o) {
|
|
1051
|
+
this.o = o;
|
|
1052
|
+
this.suite = o.suite ?? "xchacha20poly1305";
|
|
1053
|
+
this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
|
|
1054
|
+
}
|
|
1055
|
+
get deviceId() {
|
|
1056
|
+
return this.o.deviceId;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Count a decrypt/parse failure against a channel and fire the optional
|
|
1060
|
+
* onDecryptError hook. Increments BOTH the cumulative total and the
|
|
1061
|
+
* consecutive-since-last-success counter (the latter drives `stuck`). A row
|
|
1062
|
+
* with no channel mapping is still surfaced via the hook but not counted
|
|
1063
|
+
* against any channel (we cannot attribute it). Always called from inside the
|
|
1064
|
+
* row's catch — never changes decrypt/convergence behavior.
|
|
1065
|
+
*/
|
|
1066
|
+
recordDecryptError(row, error) {
|
|
1067
|
+
this.o.onDecryptError?.(row, error);
|
|
1068
|
+
const cid = row.connect_channel_id;
|
|
1069
|
+
if (!cid)
|
|
1070
|
+
return;
|
|
1071
|
+
const e = this.errors.get(cid) ?? { decryptErrors: 0, consecutiveErrors: 0, lastErrorAt: null };
|
|
1072
|
+
e.decryptErrors += 1;
|
|
1073
|
+
e.consecutiveErrors += 1;
|
|
1074
|
+
e.lastErrorAt = Date.now();
|
|
1075
|
+
this.errors.set(cid, e);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* A commit/application row for the channel decrypted cleanly: the channel is
|
|
1079
|
+
* advancing, so clear the consecutive-error run (un-sticks it). Leaves the
|
|
1080
|
+
* cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
|
|
1081
|
+
* that never errored.
|
|
1082
|
+
*/
|
|
1083
|
+
recordDecryptSuccess(channelId) {
|
|
1084
|
+
const e = this.errors.get(channelId);
|
|
1085
|
+
if (e && e.consecutiveErrors !== 0)
|
|
1086
|
+
e.consecutiveErrors = 0;
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
|
|
1090
|
+
* Surfaces a wedged channel that would otherwise "look idle": the daemon serves
|
|
1091
|
+
* it on GET /v1/metrics and the web shell reads it via a selector. A channel is
|
|
1092
|
+
* `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
|
|
1093
|
+
* intervening successful decrypt. Returns a plain object keyed by channelId;
|
|
1094
|
+
* absence of a key means zero errors observed for that channel.
|
|
1095
|
+
*/
|
|
1096
|
+
errorStats() {
|
|
1097
|
+
const out = {};
|
|
1098
|
+
for (const [cid, e] of this.errors) {
|
|
1099
|
+
out[cid] = {
|
|
1100
|
+
decryptErrors: e.decryptErrors,
|
|
1101
|
+
consecutiveErrors: e.consecutiveErrors,
|
|
1102
|
+
lastErrorAt: e.lastErrorAt,
|
|
1103
|
+
stuck: e.consecutiveErrors >= this.stuckThreshold
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
return out;
|
|
1107
|
+
}
|
|
1108
|
+
/** Total past-epoch rows permanently skipped (acked without decrypting) since
|
|
1109
|
+
* this provider was instantiated. A permanently-undecryptable row is one whose
|
|
1110
|
+
* `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
|
|
1111
|
+
* has deleted its epoch secrets so it can never decrypt on any retry. */
|
|
1112
|
+
permanentlySkippedCount() {
|
|
1113
|
+
return this.permanentlySkipped;
|
|
1114
|
+
}
|
|
1115
|
+
/** Evict the live in-memory MLS group manager for a channel. Used when the
|
|
1116
|
+
* device leaves or deletes a channel: dropping the persisted store record is
|
|
1117
|
+
* not enough on its own, because a warm manager keeps decrypting until the
|
|
1118
|
+
* page reloads. The persisted store record is removed separately by the
|
|
1119
|
+
* caller; this clears the matching in-memory cache. `managers` is the only
|
|
1120
|
+
* per-channel in-memory cache the provider holds. */
|
|
1121
|
+
evictChannelState(channelId) {
|
|
1122
|
+
this.managers.delete(channelId);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Warm a channel's group manager so a commit can run even when the room isn't
|
|
1126
|
+
* currently open. Used by the guest reconcile sweep (and slice-3 bell-approve):
|
|
1127
|
+
* the sweep may fire for a channel the host hasn't opened this session, so the
|
|
1128
|
+
* in-memory manager is cold even though the store holds usable state.
|
|
1129
|
+
*
|
|
1130
|
+
* If the store has persisted state, load it into the warm in-memory cache (via
|
|
1131
|
+
* the private manager() loader). Otherwise fall through to the join path
|
|
1132
|
+
* (ensureSession bootstrap:false), which consumes a queued Welcome. Idempotent:
|
|
1133
|
+
* a second call on an already-warm channel is a cheap no-op (manager() returns
|
|
1134
|
+
* the cached manager and ensureSession early-returns on existing store state).
|
|
1135
|
+
*/
|
|
1136
|
+
async ensureWarm(channelId, enclaveId) {
|
|
1137
|
+
if (await this.o.store.load(channelId)) {
|
|
1138
|
+
await this.locks.run(channelId, async () => {
|
|
1139
|
+
await this.manager(channelId);
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
await this.ensureSession(channelId, { enclaveId, bootstrap: false });
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Admit a guest's (possibly new) device into a live group, evicting any prior
|
|
1147
|
+
* device leaf for that SAME guest identity so a returning guest holds exactly
|
|
1148
|
+
* ONE active leaf. Used by the guest reconcile sweep when an approved guest
|
|
1149
|
+
* sits in `joining`.
|
|
1150
|
+
*
|
|
1151
|
+
* Add then evict: we commit the Add first so the new leaf exists before the
|
|
1152
|
+
* old one is dropped (the guest is never momentarily leaf-less in the group).
|
|
1153
|
+
* The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
|
|
1154
|
+
* which resolves the target leaf POSITIONALLY by add-order. At the moment of
|
|
1155
|
+
* eviction the identity owns TWO leaves (prior + new), so the ordered-device
|
|
1156
|
+
* list MUST name both in add-order — the prior device was added first, the new
|
|
1157
|
+
* one just now — or the SDK's leaf/roster guard throws LeafRosterMismatchError.
|
|
1158
|
+
* Callers that hold the real channel roster may override `orderedDeviceIds`;
|
|
1159
|
+
* the default `[priorDeviceId, newDeviceId]` is the natural add-order for the
|
|
1160
|
+
* common single-prior-device return.
|
|
1161
|
+
*
|
|
1162
|
+
* No-op eviction when there is no priorDeviceId (first-time guest) or when it
|
|
1163
|
+
* equals newDeviceId (the same device re-admitted).
|
|
1164
|
+
*/
|
|
1165
|
+
async admitGuestDevice(channelId, g) {
|
|
1166
|
+
await this.addMemberToLiveGroup(channelId, g.newDeviceId);
|
|
1167
|
+
if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
|
|
1168
|
+
const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
|
|
1169
|
+
await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
|
|
1170
|
+
identity: g.identityId,
|
|
1171
|
+
orderedDeviceIds
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/** Load (or reuse) the per-channel manager from the store. Throws if no state. */
|
|
1176
|
+
async manager(channelId) {
|
|
1177
|
+
const cached = this.managers.get(channelId);
|
|
1178
|
+
if (cached)
|
|
1179
|
+
return cached;
|
|
1180
|
+
const saved = await this.o.store.load(channelId);
|
|
1181
|
+
if (!saved)
|
|
1182
|
+
throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
|
|
1183
|
+
const mgr = new MLSGroupManager(saved.ciphersuite);
|
|
1184
|
+
mgr.importState(saved);
|
|
1185
|
+
this.managers.set(channelId, mgr);
|
|
1186
|
+
return mgr;
|
|
1187
|
+
}
|
|
1188
|
+
async currentEpoch(channelId) {
|
|
1189
|
+
return Number((await this.manager(channelId)).epoch);
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Run a committer op and, on ANY throw that escapes it, evict the channel's
|
|
1193
|
+
* cached MLSGroupManager.
|
|
1194
|
+
*
|
|
1195
|
+
* The committer ops mutate the CACHED manager (mgr.addMembers / removeMembers
|
|
1196
|
+
* advance it) and persist the advanced state only AFTER a successful
|
|
1197
|
+
* distribute. The distribute-catch already evicts on a 409. But if the
|
|
1198
|
+
* mutation itself throws — or the B11 epoch-drift guard throws after the
|
|
1199
|
+
* mutation advanced the manager — the cached manager is left partially/fully
|
|
1200
|
+
* mutated and never persisted. The per-channel queue would then run the next
|
|
1201
|
+
* serialized op against that SAME poisoned cache entry (manager() returns the
|
|
1202
|
+
* cache before touching the store). Dropping the cache entry forces the next
|
|
1203
|
+
* manager() to reload a clean instance from the committed store state.
|
|
1204
|
+
*
|
|
1205
|
+
* Always called from INSIDE the channel lock, so the eviction can't race a
|
|
1206
|
+
* concurrent op on the same channel. Idempotent with the distribute-catch's own
|
|
1207
|
+
* delete (a re-delete is a no-op). On success the manager stays cached as
|
|
1208
|
+
* before — no eviction.
|
|
1209
|
+
*/
|
|
1210
|
+
async evictOnThrow(channelId, op) {
|
|
1211
|
+
try {
|
|
1212
|
+
return await op();
|
|
1213
|
+
} catch (e) {
|
|
1214
|
+
this.managers.delete(channelId);
|
|
1215
|
+
throw e;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async exporterKey(enclaveId, channelId, epoch) {
|
|
1219
|
+
const mgr = await this.manager(channelId);
|
|
1220
|
+
return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Join a channel from the host-committed Welcome, then drain any commits that
|
|
1224
|
+
* landed after the Welcome so we converge on the true current epoch. Without
|
|
1225
|
+
* this, confirm-join 409s `member_epoch_stale` whenever the committer advanced
|
|
1226
|
+
* the epoch between Welcome generation and our join.
|
|
1227
|
+
*/
|
|
1228
|
+
async joinAndReconcile(channelId, enclaveId) {
|
|
1229
|
+
await this.ensureSession(channelId, { enclaveId, bootstrap: false });
|
|
1230
|
+
try {
|
|
1231
|
+
await this.receiveOnce(this.o.deviceId);
|
|
1232
|
+
} catch {
|
|
1233
|
+
}
|
|
1234
|
+
return this.currentEpoch(channelId);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Self-heal-on-send (Phase 1.1). Converge the sender to the live group epoch
|
|
1238
|
+
* BEFORE it seals an application message: drain any pending commits (via
|
|
1239
|
+
* {@link receiveOnce}) so {@link currentEpoch} reflects the live group. A
|
|
1240
|
+
* sender one epoch behind would otherwise seal at a stale epoch — the backend
|
|
1241
|
+
* rejects that with an epoch-stale 400 (the "owner-send-stuck / shows queued,
|
|
1242
|
+
* never posts" symptom), and even a blind relay would deliver ciphertext no
|
|
1243
|
+
* live-epoch peer can decrypt (forward secrecy deleted the old secret).
|
|
1244
|
+
*
|
|
1245
|
+
* Best-effort and idempotent: if there is nothing pending it is a cheap no-op
|
|
1246
|
+
* (one pullDelivery), and a drain failure is swallowed so it never breaks the
|
|
1247
|
+
* send path — the caller publishes at whatever epoch we reach and the
|
|
1248
|
+
* defensive reconcile-then-retry-once on a publish 400 covers a commit that
|
|
1249
|
+
* lands between this drain and publish.
|
|
1250
|
+
*
|
|
1251
|
+
* COMMITTER SAFETY (B11): this MUST only be called from a normal application-
|
|
1252
|
+
* send entry point, never inside a committer's resolve→distribute window
|
|
1253
|
+
* (addMemberToLiveGroup / removeMemberFromLiveGroup / removeDeviceFromLiveGroup
|
|
1254
|
+
* stage their own commit and explicitly must NOT receiveOnce between resolving
|
|
1255
|
+
* members and distributing — doing so would race their in-flight commit). A
|
|
1256
|
+
* text/a2a/status send is never mid-distribute, so scoping reconcileForSend to
|
|
1257
|
+
* those entry points keeps the receiveOnce drain out of any committer window.
|
|
1258
|
+
* If the channel has no local MLS state yet (nothing to drain/seal), this is a
|
|
1259
|
+
* no-op rather than an error.
|
|
1260
|
+
*/
|
|
1261
|
+
async reconcileForSend(channelId) {
|
|
1262
|
+
if (!await this.o.store.load(channelId))
|
|
1263
|
+
return;
|
|
1264
|
+
try {
|
|
1265
|
+
await this.receiveOnce(this.o.deviceId);
|
|
1266
|
+
} catch {
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Peek the device's delivery queue for a pending Welcome WITHOUT acking it.
|
|
1271
|
+
*
|
|
1272
|
+
* Returns the `connect_channel_id` of the first welcome row, or null when none
|
|
1273
|
+
* is queued yet. Used by the external-guest join to detect host approval over
|
|
1274
|
+
* the guest bearer (the guest can't call the Clerk-gated /me or channel-list).
|
|
1275
|
+
* The row is deliberately NOT acked here — the subsequent ensureSession(join) /
|
|
1276
|
+
* joinAndReconcile consumes the same Welcome and acks it after joining.
|
|
1277
|
+
*/
|
|
1278
|
+
async peekWelcomeChannel(deviceId) {
|
|
1279
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
1280
|
+
const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
|
|
1281
|
+
return w?.connect_channel_id ?? null;
|
|
1282
|
+
}
|
|
1283
|
+
async publishKeyPackage() {
|
|
1284
|
+
if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
|
|
1285
|
+
console.warn("publishKeyPackage: pending bundle exists; skipping republish");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const mgr = new MLSGroupManager();
|
|
1289
|
+
const bundle = await mgr.generateKeyPackage(this.o.identity);
|
|
1290
|
+
const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
|
|
1291
|
+
await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
|
|
1292
|
+
await this.o.store.savePendingBundle(this.o.deviceId, bundle);
|
|
1293
|
+
}
|
|
1294
|
+
async ensureSession(channelId, opts) {
|
|
1295
|
+
return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
|
|
1296
|
+
}
|
|
1297
|
+
async ensureSessionUnlocked(channelId, opts) {
|
|
1298
|
+
if (!opts.forceReinit && await this.o.store.load(channelId))
|
|
1299
|
+
return;
|
|
1300
|
+
let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1301
|
+
if (!bundle && (opts.bootstrap || opts.forceReinit)) {
|
|
1302
|
+
await this.publishKeyPackage();
|
|
1303
|
+
bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
|
|
1304
|
+
}
|
|
1305
|
+
if (!bundle)
|
|
1306
|
+
throw new Error("ensureSession: call publishKeyPackage() first");
|
|
1307
|
+
if (opts.forceReinit) {
|
|
1308
|
+
const others = opts.otherDeviceIds ?? [];
|
|
1309
|
+
const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
|
|
1310
|
+
const mgr = new MLSGroupManager();
|
|
1311
|
+
await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
|
|
1312
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1313
|
+
const pkgs = others.map((d) => {
|
|
1314
|
+
const hex = kps[d];
|
|
1315
|
+
if (!hex)
|
|
1316
|
+
throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
|
|
1317
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1318
|
+
});
|
|
1319
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1320
|
+
await this.o.backend.distribute(reinit.group_id, {
|
|
1321
|
+
device_id: this.o.deviceId,
|
|
1322
|
+
commit: bytesToHex(commit),
|
|
1323
|
+
commit_epoch: Number(mgr.epoch),
|
|
1324
|
+
expected_epoch: reinit.current_epoch,
|
|
1325
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
1326
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
1327
|
+
});
|
|
1328
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1329
|
+
this.managers.set(channelId, mgr);
|
|
1330
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
if (opts.bootstrap) {
|
|
1334
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1335
|
+
if (init.created === false)
|
|
1336
|
+
throw new BootstrapRaceLostError();
|
|
1337
|
+
const mgr = new MLSGroupManager();
|
|
1338
|
+
await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
|
|
1339
|
+
const others = opts.otherDeviceIds ?? [];
|
|
1340
|
+
const kps = await this.o.backend.fetchKeyPackages(others);
|
|
1341
|
+
const pkgs = others.map((d) => {
|
|
1342
|
+
const hex = kps[d];
|
|
1343
|
+
if (!hex)
|
|
1344
|
+
throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
|
|
1345
|
+
return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1346
|
+
});
|
|
1347
|
+
const { commit, welcome } = await mgr.addMembers(pkgs);
|
|
1348
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1349
|
+
device_id: this.o.deviceId,
|
|
1350
|
+
commit: bytesToHex(commit),
|
|
1351
|
+
commit_epoch: Number(mgr.epoch),
|
|
1352
|
+
expected_epoch: init.current_epoch,
|
|
1353
|
+
member_device_ids: [this.o.deviceId, ...others],
|
|
1354
|
+
welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
|
|
1355
|
+
});
|
|
1356
|
+
this.managers.set(channelId, mgr);
|
|
1357
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1358
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1359
|
+
} else {
|
|
1360
|
+
const rows = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1361
|
+
const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
|
|
1362
|
+
if (!w)
|
|
1363
|
+
throw new Error("ensureSession(join): no Welcome available yet for this channel");
|
|
1364
|
+
const mgr = new MLSGroupManager();
|
|
1365
|
+
await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
|
|
1366
|
+
this.managers.set(channelId, mgr);
|
|
1367
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1368
|
+
await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
|
|
1369
|
+
const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
|
|
1370
|
+
let burstPending = false;
|
|
1371
|
+
for (const r of remaining) {
|
|
1372
|
+
if (r.message_type !== "welcome")
|
|
1373
|
+
continue;
|
|
1374
|
+
const cid = r.connect_channel_id;
|
|
1375
|
+
if (!cid || !await this.o.store.load(cid)) {
|
|
1376
|
+
burstPending = true;
|
|
1377
|
+
break;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (!burstPending)
|
|
1381
|
+
await this.o.store.deletePendingBundle(this.o.deviceId);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Committer-side: add a member to an ALREADY-bootstrapped (live) group.
|
|
1386
|
+
*
|
|
1387
|
+
* The bootstrap path (ensureSession bootstrap:true) only adds members at group
|
|
1388
|
+
* creation. The admission flow admits a member AFTER the group is live, so the
|
|
1389
|
+
* committer commits the Add against its saved state and distributes the commit
|
|
1390
|
+
* plus a Welcome targeting the new device.
|
|
1391
|
+
*
|
|
1392
|
+
* group_id resolution reuses the same idempotent init endpoint bootstrap uses:
|
|
1393
|
+
* for an existing channel it returns the live group's id, current epoch, and
|
|
1394
|
+
* full member roster (unioning in any device ids we pass). That avoids a
|
|
1395
|
+
* parallel group_id-by-channel resolver.
|
|
1396
|
+
*/
|
|
1397
|
+
async addMemberToLiveGroup(channelId, newDeviceId) {
|
|
1398
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
|
|
1399
|
+
}
|
|
1400
|
+
async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt) {
|
|
1401
|
+
const mgr = await this.manager(channelId);
|
|
1402
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
|
|
1403
|
+
const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
|
|
1404
|
+
const hex = kps[newDeviceId];
|
|
1405
|
+
if (!hex)
|
|
1406
|
+
throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
|
|
1407
|
+
const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
|
|
1408
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1409
|
+
const { commit, welcome } = await mgr.addMembers([pkg]);
|
|
1410
|
+
const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
|
|
1411
|
+
try {
|
|
1412
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1413
|
+
device_id: this.o.deviceId,
|
|
1414
|
+
commit: bytesToHex(commit),
|
|
1415
|
+
commit_epoch: Number(mgr.epoch),
|
|
1416
|
+
expected_epoch: priorEpoch,
|
|
1417
|
+
member_device_ids: members,
|
|
1418
|
+
op: "add",
|
|
1419
|
+
welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex(welcome) }] : []
|
|
1420
|
+
});
|
|
1421
|
+
} catch (e) {
|
|
1422
|
+
this.managers.delete(channelId);
|
|
1423
|
+
if (_attempt >= 2)
|
|
1424
|
+
throw e;
|
|
1425
|
+
try {
|
|
1426
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1);
|
|
1430
|
+
}
|
|
1431
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Committer-side: REMOVE a member from a live group (Phase C eviction).
|
|
1435
|
+
*
|
|
1436
|
+
* Mirrors {@link addMemberToLiveGroup}, but commits a Remove (no Welcome) and
|
|
1437
|
+
* uses the store's two-slot staging for crash/conflict safety: the advanced
|
|
1438
|
+
* (post-removal) state is written to STAGING before the removal-Commit is
|
|
1439
|
+
* distributed, promoted to MAIN only on a 2xx, and discarded on a 409 so a
|
|
1440
|
+
* reload from MAIN yields the pre-commit state. The removed member's leaf is
|
|
1441
|
+
* identified by its credential identity (a member's devices share one identity,
|
|
1442
|
+
* so this evicts ALL of that member's device leaves). The ts-mls forced
|
|
1443
|
+
* UpdatePath (patches/ts-mls+1.6.2.patch) rotates the new epoch's key schedule
|
|
1444
|
+
* away from every evicted leaf, so the removed member cannot derive the new
|
|
1445
|
+
* exporter secret.
|
|
1446
|
+
*
|
|
1447
|
+
* The server resolves removed_identity_id → the device(s) to drop from the
|
|
1448
|
+
* roster + purge from delivery; member_device_ids here is the SDK's roster view
|
|
1449
|
+
* for relays that don't do that resolution. No-op if the identity isn't a member
|
|
1450
|
+
* of this group.
|
|
1451
|
+
*/
|
|
1452
|
+
async removeMemberFromLiveGroup(channelId, identityId) {
|
|
1453
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0)));
|
|
1454
|
+
}
|
|
1455
|
+
async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
|
|
1456
|
+
const mgr = await this.manager(channelId);
|
|
1457
|
+
const indices = mgr.leafIndicesForIdentity(identityId);
|
|
1458
|
+
if (indices.length === 0)
|
|
1459
|
+
return;
|
|
1460
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1461
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1462
|
+
const { commit } = await mgr.removeMembers(indices);
|
|
1463
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
1464
|
+
throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
|
|
1465
|
+
}
|
|
1466
|
+
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
1467
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
1468
|
+
try {
|
|
1469
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1470
|
+
device_id: this.o.deviceId,
|
|
1471
|
+
commit: bytesToHex(commit),
|
|
1472
|
+
commit_epoch: Number(mgr.epoch),
|
|
1473
|
+
expected_epoch: priorEpoch,
|
|
1474
|
+
member_device_ids: members,
|
|
1475
|
+
op: "remove",
|
|
1476
|
+
removed_identity_id: identityId,
|
|
1477
|
+
welcomes: []
|
|
1478
|
+
});
|
|
1479
|
+
} catch (e) {
|
|
1480
|
+
this.managers.delete(channelId);
|
|
1481
|
+
await this.o.store.discardStaging(channelId);
|
|
1482
|
+
if (_attempt >= 2)
|
|
1483
|
+
throw e;
|
|
1484
|
+
try {
|
|
1485
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1486
|
+
} catch {
|
|
1487
|
+
}
|
|
1488
|
+
return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
|
|
1489
|
+
}
|
|
1490
|
+
await this.o.store.promoteStaging(channelId);
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
|
|
1494
|
+
* per-device eviction).
|
|
1495
|
+
*
|
|
1496
|
+
* The identity-level {@link removeMemberFromLiveGroup} evicts EVERY leaf of a
|
|
1497
|
+
* credential identity. This evicts ONE — the counterpart to revoking a single
|
|
1498
|
+
* device while the owner keeps their other devices in the channel.
|
|
1499
|
+
*
|
|
1500
|
+
* Leaf resolution (LOCKED): MLS leaves carry only the member's credential
|
|
1501
|
+
* identity, never a device id, so same-identity devices are indistinguishable
|
|
1502
|
+
* at the crypto layer. The ONLY correct mapping is POSITIONAL BY ADD-ORDER:
|
|
1503
|
+
* leaves are appended in the order devices were added, so the i-th add-ordered
|
|
1504
|
+
* device of an identity occupies the i-th ascending leaf index for that
|
|
1505
|
+
* identity. The caller (which holds the channel roster with identities) passes
|
|
1506
|
+
* the target's identity AND the add-ordered device-id list; we resolve the leaf
|
|
1507
|
+
* by `orderedDeviceIds.indexOf(deviceId)`. If the tree's leaf count for that
|
|
1508
|
+
* identity disagrees with the roster's device count, we REFUSE rather than guess
|
|
1509
|
+
* (a wrong leaf would evict the wrong device).
|
|
1510
|
+
*
|
|
1511
|
+
* Everything else mirrors {@link removeMemberFromLiveGroup}: resolve leaf BEFORE
|
|
1512
|
+
* the commit, capture priorEpoch, single-leaf removeMembers, the B11 epoch-drift
|
|
1513
|
+
* guard, and two-slot staging (save→distribute→promote on 2xx / discard + evict
|
|
1514
|
+
* + receiveOnce + retry on 409). The distribute carries op:"remove" +
|
|
1515
|
+
* `removed_device_id` (instead of removed_identity_id); the server resolves the
|
|
1516
|
+
* device's identity, asserts it matches, and purges that ONE device.
|
|
1517
|
+
*/
|
|
1518
|
+
async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
|
|
1519
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
|
|
1520
|
+
}
|
|
1521
|
+
async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
|
|
1522
|
+
const mgr = await this.manager(channelId);
|
|
1523
|
+
const pos = opts.orderedDeviceIds.indexOf(deviceId);
|
|
1524
|
+
if (pos < 0)
|
|
1525
|
+
return;
|
|
1526
|
+
const leaves = mgr.leafIndicesForIdentity(opts.identity);
|
|
1527
|
+
if (leaves.length !== opts.orderedDeviceIds.length) {
|
|
1528
|
+
throw new LeafRosterMismatchError();
|
|
1529
|
+
}
|
|
1530
|
+
const leafIndex = leaves[pos];
|
|
1531
|
+
const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
|
|
1532
|
+
const priorEpoch = Number(mgr.epoch);
|
|
1533
|
+
const { commit } = await mgr.removeMembers([leafIndex]);
|
|
1534
|
+
if (Number(mgr.epoch) !== priorEpoch + 1) {
|
|
1535
|
+
throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
|
|
1536
|
+
}
|
|
1537
|
+
await this.o.store.saveStaging(channelId, mgr.exportState());
|
|
1538
|
+
const members = init.member_device_ids ?? [this.o.deviceId];
|
|
1539
|
+
const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex(opts.identity);
|
|
1540
|
+
try {
|
|
1541
|
+
await this.o.backend.distribute(init.group_id, {
|
|
1542
|
+
device_id: this.o.deviceId,
|
|
1543
|
+
commit: bytesToHex(commit),
|
|
1544
|
+
commit_epoch: Number(mgr.epoch),
|
|
1545
|
+
expected_epoch: priorEpoch,
|
|
1546
|
+
member_device_ids: members,
|
|
1547
|
+
op: "remove",
|
|
1548
|
+
removed_identity_id: removedIdentity,
|
|
1549
|
+
removed_device_id: deviceId,
|
|
1550
|
+
welcomes: []
|
|
1551
|
+
});
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
this.managers.delete(channelId);
|
|
1554
|
+
await this.o.store.discardStaging(channelId);
|
|
1555
|
+
if (_attempt >= 2)
|
|
1556
|
+
throw e;
|
|
1557
|
+
try {
|
|
1558
|
+
await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
|
|
1559
|
+
} catch {
|
|
1560
|
+
}
|
|
1561
|
+
return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
|
|
1562
|
+
}
|
|
1563
|
+
await this.o.store.promoteStaging(channelId);
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
|
|
1567
|
+
* Add(device), re-keying it with a fresh Welcome at the live epoch (Phase 1
|
|
1568
|
+
* member self-heal).
|
|
1569
|
+
*
|
|
1570
|
+
* ATOMICITY (S1, honest framing): this is atomic against CONCURRENCY — the
|
|
1571
|
+
* per-channel lock is taken ONCE and held continuously across both mutations, so
|
|
1572
|
+
* no peer commit or receive-pump row can interleave between the Remove and the
|
|
1573
|
+
* Add. It is NOT TRANSACTIONAL across the two server distributes: there is no
|
|
1574
|
+
* rollback of a landed Remove. If the Add throws AFTER the Remove's distribute
|
|
1575
|
+
* already succeeded, the member is left REMOVED-but-not-readded (detached —
|
|
1576
|
+
* strictly worse than the original wedge). That specific failure is surfaced as a
|
|
1577
|
+
* {@link HealAddAfterRemoveError} so a caller can recover by RE-RUNNING the heal:
|
|
1578
|
+
* the retry finds zero leaves for the identity and takes the Add-only branch,
|
|
1579
|
+
* completing the re-key. An Add failure BEFORE any Remove landed (e.g. the
|
|
1580
|
+
* zero-leaf branch, or a fetchKeyPackages failure) is not detaching and rethrows
|
|
1581
|
+
* as-is.
|
|
1582
|
+
*
|
|
1583
|
+
* The wedge this clears: a member fell behind, can't catch up (forward secrecy
|
|
1584
|
+
* deleted the key it needs), still HOLDS a leaf (so the admission watcher won't
|
|
1585
|
+
* re-add it), and its saved MLS state is diverged. There is no MLS-correct
|
|
1586
|
+
* "re-welcome in place" for a member that still occupies a leaf — handing a
|
|
1587
|
+
* Welcome to an identity that already holds a leaf is a two-leaf bug. The only
|
|
1588
|
+
* correct heal is to drop its stale leaf and re-Add the device, which seals a
|
|
1589
|
+
* fresh Welcome to it at the post-Remove live epoch. (reinit is the WRONG tool —
|
|
1590
|
+
* that is the committer-loss hammer, guarded to zero-other-devices, and
|
|
1591
|
+
* Welcome-storms every healthy member.)
|
|
1592
|
+
*
|
|
1593
|
+
* ONE acquisition (load-bearing): we take the per-channel lock ONCE and run BOTH
|
|
1594
|
+
* mutations inside it via the Unlocked variants, so the lock is never released
|
|
1595
|
+
* between Remove and Add — that gap would open a fork window (a peer commit or
|
|
1596
|
+
* the device-wide receive pump could advance the manager between the two). Do
|
|
1597
|
+
* NOT call the public (self-locking) variants in sequence. Remove FIRST, then
|
|
1598
|
+
* Add, so the identity is never momentarily double-leafed.
|
|
1599
|
+
*
|
|
1600
|
+
* Device-level removal (not identity-level): we resolve the member's credential
|
|
1601
|
+
* identity from its freshly-published KeyPackage (which the Add needs anyway)
|
|
1602
|
+
* and evict only the single stale leaf, so the member identity's OTHER healthy
|
|
1603
|
+
* devices are not collaterally evicted. Leaf resolution reads the LIVE
|
|
1604
|
+
* (post-lock) tree:
|
|
1605
|
+
* - exactly ONE leaf → device-level Remove (orderedDeviceIds=[deviceId]) then Add;
|
|
1606
|
+
* - ZERO leaves → no stale leaf (e.g. a heal retry whose Remove already
|
|
1607
|
+
* landed) → Add-only re-key;
|
|
1608
|
+
* - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
|
|
1609
|
+
* leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
|
|
1610
|
+
*
|
|
1611
|
+
* The Add fetches the device's freshly published KeyPackage from the backend,
|
|
1612
|
+
* exactly like a normal add — the CALLER is responsible for the member having
|
|
1613
|
+
* re-published one first (mirrors addMemberToLiveGroup's "no published
|
|
1614
|
+
* KeyPackage" error). After the call the committer's group has the member
|
|
1615
|
+
* re-added at the live epoch.
|
|
1616
|
+
*
|
|
1617
|
+
* Authorization / single-committer guards are inherited unchanged from the
|
|
1618
|
+
* Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
|
|
1619
|
+
* addMemberToLiveGroupUnlocked) — no weaker path is introduced.
|
|
1620
|
+
*/
|
|
1621
|
+
async healDivergedMember(channelId, memberDeviceId) {
|
|
1622
|
+
return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
|
|
1623
|
+
const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
|
|
1624
|
+
const hex = kps[memberDeviceId];
|
|
1625
|
+
if (!hex)
|
|
1626
|
+
throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
|
|
1627
|
+
const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
|
|
1628
|
+
if (cred.credentialType !== "basic") {
|
|
1629
|
+
throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
|
|
1630
|
+
}
|
|
1631
|
+
const identity = new TextDecoder().decode(cred.identity);
|
|
1632
|
+
const mgr = await this.manager(channelId);
|
|
1633
|
+
const leaves = mgr.leafIndicesForIdentity(identity);
|
|
1634
|
+
if (leaves.length > 1) {
|
|
1635
|
+
throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
|
|
1636
|
+
}
|
|
1637
|
+
let removeLanded = false;
|
|
1638
|
+
if (leaves.length === 1) {
|
|
1639
|
+
await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
|
|
1640
|
+
removeLanded = true;
|
|
1641
|
+
} else {
|
|
1642
|
+
const stillZero = (await this.manager(channelId)).leafIndicesForIdentity(identity);
|
|
1643
|
+
if (stillZero.length !== 0) {
|
|
1644
|
+
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)`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0);
|
|
1649
|
+
} catch (e) {
|
|
1650
|
+
if (removeLanded)
|
|
1651
|
+
throw new HealAddAfterRemoveError(e);
|
|
1652
|
+
throw e;
|
|
1653
|
+
}
|
|
1654
|
+
}));
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Member-side: drop the diverged local state for a channel so a subsequent
|
|
1658
|
+
* {@link joinAndReconcile} consumes the committer's fresh Welcome (from
|
|
1659
|
+
* {@link healDivergedMember}) instead of early-returning on the stale saved
|
|
1660
|
+
* state (Phase 1 member self-heal). This does NOT rejoin — the caller invokes
|
|
1661
|
+
* joinAndReconcile afterward.
|
|
1662
|
+
*
|
|
1663
|
+
* BOTH the in-memory manager AND the persisted store row must go: evict alone is
|
|
1664
|
+
* insufficient because ensureSession(join) early-returns whenever
|
|
1665
|
+
* `store.load(channelId)` returns existing state, so the member would reload the
|
|
1666
|
+
* diverged row and never rejoin. Also clear this channel's decrypt-error tracker
|
|
1667
|
+
* entry so a healed channel doesn't read as permanently `stuck` in errorStats.
|
|
1668
|
+
*
|
|
1669
|
+
* CHANNEL LOCK (M1): the whole reset runs under the per-channel lock, serialized
|
|
1670
|
+
* against receiveOnce / committer ops / ensureSession on the SAME channel. Without
|
|
1671
|
+
* it a concurrent receiveOnce processing a commit row for this channel could
|
|
1672
|
+
* re-persist the diverged state immediately after our `store.delete`, re-wedging
|
|
1673
|
+
* the rejoin (the join would then early-return on the resurrected row). The
|
|
1674
|
+
* in-memory evict and `errors.delete` are cheap synchronous map mutations but are
|
|
1675
|
+
* kept inside the lock so the reset is one coherent, atomic state clear.
|
|
1676
|
+
*
|
|
1677
|
+
* STAGING (M2): also discard any persisted staging row for the channel. A removal
|
|
1678
|
+
* op interrupted between saveStaging and promote/discard can leave a stale staged
|
|
1679
|
+
* advanced state; if that survives the reset it can re-diverge the rejoin. The
|
|
1680
|
+
* in-memory store's discardStaging is a no-op when no staging row exists.
|
|
1681
|
+
*/
|
|
1682
|
+
async resetChannelForRejoin(channelId) {
|
|
1683
|
+
await this.locks.run(channelId, async () => {
|
|
1684
|
+
this.evictChannelState(channelId);
|
|
1685
|
+
await this.o.store.delete(channelId);
|
|
1686
|
+
await this.o.store.discardStaging(channelId);
|
|
1687
|
+
this.errors.delete(channelId);
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
/** Returns true when an in-memory MLS manager is live for the channel.
|
|
1691
|
+
* Cheap synchronous check; does NOT consult the store. */
|
|
1692
|
+
hasChannelState(channelId) {
|
|
1693
|
+
return this.managers.has(channelId);
|
|
1694
|
+
}
|
|
1695
|
+
/** Returns true when the channel has either a live in-memory manager OR
|
|
1696
|
+
* persisted state in the store. Use this to decide whether to call
|
|
1697
|
+
* ensureSession(bootstrap:false) or ensureSession(bootstrap:true). */
|
|
1698
|
+
async hasPersistedChannelState(channelId) {
|
|
1699
|
+
if (this.managers.has(channelId))
|
|
1700
|
+
return true;
|
|
1701
|
+
return await this.o.store.load(channelId) !== null;
|
|
1702
|
+
}
|
|
1703
|
+
/** Returns true when the live in-memory MLS tree for `channelId` contains at
|
|
1704
|
+
* least one leaf bearing `identityId` as its credential. Used by the agent
|
|
1705
|
+
* admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
|
|
1706
|
+
* needed — if the agent already has a leaf the sweep skips it. Returns false
|
|
1707
|
+
* when there is no in-memory manager for the channel (i.e. the group is not
|
|
1708
|
+
* yet loaded), which causes the sweep to attempt the Add (idempotent on the
|
|
1709
|
+
* server side). */
|
|
1710
|
+
hasLeafForIdentity(channelId, identityId) {
|
|
1711
|
+
const mgr = this.managers.get(channelId);
|
|
1712
|
+
if (!mgr)
|
|
1713
|
+
return false;
|
|
1714
|
+
return mgr.leafIndicesForIdentity(identityId).length > 0;
|
|
1715
|
+
}
|
|
1716
|
+
/** Probe the server for the channel's MLS group without modifying any local
|
|
1717
|
+
* state. Maps the ChannelGroupResp field names to SDK-friendly camelCase. */
|
|
1718
|
+
async probeChannelGroup(channelId) {
|
|
1719
|
+
const r = await this.o.backend.getConnectChannelGroup(channelId);
|
|
1720
|
+
return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
|
|
1721
|
+
}
|
|
1722
|
+
async receiveOnce(deviceId) {
|
|
1723
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
1724
|
+
const appEntries = [];
|
|
1725
|
+
const handshakeAcked = [];
|
|
1726
|
+
const order = { welcome: 0, commit: 1, application: 2 };
|
|
1727
|
+
rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
|
|
1728
|
+
for (const row of rows) {
|
|
1729
|
+
try {
|
|
1730
|
+
if (row.message_type === "welcome") {
|
|
1731
|
+
const wcid = row.connect_channel_id;
|
|
1732
|
+
if (wcid && await this.o.store.load(wcid)) {
|
|
1733
|
+
handshakeAcked.push(row.queue_id);
|
|
1734
|
+
}
|
|
1735
|
+
} else if (row.message_type === "commit") {
|
|
1736
|
+
const cid = row.connect_channel_id;
|
|
1737
|
+
if (!cid)
|
|
1738
|
+
continue;
|
|
1739
|
+
await this.locks.run(cid, async () => {
|
|
1740
|
+
const mgr = await this.manager(cid);
|
|
1741
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
1742
|
+
await this.o.store.save(cid, mgr.exportState());
|
|
1743
|
+
});
|
|
1744
|
+
this.recordDecryptSuccess(cid);
|
|
1745
|
+
handshakeAcked.push(row.queue_id);
|
|
1746
|
+
} else {
|
|
1747
|
+
const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
|
|
1748
|
+
const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
|
|
1749
|
+
const aad = await buildAssociatedData({
|
|
1750
|
+
message_id: envelope.message_id,
|
|
1751
|
+
channel_id: envelope.channel_id,
|
|
1752
|
+
sender_id: envelope.sender_id,
|
|
1753
|
+
mls_group_epoch: envelope.mls_group_epoch,
|
|
1754
|
+
replay_nonce: envelope.replay_nonce,
|
|
1755
|
+
policy_binding: envelope.policy_binding
|
|
1756
|
+
});
|
|
1757
|
+
const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
|
|
1758
|
+
const appChannelId = row.connect_channel_id ?? envelope.channel_id;
|
|
1759
|
+
this.recordDecryptSuccess(appChannelId);
|
|
1760
|
+
appEntries.push({
|
|
1761
|
+
queueId: row.queue_id,
|
|
1762
|
+
messageId: envelope.message_id,
|
|
1763
|
+
channelId: appChannelId,
|
|
1764
|
+
plaintext,
|
|
1765
|
+
envelope,
|
|
1766
|
+
receivedAt: Date.now(),
|
|
1767
|
+
retryCount: 0
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
} catch (err) {
|
|
1771
|
+
this.recordDecryptError(row, err);
|
|
1772
|
+
const cid = row.connect_channel_id;
|
|
1773
|
+
if (cid && typeof row.epoch === "number") {
|
|
1774
|
+
let liveEpoch;
|
|
1775
|
+
try {
|
|
1776
|
+
liveEpoch = await this.currentEpoch(cid);
|
|
1777
|
+
} catch {
|
|
1778
|
+
liveEpoch = void 0;
|
|
1779
|
+
}
|
|
1780
|
+
const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
|
|
1781
|
+
if (permanentlyDead) {
|
|
1782
|
+
handshakeAcked.push(row.queue_id);
|
|
1783
|
+
this.permanentlySkipped++;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (handshakeAcked.length)
|
|
1789
|
+
await this.o.backend.ackDelivery(deviceId, handshakeAcked);
|
|
1790
|
+
return appEntries;
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
|
|
1794
|
+
*
|
|
1795
|
+
* A committer that loses an epoch race (409) must process the peer's winning
|
|
1796
|
+
* commit to advance to the true current epoch before it retries. It does this
|
|
1797
|
+
* from INSIDE its own channel lock, so it must NOT call the public receiveOnce
|
|
1798
|
+
* (which would re-acquire the same channel lock for that channel's commit rows
|
|
1799
|
+
* and self-deadlock — the queue is not reentrant). This helper does the commit
|
|
1800
|
+
* half of receiveOnce for ONLY the committer's own channel, without taking the
|
|
1801
|
+
* lock (the caller already holds it). Application rows are left untouched — the
|
|
1802
|
+
* committer only needs to converge its handshake epoch — and welcome rows are
|
|
1803
|
+
* left for the join path, exactly as receiveOnce leaves them.
|
|
1804
|
+
*/
|
|
1805
|
+
async drainChannelCommitsUnlocked(deviceId, channelId) {
|
|
1806
|
+
const rows = await this.o.backend.pullDelivery(deviceId);
|
|
1807
|
+
const acked = [];
|
|
1808
|
+
for (const row of rows) {
|
|
1809
|
+
if (row.message_type !== "commit")
|
|
1810
|
+
continue;
|
|
1811
|
+
if (row.connect_channel_id !== channelId)
|
|
1812
|
+
continue;
|
|
1813
|
+
try {
|
|
1814
|
+
const mgr = await this.manager(channelId);
|
|
1815
|
+
await mgr.decrypt(hexToBytes(row.payload));
|
|
1816
|
+
await this.o.store.save(channelId, mgr.exportState());
|
|
1817
|
+
this.recordDecryptSuccess(channelId);
|
|
1818
|
+
acked.push(row.queue_id);
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
this.recordDecryptError(row, err);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (acked.length)
|
|
1824
|
+
await this.o.backend.ackDelivery(deviceId, acked);
|
|
1825
|
+
}
|
|
1826
|
+
/** Delegate so the receive pump can ack without reaching the private backend. */
|
|
1827
|
+
async ackDelivery(deviceId, queueIds) {
|
|
1828
|
+
await this.o.backend.ackDelivery(deviceId, queueIds);
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
// ../connect-sdk-ts/dist/transport/frame-registry.js
|
|
1833
|
+
var str = (v) => String(v ?? "");
|
|
1834
|
+
var FRAME_PARSERS = {
|
|
1835
|
+
mls_delivery: (d) => ({ count: typeof d?.count === "number" ? d.count : void 0 }),
|
|
1836
|
+
delivery_receipt: (d) => ({
|
|
1837
|
+
receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "delivered" }]
|
|
1838
|
+
}),
|
|
1839
|
+
read_receipt: (d) => ({
|
|
1840
|
+
receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "read" }]
|
|
1841
|
+
}),
|
|
1842
|
+
agent_status_change: (d) => ({
|
|
1843
|
+
presence: [
|
|
1844
|
+
{
|
|
1845
|
+
identityId: str(d?.identity_id),
|
|
1846
|
+
status: d?.status === "offline" ? "offline" : "online",
|
|
1847
|
+
lastSeen: d?.last_seen
|
|
1848
|
+
}
|
|
1849
|
+
]
|
|
1850
|
+
}),
|
|
1851
|
+
presence_snapshot: (d) => ({
|
|
1852
|
+
presence: (Array.isArray(d?.online) ? d.online : []).map((id) => ({
|
|
1853
|
+
identityId: str(id),
|
|
1854
|
+
status: "online"
|
|
1855
|
+
}))
|
|
1856
|
+
}),
|
|
1857
|
+
typing: (d) => ({ typing: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) } }),
|
|
1858
|
+
agent_status_keepalive: (d) => ({
|
|
1859
|
+
statusKeepalive: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) }
|
|
1860
|
+
}),
|
|
1861
|
+
// MD-3: a device asked to be Welcomed into a group. A surviving committer
|
|
1862
|
+
// device acts on this for own-identity (PoP-proven) requesters; the durable
|
|
1863
|
+
// state is the welcome_request row, this frame just wakes the handler.
|
|
1864
|
+
mls_welcome_requested: (d) => ({
|
|
1865
|
+
welcomeRequest: {
|
|
1866
|
+
groupId: str(d?.group_id),
|
|
1867
|
+
roomId: d?.room_id ? str(d.room_id) : void 0,
|
|
1868
|
+
requestingDeviceId: str(d?.requesting_device_id),
|
|
1869
|
+
requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
|
|
1870
|
+
requestId: d?.request_id ? str(d.request_id) : void 0
|
|
1871
|
+
}
|
|
1872
|
+
})
|
|
1873
|
+
};
|
|
1874
|
+
var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
|
|
1875
|
+
function parseServerFrame(frame) {
|
|
1876
|
+
if (!frame || typeof frame !== "object")
|
|
1877
|
+
return void 0;
|
|
1878
|
+
const f = frame;
|
|
1879
|
+
const parser = typeof f.event === "string" ? FRAME_PARSERS[f.event] : void 0;
|
|
1880
|
+
return parser ? parser(f.data) : void 0;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// ../connect-sdk-ts/dist/transport/ws-pings.js
|
|
1884
|
+
function openDeliveryPings(o) {
|
|
1885
|
+
const base = o.backoffBaseMs ?? 500;
|
|
1886
|
+
const cap = o.backoffCapMs ?? 8e3;
|
|
1887
|
+
const rng = o.rng ?? Math.random;
|
|
1888
|
+
const maxQueue = o.maxQueue ?? 8;
|
|
1889
|
+
const stableResetMs = o.stableResetMs ?? 1e4;
|
|
1890
|
+
let closed = false;
|
|
1891
|
+
let attempt = 0;
|
|
1892
|
+
let sock;
|
|
1893
|
+
let backoffTimer;
|
|
1894
|
+
let stableTimer;
|
|
1895
|
+
const PRESENCE_PONG_MS = 3e4;
|
|
1896
|
+
let pongTimer;
|
|
1897
|
+
const stopPong = () => {
|
|
1898
|
+
if (pongTimer) {
|
|
1899
|
+
clearInterval(pongTimer);
|
|
1900
|
+
pongTimer = void 0;
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
const queue = [];
|
|
1904
|
+
let waiter;
|
|
1905
|
+
let iterStarted = false;
|
|
1906
|
+
const push = (p) => {
|
|
1907
|
+
if (waiter) {
|
|
1908
|
+
const w = waiter;
|
|
1909
|
+
waiter = void 0;
|
|
1910
|
+
w({ value: p, done: false });
|
|
1911
|
+
} else if (queue.length < maxQueue) {
|
|
1912
|
+
queue.push(p);
|
|
1913
|
+
}
|
|
1914
|
+
};
|
|
1915
|
+
const end = () => {
|
|
1916
|
+
closed = true;
|
|
1917
|
+
if (backoffTimer)
|
|
1918
|
+
clearTimeout(backoffTimer);
|
|
1919
|
+
if (stableTimer)
|
|
1920
|
+
clearTimeout(stableTimer);
|
|
1921
|
+
stopPong();
|
|
1922
|
+
try {
|
|
1923
|
+
sock?.close();
|
|
1924
|
+
} catch {
|
|
1925
|
+
}
|
|
1926
|
+
if (waiter) {
|
|
1927
|
+
const w = waiter;
|
|
1928
|
+
waiter = void 0;
|
|
1929
|
+
w({ value: void 0, done: true });
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
const scheduleReconnect = () => {
|
|
1933
|
+
if (closed)
|
|
1934
|
+
return;
|
|
1935
|
+
attempt += 1;
|
|
1936
|
+
const ceil = Math.min(cap, base * 2 ** (attempt - 1));
|
|
1937
|
+
backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
|
|
1938
|
+
};
|
|
1939
|
+
async function connect() {
|
|
1940
|
+
if (closed)
|
|
1941
|
+
return;
|
|
1942
|
+
let token;
|
|
1943
|
+
try {
|
|
1944
|
+
token = await o.tokenProvider();
|
|
1945
|
+
} catch {
|
|
1946
|
+
scheduleReconnect();
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (closed)
|
|
1950
|
+
return;
|
|
1951
|
+
const sep = o.url.includes("?") ? "&" : "?";
|
|
1952
|
+
const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
|
|
1953
|
+
sock = ws;
|
|
1954
|
+
ws.onopen = () => {
|
|
1955
|
+
push({ reconnected: true });
|
|
1956
|
+
stableTimer = setTimeout(() => {
|
|
1957
|
+
attempt = 0;
|
|
1958
|
+
}, stableResetMs);
|
|
1959
|
+
stopPong();
|
|
1960
|
+
pongTimer = setInterval(() => {
|
|
1961
|
+
try {
|
|
1962
|
+
if (ws.readyState === 1)
|
|
1963
|
+
ws.send(JSON.stringify({ event: "pong" }));
|
|
1964
|
+
} catch {
|
|
1965
|
+
}
|
|
1966
|
+
}, PRESENCE_PONG_MS);
|
|
1967
|
+
};
|
|
1968
|
+
ws.onmessage = (e) => {
|
|
1969
|
+
try {
|
|
1970
|
+
const f = JSON.parse(String(e.data));
|
|
1971
|
+
const ping = parseServerFrame(f);
|
|
1972
|
+
if (ping)
|
|
1973
|
+
push(ping);
|
|
1974
|
+
} catch {
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
ws.onerror = () => {
|
|
1978
|
+
};
|
|
1979
|
+
ws.onclose = (e) => {
|
|
1980
|
+
sock = void 0;
|
|
1981
|
+
if (stableTimer)
|
|
1982
|
+
clearTimeout(stableTimer);
|
|
1983
|
+
stopPong();
|
|
1984
|
+
if (closed)
|
|
1985
|
+
return;
|
|
1986
|
+
if (e.code === 4001) {
|
|
1987
|
+
end();
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
scheduleReconnect();
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
void connect();
|
|
1994
|
+
const pings = {
|
|
1995
|
+
[Symbol.asyncIterator]() {
|
|
1996
|
+
if (iterStarted)
|
|
1997
|
+
throw new Error("openDeliveryPings: single-use async iterable");
|
|
1998
|
+
iterStarted = true;
|
|
1999
|
+
return {
|
|
2000
|
+
next() {
|
|
2001
|
+
if (closed)
|
|
2002
|
+
return Promise.resolve({ value: void 0, done: true });
|
|
2003
|
+
if (queue.length)
|
|
2004
|
+
return Promise.resolve({ value: queue.shift(), done: false });
|
|
2005
|
+
return new Promise((resolve2) => {
|
|
2006
|
+
waiter = resolve2;
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
const send = (frame) => {
|
|
2013
|
+
try {
|
|
2014
|
+
if (sock && sock.readyState === 1)
|
|
2015
|
+
sock.send(JSON.stringify(frame));
|
|
2016
|
+
} catch {
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
return { pings, close: end, send };
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// ../connect-sdk-ts/dist/transport/rest.js
|
|
2023
|
+
var PATHS = {
|
|
2024
|
+
register: "/api/v1/connect/agents/register",
|
|
2025
|
+
// VERIFY against MOT-136
|
|
2026
|
+
channelMembers: (id) => `/api/v1/connect/channels/${id}/members`,
|
|
2027
|
+
// VERIFY against MOT-139
|
|
2028
|
+
messages: "/api/v1/messages",
|
|
2029
|
+
// live (MOT-194)
|
|
2030
|
+
stream: "/api/v1/messages/stream",
|
|
2031
|
+
// MLS-gated (MOT-144)
|
|
2032
|
+
auditRecords: "/api/v1/audit/records"
|
|
2033
|
+
// MOT-151
|
|
2034
|
+
};
|
|
2035
|
+
var RestTransport = class {
|
|
2036
|
+
opts;
|
|
2037
|
+
f;
|
|
2038
|
+
ws;
|
|
2039
|
+
tokenProvider;
|
|
2040
|
+
/** The most recent device-wide WS send handle, captured on subscribe and
|
|
2041
|
+
* cleared on its close. Control frames (status keepalive) route through it;
|
|
2042
|
+
* a no-op when no subscription is active. */
|
|
2043
|
+
_controlSend = null;
|
|
2044
|
+
constructor(opts) {
|
|
2045
|
+
this.opts = opts;
|
|
2046
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
2047
|
+
this.ws = opts.wsImpl ?? globalThis.WebSocket;
|
|
2048
|
+
this.tokenProvider = opts.tokenProvider ?? (async () => opts.bearerToken);
|
|
2049
|
+
}
|
|
2050
|
+
headers() {
|
|
2051
|
+
return { "content-type": "application/json", authorization: `Bearer ${this.opts.bearerToken}` };
|
|
2052
|
+
}
|
|
2053
|
+
async register(req) {
|
|
2054
|
+
const res = await this.f(this.opts.baseUrl + PATHS.register, {
|
|
2055
|
+
method: "POST",
|
|
2056
|
+
headers: this.headers(),
|
|
2057
|
+
body: JSON.stringify({
|
|
2058
|
+
skill_forge_token: req.skillForgeToken,
|
|
2059
|
+
skill_id: req.skillId,
|
|
2060
|
+
public_key: req.publicKey,
|
|
2061
|
+
algo: req.algo,
|
|
2062
|
+
attested: req.attested,
|
|
2063
|
+
model: req.model
|
|
2064
|
+
})
|
|
2065
|
+
});
|
|
2066
|
+
if (!res.ok)
|
|
2067
|
+
throw new RegistrationError(`register failed: ${res.status}`);
|
|
2068
|
+
const j = await res.json();
|
|
2069
|
+
return {
|
|
2070
|
+
agentId: j.agent_id,
|
|
2071
|
+
tenantId: j.tenant_id,
|
|
2072
|
+
enclaveId: j.enclave_id,
|
|
2073
|
+
bindingId: j.binding_id,
|
|
2074
|
+
bindingVersion: j.binding_version,
|
|
2075
|
+
owningOrg: j.owning_org
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
async joinChannel(channelId, types, caps) {
|
|
2079
|
+
const res = await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
|
|
2080
|
+
method: "POST",
|
|
2081
|
+
headers: this.headers(),
|
|
2082
|
+
body: JSON.stringify({ declared_message_types: types, declared_capabilities: caps })
|
|
2083
|
+
});
|
|
2084
|
+
if (!res.ok)
|
|
2085
|
+
throw new Error(`joinChannel failed: ${res.status}`);
|
|
2086
|
+
}
|
|
2087
|
+
async leaveChannel(channelId) {
|
|
2088
|
+
await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
|
|
2089
|
+
method: "DELETE",
|
|
2090
|
+
headers: this.headers()
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
async publish(envelope) {
|
|
2094
|
+
const res = await this.f(this.opts.baseUrl + PATHS.messages, {
|
|
2095
|
+
method: "POST",
|
|
2096
|
+
headers: this.headers(),
|
|
2097
|
+
body: JSON.stringify(envelope)
|
|
2098
|
+
});
|
|
2099
|
+
if (res.status === 429) {
|
|
2100
|
+
const retry = Number(res.headers.get("retry-after") ?? "1");
|
|
2101
|
+
throw new RateLimitError("server rate limit", Number.isFinite(retry) ? retry : 1);
|
|
2102
|
+
}
|
|
2103
|
+
if (res.status === 400) {
|
|
2104
|
+
const stale = await staleEpochErrorFromResponse(res);
|
|
2105
|
+
if (stale)
|
|
2106
|
+
throw stale;
|
|
2107
|
+
}
|
|
2108
|
+
if (!res.ok)
|
|
2109
|
+
throw new Error(`publish failed: ${res.status}`);
|
|
2110
|
+
const j = await res.json();
|
|
2111
|
+
return {
|
|
2112
|
+
messageId: String(j.message_id),
|
|
2113
|
+
auditRecordId: String(j.audit_record_id),
|
|
2114
|
+
thisHash: String(j.this_hash),
|
|
2115
|
+
sequenceNumber: Number(j.sequence_number)
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
subscribeDeliveryPings(_channelId) {
|
|
2119
|
+
let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
|
|
2120
|
+
if (this.opts.connectIdentityId) {
|
|
2121
|
+
wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
|
|
2122
|
+
}
|
|
2123
|
+
const handle = openDeliveryPings({ url: wsUrl, tokenProvider: this.tokenProvider, wsImpl: this.ws });
|
|
2124
|
+
this._controlSend = handle.send;
|
|
2125
|
+
const close = () => {
|
|
2126
|
+
if (this._controlSend === handle.send)
|
|
2127
|
+
this._controlSend = null;
|
|
2128
|
+
handle.close();
|
|
2129
|
+
};
|
|
2130
|
+
return { pings: handle.pings, close, send: handle.send };
|
|
2131
|
+
}
|
|
2132
|
+
sendControlFrame(_channelId, frame) {
|
|
2133
|
+
try {
|
|
2134
|
+
this._controlSend?.(frame);
|
|
2135
|
+
} catch {
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
async queryAudit(query) {
|
|
2139
|
+
const qs = new URLSearchParams(query).toString();
|
|
2140
|
+
const res = await this.f(`${this.opts.baseUrl}${PATHS.auditRecords}?${qs}`, {
|
|
2141
|
+
method: "GET",
|
|
2142
|
+
headers: this.headers()
|
|
2143
|
+
});
|
|
2144
|
+
if (res.status === 403)
|
|
2145
|
+
throw new AuditAccessDenied("audit query exceeded caller rights");
|
|
2146
|
+
if (!res.ok)
|
|
2147
|
+
throw new Error(`queryAudit failed: ${res.status}`);
|
|
2148
|
+
const j = await res.json();
|
|
2149
|
+
return (j.records ?? []).map((r) => ({
|
|
2150
|
+
recordId: String(r.record_id),
|
|
2151
|
+
sequenceNumber: Number(r.sequence_number),
|
|
2152
|
+
occurredAt: String(r.occurred_at),
|
|
2153
|
+
messageType: r.message_type ? String(r.message_type) : void 0,
|
|
2154
|
+
channelId: r.channel_id ? String(r.channel_id) : void 0
|
|
2155
|
+
}));
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
// ../connect-sdk-ts/dist/mls/rest-backend.js
|
|
2160
|
+
var RestMlsBackend = class {
|
|
2161
|
+
opts;
|
|
2162
|
+
f;
|
|
2163
|
+
constructor(opts) {
|
|
2164
|
+
this.opts = opts;
|
|
2165
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
2166
|
+
}
|
|
2167
|
+
headers() {
|
|
2168
|
+
return {
|
|
2169
|
+
"content-type": "application/json",
|
|
2170
|
+
authorization: `Bearer ${this.opts.bearerToken}`
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
async post(path, body) {
|
|
2174
|
+
const res = await this.f(this.opts.baseUrl + path, {
|
|
2175
|
+
method: "POST",
|
|
2176
|
+
headers: this.headers(),
|
|
2177
|
+
body: JSON.stringify(body)
|
|
2178
|
+
});
|
|
2179
|
+
if (!res.ok)
|
|
2180
|
+
throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
|
|
2181
|
+
return res.status === 204 ? {} : await res.json();
|
|
2182
|
+
}
|
|
2183
|
+
async get(path) {
|
|
2184
|
+
const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
|
|
2185
|
+
if (!res.ok)
|
|
2186
|
+
throw new Error(`${path} failed: ${res.status}`);
|
|
2187
|
+
return await res.json();
|
|
2188
|
+
}
|
|
2189
|
+
async getRegisterChallenge() {
|
|
2190
|
+
const j = await this.post("/api/v1/connect/devices/register-challenge", {});
|
|
2191
|
+
return String(j.challenge);
|
|
2192
|
+
}
|
|
2193
|
+
async registerDevice(req) {
|
|
2194
|
+
const j = await this.post("/api/v1/connect/devices/register", req);
|
|
2195
|
+
return { device_id: String(j.device_id) };
|
|
2196
|
+
}
|
|
2197
|
+
async publishKeyPackage(deviceId, keyPackageHex) {
|
|
2198
|
+
await this.post("/api/v1/mls/key-packages", {
|
|
2199
|
+
key_package: keyPackageHex,
|
|
2200
|
+
device_id: deviceId
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
async getConnectChannelGroup(channelId) {
|
|
2204
|
+
const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
|
|
2205
|
+
return {
|
|
2206
|
+
exists: Boolean(j.exists),
|
|
2207
|
+
group_id: j.group_id != null ? String(j.group_id) : null,
|
|
2208
|
+
current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
async initConnectChannelGroup(channelId, memberDeviceIds) {
|
|
2212
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/init`, {
|
|
2213
|
+
member_device_ids: memberDeviceIds
|
|
2214
|
+
});
|
|
2215
|
+
return {
|
|
2216
|
+
group_id: String(j.group_id),
|
|
2217
|
+
current_epoch: Number(j.current_epoch),
|
|
2218
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
|
|
2219
|
+
// Back-compat: servers that predate the created field return nothing;
|
|
2220
|
+
// treat absence as true (they always created a fresh group).
|
|
2221
|
+
created: j.created === void 0 ? true : Boolean(j.created)
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
async reinitConnectChannelGroup(channelId, memberDeviceIds) {
|
|
2225
|
+
const committer = memberDeviceIds[0];
|
|
2226
|
+
if (!committer)
|
|
2227
|
+
throw new Error("reinitConnectChannelGroup: empty member set");
|
|
2228
|
+
const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
|
|
2229
|
+
member_device_ids: memberDeviceIds,
|
|
2230
|
+
committer_device_id: committer
|
|
2231
|
+
});
|
|
2232
|
+
return {
|
|
2233
|
+
group_id: String(j.group_id),
|
|
2234
|
+
current_epoch: Number(j.current_epoch),
|
|
2235
|
+
member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
async fetchKeyPackages(deviceIds) {
|
|
2239
|
+
const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
|
|
2240
|
+
return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
|
|
2241
|
+
}
|
|
2242
|
+
async distribute(groupId, req) {
|
|
2243
|
+
await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
|
|
2244
|
+
...req,
|
|
2245
|
+
group_id: groupId
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
async pullDelivery(deviceId) {
|
|
2249
|
+
const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
|
|
2250
|
+
return j.messages ?? [];
|
|
2251
|
+
}
|
|
2252
|
+
async ackDelivery(deviceId, queueIds) {
|
|
2253
|
+
await this.post("/api/v1/mls/delivery/ack", {
|
|
2254
|
+
device_id: deviceId,
|
|
2255
|
+
queue_ids: queueIds
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
async requestWelcome(groupId, deviceId) {
|
|
2259
|
+
await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, { device_id: deviceId });
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
// ../connect-sdk-ts/dist/mls/fake-backend.js
|
|
2264
|
+
import { bytesToHex as bytesToHex2 } from "@agentvault/crypto";
|
|
2265
|
+
|
|
2266
|
+
// ../connect-sdk-ts/dist/receive/inbox-cipher.js
|
|
2267
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
2268
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha256";
|
|
2269
|
+
var SUITE = "xchacha20poly1305";
|
|
2270
|
+
var INFO = new TextEncoder().encode("agentvault-connect-inbox-v1");
|
|
2271
|
+
var EMPTY_AAD = new Uint8Array(0);
|
|
2272
|
+
var KeyedInboxCipher = class {
|
|
2273
|
+
key;
|
|
2274
|
+
constructor(key) {
|
|
2275
|
+
this.key = key;
|
|
2276
|
+
}
|
|
2277
|
+
async seal(plaintext) {
|
|
2278
|
+
return sealPayload({
|
|
2279
|
+
plaintext,
|
|
2280
|
+
key: this.key,
|
|
2281
|
+
associatedData: EMPTY_AAD,
|
|
2282
|
+
suite: SUITE
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
async open(sealed) {
|
|
2286
|
+
return openPayload({
|
|
2287
|
+
ciphertext: sealed,
|
|
2288
|
+
key: this.key,
|
|
2289
|
+
associatedData: EMPTY_AAD,
|
|
2290
|
+
suite: SUITE
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
var MLS_STORE_INFO = new TextEncoder().encode("agentvault-connect-mls-store-v1");
|
|
2295
|
+
function deriveMlsStoreKey(identityKeyMaterial) {
|
|
2296
|
+
return hkdf(sha2562, identityKeyMaterial, void 0, MLS_STORE_INFO, 32);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// ../connect-sdk-ts/dist/recovery/backup-crypto.js
|
|
2300
|
+
import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
|
|
2301
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha256";
|
|
2302
|
+
import { randomBytes as randomBytes2 } from "@noble/hashes/utils";
|
|
2303
|
+
var BACKUP_INFO = new TextEncoder().encode("connect-backup-v1");
|
|
2304
|
+
var RECOVERY_INFO = new TextEncoder().encode("connect-recovery-v1");
|
|
2305
|
+
|
|
2306
|
+
// ../connect-sdk-ts/dist/recovery/guest-recovery-crypto.js
|
|
2307
|
+
import { argon2idAsync } from "@noble/hashes/argon2";
|
|
2308
|
+
import { hkdf as hkdf3 } from "@noble/hashes/hkdf";
|
|
2309
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha256";
|
|
2310
|
+
var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
|
|
2311
|
+
|
|
2312
|
+
// ../connect-mls-store/dist/sqlite-store.js
|
|
2313
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2314
|
+
|
|
2315
|
+
// ../connect-mls-store/dist/schema.js
|
|
2316
|
+
var SCHEMA_VERSION = 1;
|
|
2317
|
+
function openSchema(db) {
|
|
2318
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
2319
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
2320
|
+
if (row.user_version === 0) {
|
|
2321
|
+
db.exec(`
|
|
2322
|
+
CREATE TABLE IF NOT EXISTS mls_group_state (
|
|
2323
|
+
identity_id TEXT NOT NULL,
|
|
2324
|
+
channel_id TEXT NOT NULL,
|
|
2325
|
+
sealed_main BLOB NOT NULL,
|
|
2326
|
+
sealed_staging BLOB,
|
|
2327
|
+
updated_at INTEGER NOT NULL,
|
|
2328
|
+
PRIMARY KEY (identity_id, channel_id)
|
|
2329
|
+
);
|
|
2330
|
+
`);
|
|
2331
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// ../connect-mls-store/dist/errors.js
|
|
2336
|
+
var MlsStoreCorruptError = class extends Error {
|
|
2337
|
+
constructor(detail) {
|
|
2338
|
+
super(`MLS store corrupt: ${detail}`);
|
|
2339
|
+
this.name = "MlsStoreCorruptError";
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
var MlsStoreSealError = class extends Error {
|
|
2343
|
+
constructor(detail) {
|
|
2344
|
+
super(`MLS store seal/open failed: ${detail}`);
|
|
2345
|
+
this.name = "MlsStoreSealError";
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
// ../connect-mls-store/dist/sqlite-store.js
|
|
2350
|
+
var SqliteMlsStateStore = class {
|
|
2351
|
+
db;
|
|
2352
|
+
id;
|
|
2353
|
+
cipher;
|
|
2354
|
+
// Bundles stay in-memory (spec §4): private half not durably serializable.
|
|
2355
|
+
// Per-instance map → per-identity store instances are naturally isolated (§6.1).
|
|
2356
|
+
bundles = /* @__PURE__ */ new Map();
|
|
2357
|
+
constructor(opts) {
|
|
2358
|
+
this.id = opts.identityId;
|
|
2359
|
+
this.cipher = opts.cipherFor(opts.identityId);
|
|
2360
|
+
try {
|
|
2361
|
+
this.db = new DatabaseSync(opts.dbPath);
|
|
2362
|
+
} catch (e) {
|
|
2363
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2364
|
+
}
|
|
2365
|
+
try {
|
|
2366
|
+
openSchema(this.db);
|
|
2367
|
+
} catch (e) {
|
|
2368
|
+
this.db.close();
|
|
2369
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2370
|
+
}
|
|
2371
|
+
if (opts.dbPath !== ":memory:") {
|
|
2372
|
+
let r;
|
|
2373
|
+
try {
|
|
2374
|
+
r = this.db.prepare("PRAGMA integrity_check").get();
|
|
2375
|
+
} catch (e) {
|
|
2376
|
+
this.db.close();
|
|
2377
|
+
throw new MlsStoreCorruptError(String(e?.message ?? e));
|
|
2378
|
+
}
|
|
2379
|
+
if (r.integrity_check !== "ok") {
|
|
2380
|
+
this.db.close();
|
|
2381
|
+
throw new MlsStoreCorruptError(r.integrity_check);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
async seal(state) {
|
|
2386
|
+
return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
|
|
2387
|
+
}
|
|
2388
|
+
async unseal(blob) {
|
|
2389
|
+
let bytes;
|
|
2390
|
+
try {
|
|
2391
|
+
bytes = await this.cipher.open(blob);
|
|
2392
|
+
} catch (e) {
|
|
2393
|
+
throw new MlsStoreSealError(String(e?.message ?? e));
|
|
2394
|
+
}
|
|
2395
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
2396
|
+
}
|
|
2397
|
+
async load(channelId) {
|
|
2398
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
|
|
2399
|
+
if (!row)
|
|
2400
|
+
return null;
|
|
2401
|
+
return this.unseal(row.sealed_main);
|
|
2402
|
+
}
|
|
2403
|
+
async save(channelId, state) {
|
|
2404
|
+
const sealed = await this.seal(state);
|
|
2405
|
+
this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
|
|
2406
|
+
VALUES (?,?,?,?)
|
|
2407
|
+
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());
|
|
2408
|
+
}
|
|
2409
|
+
async delete(channelId) {
|
|
2410
|
+
this.db.prepare("DELETE FROM mls_group_state WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
2411
|
+
}
|
|
2412
|
+
async saveStaging(channelId, state) {
|
|
2413
|
+
const sealed = await this.seal(state);
|
|
2414
|
+
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);
|
|
2415
|
+
}
|
|
2416
|
+
async promoteStaging(channelId) {
|
|
2417
|
+
this.db.prepare(`UPDATE mls_group_state SET sealed_main=sealed_staging, sealed_staging=NULL, updated_at=?
|
|
2418
|
+
WHERE identity_id=? AND channel_id=? AND sealed_staging IS NOT NULL`).run(Date.now(), this.id, channelId);
|
|
2419
|
+
}
|
|
2420
|
+
async discardStaging(channelId) {
|
|
2421
|
+
this.db.prepare("UPDATE mls_group_state SET sealed_staging=NULL WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
|
|
2422
|
+
}
|
|
2423
|
+
async savePendingBundle(ref, bundle) {
|
|
2424
|
+
this.bundles.set(ref, bundle);
|
|
2425
|
+
}
|
|
2426
|
+
async loadPendingBundle(ref) {
|
|
2427
|
+
return this.bundles.get(ref) ?? null;
|
|
2428
|
+
}
|
|
2429
|
+
async deletePendingBundle(ref) {
|
|
2430
|
+
this.bundles.delete(ref);
|
|
2431
|
+
}
|
|
2432
|
+
/** @internal test-only */
|
|
2433
|
+
_rawMainForTest(identityId, channelId) {
|
|
2434
|
+
const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
|
|
2435
|
+
return row?.sealed_main;
|
|
2436
|
+
}
|
|
2437
|
+
close() {
|
|
2438
|
+
this.db.close();
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
|
|
2442
|
+
// src/latency.ts
|
|
2443
|
+
var DEFAULT_CAPACITY = 1024;
|
|
2444
|
+
function round(n) {
|
|
2445
|
+
return Math.round(n * 100) / 100;
|
|
2446
|
+
}
|
|
2447
|
+
function percentile(sorted, p) {
|
|
2448
|
+
if (sorted.length === 0) return 0;
|
|
2449
|
+
const rank = Math.ceil(p / 100 * sorted.length);
|
|
2450
|
+
const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
|
|
2451
|
+
return sorted[idx];
|
|
2452
|
+
}
|
|
2453
|
+
var LatencyRecorder = class {
|
|
2454
|
+
capacity;
|
|
2455
|
+
// Per-stage ring buffer of recent samples (ms).
|
|
2456
|
+
buffers = /* @__PURE__ */ new Map();
|
|
2457
|
+
// Write cursor per stage (for the ring buffer wrap).
|
|
2458
|
+
cursors = /* @__PURE__ */ new Map();
|
|
2459
|
+
constructor(opts = {}) {
|
|
2460
|
+
this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
|
|
2461
|
+
}
|
|
2462
|
+
/** Record a single sample (ms) for a stage. O(1). */
|
|
2463
|
+
record(stage, ms) {
|
|
2464
|
+
let buf = this.buffers.get(stage);
|
|
2465
|
+
if (!buf) {
|
|
2466
|
+
buf = [];
|
|
2467
|
+
this.buffers.set(stage, buf);
|
|
2468
|
+
this.cursors.set(stage, 0);
|
|
2469
|
+
}
|
|
2470
|
+
if (buf.length < this.capacity) {
|
|
2471
|
+
buf.push(ms);
|
|
2472
|
+
} else {
|
|
2473
|
+
const cur = this.cursors.get(stage) ?? 0;
|
|
2474
|
+
buf[cur] = ms;
|
|
2475
|
+
this.cursors.set(stage, (cur + 1) % this.capacity);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Time an async fn, record its elapsed wall time under `stage`, return its
|
|
2480
|
+
* value. Records even when the fn throws (the failure still consumed time),
|
|
2481
|
+
* then re-throws — so a thrown error never loses the timing sample.
|
|
2482
|
+
*/
|
|
2483
|
+
async time(stage, fn) {
|
|
2484
|
+
const start = performance.now();
|
|
2485
|
+
try {
|
|
2486
|
+
return await fn();
|
|
2487
|
+
} finally {
|
|
2488
|
+
this.record(stage, performance.now() - start);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
/** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
|
|
2492
|
+
snapshot() {
|
|
2493
|
+
const out = {};
|
|
2494
|
+
for (const [stage, buf] of this.buffers) {
|
|
2495
|
+
if (buf.length === 0) continue;
|
|
2496
|
+
const sorted = [...buf].sort((a, b) => a - b);
|
|
2497
|
+
const sum = sorted.reduce((acc, n) => acc + n, 0);
|
|
2498
|
+
out[stage] = {
|
|
2499
|
+
count: sorted.length,
|
|
2500
|
+
p50: round(percentile(sorted, 50)),
|
|
2501
|
+
p95: round(percentile(sorted, 95)),
|
|
2502
|
+
p99: round(percentile(sorted, 99)),
|
|
2503
|
+
max: round(sorted[sorted.length - 1]),
|
|
2504
|
+
mean: round(sum / sorted.length)
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
return out;
|
|
2508
|
+
}
|
|
2509
|
+
};
|
|
2510
|
+
|
|
2511
|
+
// src/coordination.ts
|
|
2512
|
+
function parseMentions(text) {
|
|
2513
|
+
const mentions = [];
|
|
2514
|
+
const re = /@(\w[\w]*)/gi;
|
|
2515
|
+
let match;
|
|
2516
|
+
while ((match = re.exec(text)) !== null) {
|
|
2517
|
+
mentions.push(match[1].toLowerCase());
|
|
2518
|
+
}
|
|
2519
|
+
return mentions;
|
|
2520
|
+
}
|
|
2521
|
+
function ownRoomNames(agentName, accountId, roomDisplayName) {
|
|
2522
|
+
const names = /* @__PURE__ */ new Set();
|
|
2523
|
+
const add = (n) => {
|
|
2524
|
+
if (!n) return;
|
|
2525
|
+
const lower = n.toLowerCase();
|
|
2526
|
+
names.add(lower);
|
|
2527
|
+
names.add(lower.split(/[\s(]/)[0]);
|
|
2528
|
+
};
|
|
2529
|
+
add(agentName);
|
|
2530
|
+
add(accountId);
|
|
2531
|
+
add(roomDisplayName);
|
|
2532
|
+
return names;
|
|
2533
|
+
}
|
|
2534
|
+
function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
|
|
2535
|
+
const mentions = parseMentions(plaintext);
|
|
2536
|
+
if (mentions.length === 0) return true;
|
|
2537
|
+
if (mentions.includes("all") || mentions.includes("everyone")) return true;
|
|
2538
|
+
const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
|
|
2539
|
+
if (mentions.some((m) => ownNames.has(m))) return true;
|
|
2540
|
+
return false;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// src/rate-limit.ts
|
|
2544
|
+
var TokenBucketFloor = class {
|
|
2545
|
+
capacity;
|
|
2546
|
+
refillPerMs;
|
|
2547
|
+
now;
|
|
2548
|
+
buckets = /* @__PURE__ */ new Map();
|
|
2549
|
+
constructor(opts) {
|
|
2550
|
+
this.capacity = opts.capacity;
|
|
2551
|
+
this.refillPerMs = opts.refillPerMinute / 6e4;
|
|
2552
|
+
this.now = opts.now ?? Date.now;
|
|
2553
|
+
}
|
|
2554
|
+
refill(key) {
|
|
2555
|
+
const t = this.now();
|
|
2556
|
+
let b = this.buckets.get(key);
|
|
2557
|
+
if (!b) {
|
|
2558
|
+
b = { tokens: this.capacity, lastRefill: t };
|
|
2559
|
+
this.buckets.set(key, b);
|
|
2560
|
+
return b;
|
|
2561
|
+
}
|
|
2562
|
+
const elapsed = t - b.lastRefill;
|
|
2563
|
+
if (elapsed > 0) {
|
|
2564
|
+
b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerMs);
|
|
2565
|
+
b.lastRefill = t;
|
|
2566
|
+
}
|
|
2567
|
+
return b;
|
|
2568
|
+
}
|
|
2569
|
+
/** Consume one token. Returns true if allowed, false if the bucket is empty. */
|
|
2570
|
+
tryConsume(key) {
|
|
2571
|
+
if (this.capacity <= 0) return true;
|
|
2572
|
+
const b = this.refill(key);
|
|
2573
|
+
if (b.tokens >= 1) {
|
|
2574
|
+
b.tokens -= 1;
|
|
2575
|
+
return true;
|
|
2576
|
+
}
|
|
2577
|
+
return false;
|
|
2578
|
+
}
|
|
2579
|
+
/** Milliseconds until one token is available (0 if already available). */
|
|
2580
|
+
msUntilNextToken(key) {
|
|
2581
|
+
if (this.capacity <= 0) return 0;
|
|
2582
|
+
const b = this.refill(key);
|
|
2583
|
+
if (b.tokens >= 1) return 0;
|
|
2584
|
+
if (this.refillPerMs <= 0) return Infinity;
|
|
2585
|
+
return Math.ceil((1 - b.tokens) / this.refillPerMs);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
|
|
2589
|
+
// src/bridge-agent.ts
|
|
2590
|
+
var DEFAULT_MAX_BUFFER = 1e3;
|
|
2591
|
+
var CHANNEL_META_TTL_MS = 5 * 6e4;
|
|
2592
|
+
var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
|
|
2593
|
+
var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
|
|
2594
|
+
var ChannelPendingError = class extends Error {
|
|
2595
|
+
constructor(channelId) {
|
|
2596
|
+
super(`channel ${channelId} is not yet admitted (awaiting Welcome)`);
|
|
2597
|
+
this.channelId = channelId;
|
|
2598
|
+
this.name = "ChannelPendingError";
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2601
|
+
var RateLimitedError = class extends Error {
|
|
2602
|
+
constructor(channelId, retryAfterMs) {
|
|
2603
|
+
super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
|
|
2604
|
+
this.channelId = channelId;
|
|
2605
|
+
this.retryAfterMs = retryAfterMs;
|
|
2606
|
+
this.name = "RateLimitedError";
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
var BRIDGE_VERSION = "0.0.0";
|
|
2610
|
+
function hexToBytes2(h) {
|
|
2611
|
+
return new Uint8Array(Buffer.from(h, "hex"));
|
|
2612
|
+
}
|
|
2613
|
+
var BridgeAgent = class _BridgeAgent {
|
|
2614
|
+
constructor(cfg, deps = {}) {
|
|
2615
|
+
this.cfg = cfg;
|
|
2616
|
+
const seed = hexToBytes2(cfg.signingPrivateKey);
|
|
2617
|
+
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
2618
|
+
this.transport = deps.transport ?? new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
|
|
2619
|
+
this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
2620
|
+
this.store = new SqliteMlsStateStore({
|
|
2621
|
+
dbPath: cfg.storePath,
|
|
2622
|
+
identityId: cfg.identityId,
|
|
2623
|
+
cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed))
|
|
2624
|
+
});
|
|
2625
|
+
this.provider = new MlsGroupKeyProvider({
|
|
2626
|
+
store: this.store,
|
|
2627
|
+
backend: this.backend,
|
|
2628
|
+
deviceId: cfg.deviceId,
|
|
2629
|
+
identity: new TextEncoder().encode(cfg.identityId),
|
|
2630
|
+
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
2631
|
+
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
2632
|
+
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
2633
|
+
onDecryptError: (row, err) => {
|
|
2634
|
+
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
2635
|
+
console.warn(
|
|
2636
|
+
`[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`
|
|
2637
|
+
);
|
|
2638
|
+
}
|
|
2639
|
+
});
|
|
2640
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
2641
|
+
this.latency = deps.latency ?? new LatencyRecorder();
|
|
2642
|
+
this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
2643
|
+
this.rateFloor = new TokenBucketFloor({
|
|
2644
|
+
capacity: cfg.rateLimitCapacity ?? 20,
|
|
2645
|
+
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
transport;
|
|
2649
|
+
provider;
|
|
2650
|
+
backend;
|
|
2651
|
+
// stored for peek-then-join in ingestOnce
|
|
2652
|
+
custody;
|
|
2653
|
+
fetchImpl;
|
|
2654
|
+
joinState = /* @__PURE__ */ new Map();
|
|
2655
|
+
/**
|
|
2656
|
+
* Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
|
|
2657
|
+
* by the multi-agent room @mention filter at ingest. Populated/refreshed from a
|
|
2658
|
+
* SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
|
|
2659
|
+
* be resolved (network error, or not present in the listing) stays UNKNOWN → the
|
|
2660
|
+
* filter fails OPEN.
|
|
2661
|
+
*
|
|
2662
|
+
* `is_dm` is immutable for a given channel_id (no misclassification risk), so the
|
|
2663
|
+
* TTL exists only to keep `name` fresh after a channel rename — an entry older
|
|
2664
|
+
* than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
|
|
2665
|
+
*/
|
|
2666
|
+
channelMeta = /* @__PURE__ */ new Map();
|
|
2667
|
+
/**
|
|
2668
|
+
* Phase 2 (sender attribution): per-channel roster cache
|
|
2669
|
+
* (channel_id → identity_id → { name, is_agent }) resolved from the GET
|
|
2670
|
+
* members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
|
|
2671
|
+
* unresolved channel (network error, or no entry) leaves sender_name
|
|
2672
|
+
* undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
|
|
2673
|
+
* per channel on a miss.
|
|
2674
|
+
*/
|
|
2675
|
+
channelRoster = /* @__PURE__ */ new Map();
|
|
2676
|
+
buffer = [];
|
|
2677
|
+
maxBuffer;
|
|
2678
|
+
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
2679
|
+
droppedMessages = 0;
|
|
2680
|
+
/**
|
|
2681
|
+
* Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
|
|
2682
|
+
* ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
|
|
2683
|
+
* most room traffic is distinguishable from an idle/wedged one.
|
|
2684
|
+
*/
|
|
2685
|
+
filteredRoomMessages = 0;
|
|
2686
|
+
/** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
|
|
2687
|
+
rateLimitedSends = 0;
|
|
2688
|
+
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
2689
|
+
welcomeRequestsSent = 0;
|
|
2690
|
+
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
2691
|
+
lastWelcomeRequest = /* @__PURE__ */ new Map();
|
|
2692
|
+
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
2693
|
+
static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
|
|
2694
|
+
rateFloor;
|
|
2695
|
+
waiters = [];
|
|
2696
|
+
pingHandle;
|
|
2697
|
+
safetyTimer;
|
|
2698
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2699
|
+
startedAt = 0;
|
|
2700
|
+
store;
|
|
2701
|
+
latency;
|
|
2702
|
+
/**
|
|
2703
|
+
* Enforce the inbound-buffer cap with a DROP-OLDEST policy.
|
|
2704
|
+
*
|
|
2705
|
+
* The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
|
|
2706
|
+
* decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
|
|
2707
|
+
* messages at the FRONT — those re-buffered messages were consumed earlier, so
|
|
2708
|
+
* they are by definition OLDER than whatever already sits in the buffer. In
|
|
2709
|
+
* every push path the newest messages therefore live at the back. To honour
|
|
2710
|
+
* "the most-recent survive" we always shed from the FRONT, then record the
|
|
2711
|
+
* shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
|
|
2712
|
+
*/
|
|
2713
|
+
capBuffer() {
|
|
2714
|
+
if (this.maxBuffer <= 0) return;
|
|
2715
|
+
const overflow = this.buffer.length - this.maxBuffer;
|
|
2716
|
+
if (overflow > 0) {
|
|
2717
|
+
this.buffer.splice(0, overflow);
|
|
2718
|
+
this.droppedMessages += overflow;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
async start() {
|
|
2722
|
+
this.startedAt = Date.now();
|
|
2723
|
+
await this.provider.publishKeyPackage();
|
|
2724
|
+
this.startReceivePump();
|
|
2725
|
+
}
|
|
2726
|
+
async reconcileJoin(channelId) {
|
|
2727
|
+
if (this.joinState.get(channelId) === "JOINED") return true;
|
|
2728
|
+
try {
|
|
2729
|
+
await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
|
|
2730
|
+
this.joinState.set(channelId, "JOINED");
|
|
2731
|
+
return true;
|
|
2732
|
+
} catch {
|
|
2733
|
+
this.joinState.set(channelId, "PENDING");
|
|
2734
|
+
return false;
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
2739
|
+
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
2740
|
+
* room and a2a traffic on the same channel are tracked separately.
|
|
2741
|
+
*/
|
|
2742
|
+
enforceRateFloor(kind, channelId) {
|
|
2743
|
+
const key = `${kind}:${channelId}`;
|
|
2744
|
+
if (!this.rateFloor.tryConsume(key)) {
|
|
2745
|
+
this.rateLimitedSends++;
|
|
2746
|
+
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
async send(channelId, text, messageId) {
|
|
2750
|
+
const meta = this.freshMeta(channelId);
|
|
2751
|
+
if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
|
|
2752
|
+
const joined = await this.reconcileJoin(channelId);
|
|
2753
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
2754
|
+
const id = messageId ?? crypto.randomUUID();
|
|
2755
|
+
const sendStart = performance.now();
|
|
2756
|
+
await sendWithSelfHeal(
|
|
2757
|
+
this.provider,
|
|
2758
|
+
channelId,
|
|
2759
|
+
(epoch) => this.latency.time(
|
|
2760
|
+
"send.encrypt",
|
|
2761
|
+
() => buildOutboundEnvelope({
|
|
2762
|
+
custody: this.custody,
|
|
2763
|
+
groupKey: this.provider,
|
|
2764
|
+
messageId: id,
|
|
2765
|
+
channelId,
|
|
2766
|
+
enclaveId: this.cfg.enclaveId,
|
|
2767
|
+
senderId: this.cfg.identityId,
|
|
2768
|
+
senderType: "agent",
|
|
2769
|
+
type: "text",
|
|
2770
|
+
bodyPlaintext: JSON.stringify({ text }),
|
|
2771
|
+
bindingId: "",
|
|
2772
|
+
bindingVersion: "0.0.0",
|
|
2773
|
+
agentSkillId: null,
|
|
2774
|
+
epoch,
|
|
2775
|
+
traceparent: ZERO_TRACEPARENT,
|
|
2776
|
+
references: [],
|
|
2777
|
+
stage5Outcome: "allow",
|
|
2778
|
+
stage5Evaluators: []
|
|
2779
|
+
})
|
|
2780
|
+
),
|
|
2781
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
2782
|
+
);
|
|
2783
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
2784
|
+
return { message_id: id, status: "sent" };
|
|
2785
|
+
}
|
|
2786
|
+
async sendA2A(channelId, a2a, messageId) {
|
|
2787
|
+
this.enforceRateFloor("a2a", channelId);
|
|
2788
|
+
const joined = await this.reconcileJoin(channelId);
|
|
2789
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
2790
|
+
const id = messageId ?? crypto.randomUUID();
|
|
2791
|
+
const sendStart = performance.now();
|
|
2792
|
+
await sendWithSelfHeal(
|
|
2793
|
+
this.provider,
|
|
2794
|
+
channelId,
|
|
2795
|
+
(epoch) => this.latency.time(
|
|
2796
|
+
"send.encrypt",
|
|
2797
|
+
() => buildOutboundEnvelope({
|
|
2798
|
+
custody: this.custody,
|
|
2799
|
+
groupKey: this.provider,
|
|
2800
|
+
messageId: id,
|
|
2801
|
+
channelId,
|
|
2802
|
+
enclaveId: this.cfg.enclaveId,
|
|
2803
|
+
senderId: this.cfg.identityId,
|
|
2804
|
+
senderType: "agent",
|
|
2805
|
+
type: "a2a",
|
|
2806
|
+
bodyPlaintext: JSON.stringify({ type: "a2a", a2a_version: a2a.a2a_version, kind: a2a.kind, object: a2a.object }),
|
|
2807
|
+
bindingId: "",
|
|
2808
|
+
bindingVersion: "0.0.0",
|
|
2809
|
+
agentSkillId: null,
|
|
2810
|
+
epoch,
|
|
2811
|
+
traceparent: ZERO_TRACEPARENT,
|
|
2812
|
+
references: [],
|
|
2813
|
+
stage5Outcome: "allow",
|
|
2814
|
+
stage5Evaluators: []
|
|
2815
|
+
})
|
|
2816
|
+
),
|
|
2817
|
+
(envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
|
|
2818
|
+
);
|
|
2819
|
+
this.latency.record("send.total", performance.now() - sendStart);
|
|
2820
|
+
return { message_id: id, status: "sent" };
|
|
2821
|
+
}
|
|
2822
|
+
async listChannels() {
|
|
2823
|
+
const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
2824
|
+
const res = await this.fetchImpl(url, {
|
|
2825
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
2826
|
+
});
|
|
2827
|
+
if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
|
|
2828
|
+
const rows = await res.json();
|
|
2829
|
+
return rows.map((r) => ({
|
|
2830
|
+
channel_id: r.channel_id,
|
|
2831
|
+
name: r.name ?? null,
|
|
2832
|
+
is_dm: r.is_dm,
|
|
2833
|
+
joined: true
|
|
2834
|
+
}));
|
|
2835
|
+
}
|
|
2836
|
+
/** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
|
|
2837
|
+
freshMeta(channelId) {
|
|
2838
|
+
const hit = this.channelMeta.get(channelId);
|
|
2839
|
+
if (!hit) return void 0;
|
|
2840
|
+
if (Date.now() - hit.at >= CHANNEL_META_TTL_MS) return void 0;
|
|
2841
|
+
return { is_dm: hit.is_dm, name: hit.name };
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
|
|
2845
|
+
* ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
|
|
2846
|
+
* burst of K unknown channels fires a single listChannels() instead of K. Any
|
|
2847
|
+
* listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
|
|
2848
|
+
* blocks on the network.
|
|
2849
|
+
*/
|
|
2850
|
+
async refreshChannelMeta() {
|
|
2851
|
+
try {
|
|
2852
|
+
const now = Date.now();
|
|
2853
|
+
const channels = await this.listChannels();
|
|
2854
|
+
for (const c of channels) {
|
|
2855
|
+
this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
|
|
2856
|
+
}
|
|
2857
|
+
} catch {
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
2861
|
+
freshRoster(channelId) {
|
|
2862
|
+
const hit = this.channelRoster.get(channelId);
|
|
2863
|
+
if (!hit) return void 0;
|
|
2864
|
+
if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
|
|
2865
|
+
return hit.members;
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
2869
|
+
* GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
|
|
2870
|
+
* cycle per channel on a cache miss. Fail-soft: any error leaves the cache
|
|
2871
|
+
* as-is so the affected senders stay unresolved (sender_name undefined) —
|
|
2872
|
+
* attribution must NEVER block or drop a message.
|
|
2873
|
+
*/
|
|
2874
|
+
async refreshChannelRoster(channelId) {
|
|
2875
|
+
try {
|
|
2876
|
+
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
|
|
2877
|
+
channelId
|
|
2878
|
+
)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
2879
|
+
const res = await this.fetchImpl(url, {
|
|
2880
|
+
headers: { authorization: `Bearer ${this.cfg.apiKey}` }
|
|
2881
|
+
});
|
|
2882
|
+
if (!res.ok) return;
|
|
2883
|
+
const rows = await res.json();
|
|
2884
|
+
const members = /* @__PURE__ */ new Map();
|
|
2885
|
+
for (const r of rows) {
|
|
2886
|
+
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
2887
|
+
}
|
|
2888
|
+
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
2889
|
+
} catch {
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
async reportStatus(channelId, state, detail) {
|
|
2893
|
+
const joined = await this.reconcileJoin(channelId);
|
|
2894
|
+
if (!joined) throw new ChannelPendingError(channelId);
|
|
2895
|
+
await sendWithSelfHeal(
|
|
2896
|
+
this.provider,
|
|
2897
|
+
channelId,
|
|
2898
|
+
(epoch) => buildOutboundEnvelope({
|
|
2899
|
+
custody: this.custody,
|
|
2900
|
+
groupKey: this.provider,
|
|
2901
|
+
messageId: crypto.randomUUID(),
|
|
2902
|
+
channelId,
|
|
2903
|
+
enclaveId: this.cfg.enclaveId,
|
|
2904
|
+
senderId: this.cfg.identityId,
|
|
2905
|
+
senderType: "agent",
|
|
2906
|
+
type: "system_event",
|
|
2907
|
+
bodyPlaintext: JSON.stringify({
|
|
2908
|
+
v: 1,
|
|
2909
|
+
type: "agent_status",
|
|
2910
|
+
state,
|
|
2911
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2912
|
+
...detail ? { detail } : {}
|
|
2913
|
+
}),
|
|
2914
|
+
bindingId: "",
|
|
2915
|
+
bindingVersion: "0.0.0",
|
|
2916
|
+
agentSkillId: null,
|
|
2917
|
+
epoch,
|
|
2918
|
+
traceparent: ZERO_TRACEPARENT,
|
|
2919
|
+
references: [],
|
|
2920
|
+
stage5Outcome: "allow",
|
|
2921
|
+
stage5Evaluators: []
|
|
2922
|
+
}),
|
|
2923
|
+
(envelope) => this.transport.publish(envelope)
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
startReceivePump() {
|
|
2927
|
+
const sub = this.transport.subscribeDeliveryPings("");
|
|
2928
|
+
this.pingHandle = { close: sub.close };
|
|
2929
|
+
void (async () => {
|
|
2930
|
+
try {
|
|
2931
|
+
for await (const _ping of sub.pings) {
|
|
2932
|
+
await this.ingestOnce();
|
|
2933
|
+
}
|
|
2934
|
+
} catch {
|
|
2935
|
+
}
|
|
2936
|
+
})();
|
|
2937
|
+
this.safetyTimer = setInterval(() => {
|
|
2938
|
+
void this.ingestOnce();
|
|
2939
|
+
}, 25e3);
|
|
2940
|
+
}
|
|
2941
|
+
async ingestOnce() {
|
|
2942
|
+
const cycleStart = performance.now();
|
|
2943
|
+
try {
|
|
2944
|
+
const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
|
|
2945
|
+
const channelIds = /* @__PURE__ */ new Set();
|
|
2946
|
+
for (const row of rows) {
|
|
2947
|
+
if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
|
|
2948
|
+
}
|
|
2949
|
+
for (const cid of channelIds) {
|
|
2950
|
+
if (this.joinState.get(cid) !== "JOINED") {
|
|
2951
|
+
await this.reconcileJoin(cid);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
for (const row of rows) {
|
|
2955
|
+
const cid = row.connect_channel_id;
|
|
2956
|
+
const gid = row.group_id;
|
|
2957
|
+
if (!cid || !gid) continue;
|
|
2958
|
+
if (await this.provider.hasPersistedChannelState(cid)) continue;
|
|
2959
|
+
const now = performance.now();
|
|
2960
|
+
const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
|
|
2961
|
+
if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
|
|
2962
|
+
this.lastWelcomeRequest.set(cid, now);
|
|
2963
|
+
try {
|
|
2964
|
+
await this.backend.requestWelcome(gid, this.cfg.deviceId);
|
|
2965
|
+
this.welcomeRequestsSent++;
|
|
2966
|
+
} catch {
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
} catch {
|
|
2970
|
+
}
|
|
2971
|
+
let entries;
|
|
2972
|
+
try {
|
|
2973
|
+
entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
|
|
2974
|
+
} catch {
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (!entries.length) return;
|
|
2978
|
+
let metaRefreshed = false;
|
|
2979
|
+
if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
|
|
2980
|
+
await this.refreshChannelMeta();
|
|
2981
|
+
metaRefreshed = true;
|
|
2982
|
+
}
|
|
2983
|
+
const rosterRefreshed = /* @__PURE__ */ new Set();
|
|
2984
|
+
for (const m of entries) {
|
|
2985
|
+
if (rosterRefreshed.has(m.channelId)) continue;
|
|
2986
|
+
rosterRefreshed.add(m.channelId);
|
|
2987
|
+
if (this.freshRoster(m.channelId) === void 0) {
|
|
2988
|
+
await this.refreshChannelRoster(m.channelId);
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
const acked = [];
|
|
2992
|
+
for (const m of entries) {
|
|
2993
|
+
try {
|
|
2994
|
+
const parsed = JSON.parse(
|
|
2995
|
+
new TextDecoder().decode(m.plaintext)
|
|
2996
|
+
);
|
|
2997
|
+
if (parsed.type && parsed.type !== "text") {
|
|
2998
|
+
acked.push(m.queueId);
|
|
2999
|
+
continue;
|
|
3000
|
+
}
|
|
3001
|
+
if (typeof parsed.text === "string") {
|
|
3002
|
+
const meta = this.freshMeta(m.channelId);
|
|
3003
|
+
const senderType = m.envelope.sender_type;
|
|
3004
|
+
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
|
|
3005
|
+
let shouldBuffer = true;
|
|
3006
|
+
if (meta && meta.is_dm === false) {
|
|
3007
|
+
shouldBuffer = shouldProcessRoomMessage(
|
|
3008
|
+
parsed.text,
|
|
3009
|
+
this.cfg.agentName ?? this.cfg.identityId,
|
|
3010
|
+
this.cfg.identityId,
|
|
3011
|
+
{ senderIsAgent, roomDisplayName: meta.name ?? void 0 }
|
|
3012
|
+
);
|
|
3013
|
+
if (!shouldBuffer) this.filteredRoomMessages++;
|
|
3014
|
+
}
|
|
3015
|
+
if (shouldBuffer) {
|
|
3016
|
+
const senderName = this.freshRoster(m.channelId)?.get(
|
|
3017
|
+
m.envelope.sender_id
|
|
3018
|
+
)?.name;
|
|
3019
|
+
this.buffer.push({
|
|
3020
|
+
channel_id: m.channelId,
|
|
3021
|
+
message_id: m.messageId,
|
|
3022
|
+
sender_id: m.envelope.sender_id,
|
|
3023
|
+
text: parsed.text,
|
|
3024
|
+
ts: m.receivedAt,
|
|
3025
|
+
sender_name: senderName,
|
|
3026
|
+
sender_is_agent: senderIsAgent
|
|
3027
|
+
});
|
|
3028
|
+
this.capBuffer();
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
acked.push(m.queueId);
|
|
3032
|
+
} catch {
|
|
3033
|
+
acked.push(m.queueId);
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
if (acked.length) {
|
|
3037
|
+
try {
|
|
3038
|
+
await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
|
|
3039
|
+
} catch {
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
this.latency.record("recv.cycle", performance.now() - cycleStart);
|
|
3043
|
+
if (this.buffer.length) {
|
|
3044
|
+
for (const l of this.listeners) l();
|
|
3045
|
+
const w = this.waiters.splice(0);
|
|
3046
|
+
for (const r of w) r();
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
async drain(channelId, waitSeconds) {
|
|
3050
|
+
let got = this.takeBuffered(channelId);
|
|
3051
|
+
if (got.length || waitSeconds <= 0) return got;
|
|
3052
|
+
const cap = Math.min(waitSeconds, 50) * 1e3;
|
|
3053
|
+
await new Promise((resolve2) => {
|
|
3054
|
+
let fired = false;
|
|
3055
|
+
let timer;
|
|
3056
|
+
const wake = () => {
|
|
3057
|
+
if (fired) return;
|
|
3058
|
+
fired = true;
|
|
3059
|
+
clearTimeout(timer);
|
|
3060
|
+
const i = this.waiters.indexOf(wake);
|
|
3061
|
+
if (i >= 0) this.waiters.splice(i, 1);
|
|
3062
|
+
resolve2();
|
|
3063
|
+
};
|
|
3064
|
+
timer = setTimeout(wake, cap);
|
|
3065
|
+
this.waiters.push(wake);
|
|
3066
|
+
});
|
|
3067
|
+
return this.takeBuffered(channelId);
|
|
3068
|
+
}
|
|
3069
|
+
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
3070
|
+
subscribe(listener) {
|
|
3071
|
+
this.listeners.add(listener);
|
|
3072
|
+
return () => this.listeners.delete(listener);
|
|
3073
|
+
}
|
|
3074
|
+
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
3075
|
+
takeBuffered(channelId) {
|
|
3076
|
+
if (channelId === void 0) return this.buffer.splice(0);
|
|
3077
|
+
const keep = [];
|
|
3078
|
+
const out = [];
|
|
3079
|
+
for (const m of this.buffer) (m.channel_id === channelId ? out : keep).push(m);
|
|
3080
|
+
this.buffer.length = 0;
|
|
3081
|
+
this.buffer.push(...keep);
|
|
3082
|
+
return out;
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
3086
|
+
*
|
|
3087
|
+
* The re-buffered messages are older un-acked deliveries, so they go at the
|
|
3088
|
+
* FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
|
|
3089
|
+
* the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
|
|
3090
|
+
* sheds from the front, preserving the newest maxBuffer messages across the
|
|
3091
|
+
* combined [requeued-older..., existing-newer...] buffer.
|
|
3092
|
+
*/
|
|
3093
|
+
requeueFront(msgs) {
|
|
3094
|
+
if (msgs.length) {
|
|
3095
|
+
this.buffer.unshift(...msgs);
|
|
3096
|
+
this.capBuffer();
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Poke subscribers + waiters to drain the buffer now. Used after a
|
|
3101
|
+
* disconnect re-buffers un-acked messages so a still-connected consumer
|
|
3102
|
+
* (e.g. a replacement WS) redelivers them without waiting for new inbound.
|
|
3103
|
+
* Mirrors the notify tail of ingestOnce.
|
|
3104
|
+
*/
|
|
3105
|
+
wakeListeners() {
|
|
3106
|
+
for (const l of this.listeners) l();
|
|
3107
|
+
const w = this.waiters.splice(0);
|
|
3108
|
+
for (const r of w) r();
|
|
3109
|
+
}
|
|
3110
|
+
health() {
|
|
3111
|
+
let joined = 0;
|
|
3112
|
+
for (const s of this.joinState.values()) if (s === "JOINED") joined++;
|
|
3113
|
+
return {
|
|
3114
|
+
status: "ok",
|
|
3115
|
+
identity_id: this.cfg.identityId,
|
|
3116
|
+
joined_channels: joined,
|
|
3117
|
+
buffered: this.buffer.length,
|
|
3118
|
+
dropped: this.droppedMessages,
|
|
3119
|
+
filtered_room: this.filteredRoomMessages,
|
|
3120
|
+
rate_limited: this.rateLimitedSends,
|
|
3121
|
+
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
3122
|
+
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
|
|
3123
|
+
version: BRIDGE_VERSION
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Loopback observability snapshot served at GET /v1/metrics.
|
|
3128
|
+
*
|
|
3129
|
+
* `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
|
|
3130
|
+
* locally and monotonically — send.encrypt / send.publish / send.total (the
|
|
3131
|
+
* sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
|
|
3132
|
+
* recipient half). Excludes the agent's LLM time entirely.
|
|
3133
|
+
*
|
|
3134
|
+
* `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
|
|
3135
|
+
* provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
|
|
3136
|
+
* channel appears only once it has had a decrypt error; `stuck:true` means
|
|
3137
|
+
* repeated failures with no intervening successful decrypt.
|
|
3138
|
+
*
|
|
3139
|
+
* `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
|
|
3140
|
+
* bounded-buffer cap. A daemon silently dropping messages under backpressure is
|
|
3141
|
+
* exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
|
|
3142
|
+
* on health()) rather than swallowed.
|
|
3143
|
+
*
|
|
3144
|
+
* `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
|
|
3145
|
+
* dropped by the @mention filter. A silently-filtered room message produces no
|
|
3146
|
+
* other signal, so an agent correctly filtering 95% of room traffic would look
|
|
3147
|
+
* identical to an idle/wedged one without this counter.
|
|
3148
|
+
*
|
|
3149
|
+
* `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
|
|
3150
|
+
* acked-and-dropped because they were provably past-epoch undecryptable
|
|
3151
|
+
* (forward-secrecy secret gone), so a self-healed poison row is observable
|
|
3152
|
+
* here instead of silently dropped.
|
|
3153
|
+
*/
|
|
3154
|
+
metrics() {
|
|
3155
|
+
return {
|
|
3156
|
+
stages: this.latency.snapshot(),
|
|
3157
|
+
errors: this.provider.errorStats(),
|
|
3158
|
+
dropped: this.droppedMessages,
|
|
3159
|
+
filteredRoomMessages: this.filteredRoomMessages,
|
|
3160
|
+
rateLimitedSends: this.rateLimitedSends,
|
|
3161
|
+
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
3162
|
+
welcomeRequestsSent: this.welcomeRequestsSent
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
async close() {
|
|
3166
|
+
this.pingHandle?.close();
|
|
3167
|
+
if (this.safetyTimer) clearInterval(this.safetyTimer);
|
|
3168
|
+
this.store.close();
|
|
3169
|
+
}
|
|
3170
|
+
};
|
|
3171
|
+
|
|
3172
|
+
// src/auth.ts
|
|
3173
|
+
import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
|
|
3174
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
3175
|
+
function resolveToken(opts) {
|
|
3176
|
+
if (opts.token) return opts.token;
|
|
3177
|
+
const f = opts.tokenFile;
|
|
3178
|
+
if (f && existsSync(f)) return readFileSync2(f, "utf8").trim();
|
|
3179
|
+
const tok = randomBytes3(32).toString("base64url");
|
|
3180
|
+
if (f) writeFileSync(f, tok, { mode: 384 });
|
|
3181
|
+
return tok;
|
|
3182
|
+
}
|
|
3183
|
+
function tokenMatches(provided, expected) {
|
|
3184
|
+
if (!provided) return false;
|
|
3185
|
+
const a = Buffer.from(provided);
|
|
3186
|
+
const b = Buffer.from(expected);
|
|
3187
|
+
if (a.length !== b.length) return false;
|
|
3188
|
+
return timingSafeEqual(a, b);
|
|
3189
|
+
}
|
|
3190
|
+
function bearerFrom(header) {
|
|
3191
|
+
if (!header) return void 0;
|
|
3192
|
+
const m = /^Bearer (.+)$/i.exec(header.trim());
|
|
3193
|
+
return m ? m[1] : void 0;
|
|
3194
|
+
}
|
|
3195
|
+
function hostnameOf(hostHeader) {
|
|
3196
|
+
if (!hostHeader) return void 0;
|
|
3197
|
+
if (hostHeader.startsWith("[")) return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
|
|
3198
|
+
const i = hostHeader.lastIndexOf(":");
|
|
3199
|
+
return i > 0 ? hostHeader.slice(0, i) : hostHeader;
|
|
3200
|
+
}
|
|
3201
|
+
function originHostAllowed(headers, allowOrigins) {
|
|
3202
|
+
const host = hostnameOf(headers.host);
|
|
3203
|
+
if (host && !isLoopback(host) && !isLoopback(`[${host}]`)) return false;
|
|
3204
|
+
const origin = headers.origin;
|
|
3205
|
+
if (!origin) return true;
|
|
3206
|
+
if (allowOrigins.includes(origin)) return true;
|
|
3207
|
+
try {
|
|
3208
|
+
return isLoopback(new URL(origin).hostname);
|
|
3209
|
+
} catch {
|
|
3210
|
+
return false;
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// src/http-server.ts
|
|
3215
|
+
import { createServer as createHttp } from "node:http";
|
|
3216
|
+
import { createServer as createHttps } from "node:https";
|
|
3217
|
+
import { z } from "zod";
|
|
3218
|
+
|
|
3219
|
+
// src/errors.ts
|
|
3220
|
+
var BRIDGE_ERRORS = {
|
|
3221
|
+
unauthorized: { http: 401, message: "missing or invalid bearer token" },
|
|
3222
|
+
forbidden_origin: { http: 403, message: "origin or host not allowed" },
|
|
3223
|
+
not_found: { http: 404, message: "no such route" },
|
|
3224
|
+
invalid_request: { http: 400, message: "request failed validation" },
|
|
3225
|
+
invalid_json: { http: 400, message: "request body is not valid JSON" },
|
|
3226
|
+
body_too_large: { http: 400, message: "request body exceeds the size limit" },
|
|
3227
|
+
channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
|
|
3228
|
+
rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
|
|
3229
|
+
internal: { http: 500, message: "internal error" }
|
|
3230
|
+
};
|
|
3231
|
+
function errorBody(code, detail) {
|
|
3232
|
+
const body = { error: code, message: BRIDGE_ERRORS[code].message };
|
|
3233
|
+
if (detail !== void 0) body.detail = detail;
|
|
3234
|
+
return body;
|
|
3235
|
+
}
|
|
3236
|
+
function httpStatus(code) {
|
|
3237
|
+
return BRIDGE_ERRORS[code].http;
|
|
3238
|
+
}
|
|
3239
|
+
function wsErrorFrame(code, detail) {
|
|
3240
|
+
return { type: "error", ...errorBody(code, detail) };
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
// src/http-server.ts
|
|
3244
|
+
function writeSse(res, msgs) {
|
|
3245
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
3246
|
+
if (res.destroyed || res.writableEnded) return msgs.slice(i);
|
|
3247
|
+
try {
|
|
3248
|
+
res.write(`event: message
|
|
3249
|
+
data: ${JSON.stringify(msgs[i])}
|
|
3250
|
+
|
|
3251
|
+
`);
|
|
3252
|
+
} catch {
|
|
3253
|
+
return msgs.slice(i);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return [];
|
|
3257
|
+
}
|
|
3258
|
+
function capabilities(opts) {
|
|
3259
|
+
return {
|
|
3260
|
+
api_version: "1",
|
|
3261
|
+
receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
|
|
3262
|
+
send_types: ["text", "agent_status", "a2a"],
|
|
3263
|
+
webhook_push: false
|
|
3264
|
+
// B3: A2A-style push-notification config (ciphertext/wake-only)
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
var SEND_CAP = 64 * 1024;
|
|
3268
|
+
var OTHER_CAP = 8 * 1024;
|
|
3269
|
+
var a2aSchema = z.object({
|
|
3270
|
+
a2a_version: z.string().min(1),
|
|
3271
|
+
kind: z.enum(["message", "task", "artifact"]),
|
|
3272
|
+
object: z.record(z.unknown())
|
|
3273
|
+
});
|
|
3274
|
+
var sendSchema = z.object({
|
|
3275
|
+
channel_id: z.string().min(1),
|
|
3276
|
+
text: z.string().optional(),
|
|
3277
|
+
idempotency_key: z.string().optional(),
|
|
3278
|
+
a2a: a2aSchema.optional()
|
|
3279
|
+
}).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
|
|
3280
|
+
message: "send requires either text or a2a"
|
|
3281
|
+
});
|
|
3282
|
+
var statusSchema = z.object({
|
|
3283
|
+
channel_id: z.string().min(1),
|
|
3284
|
+
state: z.enum(["idle", "working", "stalled", "error"]),
|
|
3285
|
+
detail: z.string().optional()
|
|
3286
|
+
});
|
|
3287
|
+
function json(res, status, body) {
|
|
3288
|
+
const s = JSON.stringify(body);
|
|
3289
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
3290
|
+
res.end(s);
|
|
3291
|
+
}
|
|
3292
|
+
function jsonError(res, code, detail) {
|
|
3293
|
+
json(res, httpStatus(code), errorBody(code, detail));
|
|
3294
|
+
}
|
|
3295
|
+
function readBody(req, cap) {
|
|
3296
|
+
return new Promise((resolve2, reject) => {
|
|
3297
|
+
let size = 0;
|
|
3298
|
+
let done = false;
|
|
3299
|
+
const chunks = [];
|
|
3300
|
+
const onData = (c) => {
|
|
3301
|
+
if (done) return;
|
|
3302
|
+
size += c.length;
|
|
3303
|
+
if (size > cap) {
|
|
3304
|
+
done = true;
|
|
3305
|
+
req.removeListener("data", onData);
|
|
3306
|
+
req.pause();
|
|
3307
|
+
reject(new Error("body_too_large"));
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
chunks.push(c);
|
|
3311
|
+
};
|
|
3312
|
+
req.on("data", onData);
|
|
3313
|
+
req.on("end", () => {
|
|
3314
|
+
if (!done) {
|
|
3315
|
+
done = true;
|
|
3316
|
+
resolve2(Buffer.concat(chunks).toString("utf8"));
|
|
3317
|
+
}
|
|
3318
|
+
});
|
|
3319
|
+
req.on("error", (e) => {
|
|
3320
|
+
if (!done) {
|
|
3321
|
+
done = true;
|
|
3322
|
+
reject(e);
|
|
3323
|
+
}
|
|
3324
|
+
});
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
function createHttpServer(agent, opts) {
|
|
3328
|
+
let closeSse = () => {
|
|
3329
|
+
};
|
|
3330
|
+
let sse = null;
|
|
3331
|
+
const tearDownSse = () => {
|
|
3332
|
+
if (!sse) return;
|
|
3333
|
+
sse.unsubscribe();
|
|
3334
|
+
clearInterval(sse.keepalive);
|
|
3335
|
+
try {
|
|
3336
|
+
sse.res.end();
|
|
3337
|
+
} catch {
|
|
3338
|
+
}
|
|
3339
|
+
sse = null;
|
|
3340
|
+
};
|
|
3341
|
+
closeSse = tearDownSse;
|
|
3342
|
+
const handler = async (req, res) => {
|
|
3343
|
+
try {
|
|
3344
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3345
|
+
const path = url.pathname;
|
|
3346
|
+
const method = req.method ?? "GET";
|
|
3347
|
+
if (method === "GET" && path === "/healthz") {
|
|
3348
|
+
return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
|
|
3349
|
+
}
|
|
3350
|
+
if (!tokenMatches(bearerFrom(req.headers.authorization), opts.token)) {
|
|
3351
|
+
return jsonError(res, "unauthorized");
|
|
3352
|
+
}
|
|
3353
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
3354
|
+
return jsonError(res, "forbidden_origin");
|
|
3355
|
+
}
|
|
3356
|
+
if (method === "GET" && path === "/v1/channels") {
|
|
3357
|
+
return json(res, 200, await agent.listChannels());
|
|
3358
|
+
}
|
|
3359
|
+
if (method === "GET" && path === "/v1/metrics") {
|
|
3360
|
+
return json(res, 200, agent.metrics());
|
|
3361
|
+
}
|
|
3362
|
+
if (method === "POST" && path === "/v1/send") {
|
|
3363
|
+
const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
|
|
3364
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
3365
|
+
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);
|
|
3366
|
+
return json(res, 200, r);
|
|
3367
|
+
}
|
|
3368
|
+
if (method === "POST" && path === "/v1/status") {
|
|
3369
|
+
const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
3370
|
+
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
3371
|
+
await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
|
|
3372
|
+
return json(res, 200, { ok: true });
|
|
3373
|
+
}
|
|
3374
|
+
if (method === "GET" && path === "/v1/messages") {
|
|
3375
|
+
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
3376
|
+
const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
|
|
3377
|
+
return json(res, 200, await agent.drain(channel, wait));
|
|
3378
|
+
}
|
|
3379
|
+
if (method === "GET" && path === "/v1/events") {
|
|
3380
|
+
const channel = url.searchParams.get("channel_id") ?? void 0;
|
|
3381
|
+
tearDownSse();
|
|
3382
|
+
res.writeHead(200, {
|
|
3383
|
+
"content-type": "text/event-stream",
|
|
3384
|
+
"cache-control": "no-cache",
|
|
3385
|
+
connection: "keep-alive"
|
|
3386
|
+
});
|
|
3387
|
+
res.write(":connected\n\n");
|
|
3388
|
+
const drainToClient = () => {
|
|
3389
|
+
const left = writeSse(res, agent.takeBuffered(channel));
|
|
3390
|
+
if (left.length) agent.requeueFront(left);
|
|
3391
|
+
};
|
|
3392
|
+
const unsubscribe = agent.subscribe(drainToClient);
|
|
3393
|
+
const keepalive = setInterval(() => {
|
|
3394
|
+
try {
|
|
3395
|
+
res.write(":keepalive\n\n");
|
|
3396
|
+
} catch {
|
|
3397
|
+
}
|
|
3398
|
+
}, 15e3);
|
|
3399
|
+
sse = { res, unsubscribe, keepalive };
|
|
3400
|
+
drainToClient();
|
|
3401
|
+
req.on("close", () => {
|
|
3402
|
+
if (sse?.res === res) tearDownSse();
|
|
3403
|
+
});
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
return jsonError(res, "not_found");
|
|
3407
|
+
} catch (e) {
|
|
3408
|
+
if (e.message === "body_too_large") {
|
|
3409
|
+
if (!res.headersSent) {
|
|
3410
|
+
res.writeHead(httpStatus("body_too_large"), { "content-type": "application/json", connection: "close" });
|
|
3411
|
+
}
|
|
3412
|
+
res.end(JSON.stringify(errorBody("body_too_large")));
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
|
|
3416
|
+
if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
|
|
3417
|
+
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
3418
|
+
process.stderr.write(`[bridge] request error: ${e?.message ?? e}
|
|
3419
|
+
`);
|
|
3420
|
+
return jsonError(res, "internal");
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
3423
|
+
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));
|
|
3424
|
+
return { server, closeAllSseStreams: () => closeSse() };
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
// src/ws-server.ts
|
|
3428
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
3429
|
+
var PING_INTERVAL_MS = 3e4;
|
|
3430
|
+
function tokenFromUpgrade(req) {
|
|
3431
|
+
try {
|
|
3432
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
3433
|
+
const q = url.searchParams.get("token");
|
|
3434
|
+
if (q) return q;
|
|
3435
|
+
} catch {
|
|
3436
|
+
}
|
|
3437
|
+
return bearerFrom(req.headers.authorization);
|
|
3438
|
+
}
|
|
3439
|
+
function authedUpgrade(req, opts) {
|
|
3440
|
+
if (!tokenMatches(tokenFromUpgrade(req), opts.token)) return false;
|
|
3441
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
3442
|
+
return false;
|
|
3443
|
+
}
|
|
3444
|
+
return true;
|
|
3445
|
+
}
|
|
3446
|
+
function attachAgentWebSocket(server, agent, opts) {
|
|
3447
|
+
const wss = new WebSocketServer({ server, path: "/v1/ws" });
|
|
3448
|
+
const keepalive = setInterval(() => {
|
|
3449
|
+
for (const client of wss.clients) {
|
|
3450
|
+
const sock = client;
|
|
3451
|
+
if (sock.isAlive === false) {
|
|
3452
|
+
sock.terminate();
|
|
3453
|
+
continue;
|
|
3454
|
+
}
|
|
3455
|
+
sock.isAlive = false;
|
|
3456
|
+
try {
|
|
3457
|
+
sock.ping();
|
|
3458
|
+
} catch {
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
}, PING_INTERVAL_MS);
|
|
3462
|
+
if (typeof keepalive.unref === "function") keepalive.unref();
|
|
3463
|
+
let current = null;
|
|
3464
|
+
wss.on("connection", (sock, req) => {
|
|
3465
|
+
if (!authedUpgrade(req, opts)) {
|
|
3466
|
+
sock.close(1008, "unauthorized");
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
if (current && current.sock !== sock) {
|
|
3470
|
+
const prior = current;
|
|
3471
|
+
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
3472
|
+
prior.sock.close(1e3, "replaced by newer connection");
|
|
3473
|
+
}
|
|
3474
|
+
prior.cleanup();
|
|
3475
|
+
}
|
|
3476
|
+
sock.isAlive = true;
|
|
3477
|
+
sock.on("pong", () => {
|
|
3478
|
+
sock.isAlive = true;
|
|
3479
|
+
});
|
|
3480
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
3481
|
+
const flush = () => {
|
|
3482
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
3483
|
+
const batch = agent.takeBuffered();
|
|
3484
|
+
for (let i = 0; i < batch.length; i++) {
|
|
3485
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
3486
|
+
agent.requeueFront(batch.slice(i));
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
const frame = { type: "message", ...batch[i] };
|
|
3490
|
+
try {
|
|
3491
|
+
sock.send(JSON.stringify(frame));
|
|
3492
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
3493
|
+
} catch {
|
|
3494
|
+
agent.requeueFront(batch.slice(i));
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
};
|
|
3499
|
+
const unsubscribe = agent.subscribe(flush);
|
|
3500
|
+
let cleaned = false;
|
|
3501
|
+
const cleanup = () => {
|
|
3502
|
+
if (cleaned) return;
|
|
3503
|
+
cleaned = true;
|
|
3504
|
+
unsubscribe();
|
|
3505
|
+
if (inflight.size) {
|
|
3506
|
+
agent.requeueFront([...inflight.values()]);
|
|
3507
|
+
inflight.clear();
|
|
3508
|
+
agent.wakeListeners();
|
|
3509
|
+
}
|
|
3510
|
+
if (current?.sock === sock) current = null;
|
|
3511
|
+
};
|
|
3512
|
+
current = { sock, cleanup };
|
|
3513
|
+
sock.on("close", cleanup);
|
|
3514
|
+
sock.on("error", cleanup);
|
|
3515
|
+
sock.on("message", (data) => {
|
|
3516
|
+
void handleInbound(sock, agent, data, inflight);
|
|
3517
|
+
});
|
|
3518
|
+
flush();
|
|
3519
|
+
});
|
|
3520
|
+
return {
|
|
3521
|
+
close() {
|
|
3522
|
+
clearInterval(keepalive);
|
|
3523
|
+
for (const client of wss.clients) {
|
|
3524
|
+
try {
|
|
3525
|
+
client.terminate();
|
|
3526
|
+
} catch {
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
wss.close();
|
|
3530
|
+
}
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function sendJson(sock, body) {
|
|
3534
|
+
if (sock.readyState !== WebSocket.OPEN) return;
|
|
3535
|
+
try {
|
|
3536
|
+
sock.send(JSON.stringify(body));
|
|
3537
|
+
} catch {
|
|
3538
|
+
}
|
|
3539
|
+
}
|
|
3540
|
+
async function handleInbound(sock, agent, data, inflight) {
|
|
3541
|
+
let msg;
|
|
3542
|
+
try {
|
|
3543
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
3544
|
+
} catch {
|
|
3545
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3548
|
+
if (typeof msg !== "object" || msg === null) {
|
|
3549
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
const m = msg;
|
|
3553
|
+
if (m.type === "ack") {
|
|
3554
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
3555
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
inflight.delete(m.message_id);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
if (m.type === "send") {
|
|
3562
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
3563
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
if (m.a2a && typeof m.a2a === "object") {
|
|
3567
|
+
const a = m.a2a;
|
|
3568
|
+
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) {
|
|
3569
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
try {
|
|
3573
|
+
const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
|
|
3574
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
if (e instanceof ChannelPendingError) {
|
|
3577
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
3578
|
+
} else if (e instanceof RateLimitedError) {
|
|
3579
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
3580
|
+
} else {
|
|
3581
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
return;
|
|
3585
|
+
}
|
|
3586
|
+
if (typeof m.text !== "string" || !m.text) {
|
|
3587
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : void 0;
|
|
3591
|
+
try {
|
|
3592
|
+
const r = await agent.send(m.channel_id, m.text, idempotencyKey);
|
|
3593
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
3594
|
+
} catch (e) {
|
|
3595
|
+
if (e instanceof ChannelPendingError) {
|
|
3596
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
3597
|
+
} else if (e instanceof RateLimitedError) {
|
|
3598
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
3599
|
+
} else {
|
|
3600
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
if (m.type === "status") {
|
|
3606
|
+
const states = ["idle", "working", "stalled", "error"];
|
|
3607
|
+
if (typeof m.channel_id !== "string" || !m.channel_id || typeof m.state !== "string" || !states.includes(m.state)) {
|
|
3608
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
const detail = typeof m.detail === "string" ? m.detail : void 0;
|
|
3612
|
+
try {
|
|
3613
|
+
await agent.reportStatus(m.channel_id, m.state, detail);
|
|
3614
|
+
sendJson(sock, { type: "ok" });
|
|
3615
|
+
} catch (e) {
|
|
3616
|
+
if (e instanceof ChannelPendingError) {
|
|
3617
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
3618
|
+
} else if (e instanceof RateLimitedError) {
|
|
3619
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
3620
|
+
} else {
|
|
3621
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
// src/bin.ts
|
|
3630
|
+
import { readFileSync as readFileSync3, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
|
|
3631
|
+
import { join as join3, dirname as dirname4 } from "node:path";
|
|
3632
|
+
import { fileURLToPath } from "node:url";
|
|
3633
|
+
import { homedir, userInfo } from "node:os";
|
|
3634
|
+
import { spawnSync } from "node:child_process";
|
|
3635
|
+
|
|
3636
|
+
// src/service/install.ts
|
|
3637
|
+
import { dirname as dirname2 } from "node:path";
|
|
3638
|
+
|
|
3639
|
+
// src/service/config.ts
|
|
3640
|
+
import { basename, extname, isAbsolute, resolve } from "node:path";
|
|
3641
|
+
function slugify(s) {
|
|
3642
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
3643
|
+
}
|
|
3644
|
+
function deriveServiceConfig(input) {
|
|
3645
|
+
const stem = input.label ?? basename(input.credentialPath, extname(input.credentialPath));
|
|
3646
|
+
const slug = slugify(stem);
|
|
3647
|
+
const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
|
|
3648
|
+
const args2 = ["--credential", cred];
|
|
3649
|
+
if (input.storePath) args2.push("--store", isAbsolute(input.storePath) ? input.storePath : resolve(input.storePath));
|
|
3650
|
+
if (input.port != null) args2.push("--port", String(input.port));
|
|
3651
|
+
return {
|
|
3652
|
+
slug,
|
|
3653
|
+
label: `com.securecoms.bridge.${slug}`,
|
|
3654
|
+
unitName: `securecoms-bridge-${slug}`,
|
|
3655
|
+
args: args2,
|
|
3656
|
+
execPath: input.execPath,
|
|
3657
|
+
binScript: input.binScript,
|
|
3658
|
+
uid: input.uid,
|
|
3659
|
+
plistPath: `${input.home}/Library/LaunchAgents/com.securecoms.bridge.${slug}.plist`,
|
|
3660
|
+
unitPath: `${input.home}/.config/systemd/user/securecoms-bridge-${slug}.service`,
|
|
3661
|
+
logPath: `${input.home}/Library/Logs/securecoms-bridge-${slug}.log`
|
|
3662
|
+
};
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// src/service/launchd.ts
|
|
3666
|
+
function renderLaunchdPlist(cfg) {
|
|
3667
|
+
const argv = [cfg.execPath, cfg.binScript, ...cfg.args];
|
|
3668
|
+
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
3669
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3670
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3671
|
+
<plist version="1.0">
|
|
3672
|
+
<dict>
|
|
3673
|
+
<key>Label</key>
|
|
3674
|
+
<string>${cfg.label}</string>
|
|
3675
|
+
<key>ProgramArguments</key>
|
|
3676
|
+
<array>
|
|
3677
|
+
${programArgs}
|
|
3678
|
+
</array>
|
|
3679
|
+
<key>RunAtLoad</key>
|
|
3680
|
+
<true/>
|
|
3681
|
+
<key>KeepAlive</key>
|
|
3682
|
+
<true/>
|
|
3683
|
+
<key>StandardOutPath</key>
|
|
3684
|
+
<string>${escapeXml(cfg.logPath)}</string>
|
|
3685
|
+
<key>StandardErrorPath</key>
|
|
3686
|
+
<string>${escapeXml(cfg.logPath)}</string>
|
|
3687
|
+
</dict>
|
|
3688
|
+
</plist>
|
|
3689
|
+
`;
|
|
3690
|
+
}
|
|
3691
|
+
function escapeXml(s) {
|
|
3692
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/service/systemd.ts
|
|
3696
|
+
function sdQuote(s) {
|
|
3697
|
+
return s.includes(" ") || s.includes('"') ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
|
3698
|
+
}
|
|
3699
|
+
function renderSystemdUnit(cfg) {
|
|
3700
|
+
const execStart = [cfg.execPath, cfg.binScript, ...cfg.args].map(sdQuote).join(" ");
|
|
3701
|
+
return `[Unit]
|
|
3702
|
+
Description=SecureComs agent bridge (${cfg.slug})
|
|
3703
|
+
After=network-online.target
|
|
3704
|
+
Wants=network-online.target
|
|
3705
|
+
|
|
3706
|
+
[Service]
|
|
3707
|
+
Type=simple
|
|
3708
|
+
ExecStart=${execStart}
|
|
3709
|
+
Restart=always
|
|
3710
|
+
RestartSec=5
|
|
3711
|
+
|
|
3712
|
+
[Install]
|
|
3713
|
+
WantedBy=default.target
|
|
3714
|
+
`;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
// src/service/install.ts
|
|
3718
|
+
var ServiceUnsupportedError = class extends Error {
|
|
3719
|
+
};
|
|
3720
|
+
function tokenHint(input) {
|
|
3721
|
+
return `${dirname2(input.credentialPath)}/bridge-token`;
|
|
3722
|
+
}
|
|
3723
|
+
function requireOk(r, what) {
|
|
3724
|
+
if (r.status !== 0) throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
3725
|
+
}
|
|
3726
|
+
function installService(input, deps) {
|
|
3727
|
+
const cfg = deriveServiceConfig(input);
|
|
3728
|
+
if (deps.platform === "darwin") {
|
|
3729
|
+
deps.mkdirp(dirname2(cfg.plistPath));
|
|
3730
|
+
deps.mkdirp(dirname2(cfg.logPath));
|
|
3731
|
+
deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
|
|
3732
|
+
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
3733
|
+
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
3734
|
+
deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
|
|
3735
|
+
requireOk(deps.run("launchctl", ["kickstart", "-k", `gui/${cfg.uid}/${cfg.label}`]), "launchctl kickstart");
|
|
3736
|
+
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
|
|
3737
|
+
}
|
|
3738
|
+
if (deps.platform === "linux") {
|
|
3739
|
+
deps.mkdirp(dirname2(cfg.unitPath));
|
|
3740
|
+
deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
|
|
3741
|
+
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
3742
|
+
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
3743
|
+
requireOk(deps.run("systemctl", ["--user", "enable", "--now", cfg.unitName]), "systemctl --user enable --now");
|
|
3744
|
+
return { label: cfg.unitName, filePath: cfg.unitPath, logHint: `journalctl --user -u ${cfg.unitName} -f`, tokenHint: tokenHint(input) };
|
|
3745
|
+
}
|
|
3746
|
+
throw new ServiceUnsupportedError(`Service install isn't supported on ${deps.platform} yet. Run the agent in a terminal, or use a process manager.`);
|
|
3747
|
+
}
|
|
3748
|
+
function uninstallService(input, deps) {
|
|
3749
|
+
const cfg = deriveServiceConfig(input);
|
|
3750
|
+
if (deps.platform === "darwin") {
|
|
3751
|
+
const existed = deps.fileExists(cfg.plistPath);
|
|
3752
|
+
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
3753
|
+
if (existed) deps.removeFile(cfg.plistPath);
|
|
3754
|
+
return { removed: existed, filePath: cfg.plistPath };
|
|
3755
|
+
}
|
|
3756
|
+
if (deps.platform === "linux") {
|
|
3757
|
+
const existed = deps.fileExists(cfg.unitPath);
|
|
3758
|
+
deps.run("systemctl", ["--user", "disable", "--now", cfg.unitName]);
|
|
3759
|
+
if (existed) deps.removeFile(cfg.unitPath);
|
|
3760
|
+
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
3761
|
+
return { removed: existed, filePath: cfg.unitPath };
|
|
3762
|
+
}
|
|
3763
|
+
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
// src/service/resolve-bin.ts
|
|
3767
|
+
import { join as join2, dirname as dirname3 } from "node:path";
|
|
3768
|
+
import { createRequire } from "node:module";
|
|
3769
|
+
var GlobalInstallPermissionError = class extends Error {
|
|
3770
|
+
constructor(message) {
|
|
3771
|
+
super(message);
|
|
3772
|
+
this.name = "GlobalInstallPermissionError";
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
function isEphemeralPath(p) {
|
|
3776
|
+
return /[\\/]_npx[\\/]/.test(p);
|
|
3777
|
+
}
|
|
3778
|
+
function refersToSameFile(a, b, realpath) {
|
|
3779
|
+
if (a === b) return true;
|
|
3780
|
+
try {
|
|
3781
|
+
return realpath(a) === realpath(b);
|
|
3782
|
+
} catch {
|
|
3783
|
+
return false;
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
function resolveStableBinScript(input, deps) {
|
|
3787
|
+
const ephemeral = isEphemeralPath(input.binScript);
|
|
3788
|
+
if (!ephemeral) {
|
|
3789
|
+
if (deps.fileExists(input.binScript)) return input.binScript;
|
|
3790
|
+
throw new Error(`bin path does not exist and is not an npx cache path: ${input.binScript}`);
|
|
3791
|
+
}
|
|
3792
|
+
const pkgRoot = dirname3(dirname3(input.binScript));
|
|
3793
|
+
const inst = deps.run("npm", ["i", "-g", pkgRoot, "--install-links"]);
|
|
3794
|
+
if (inst.status !== 0) {
|
|
3795
|
+
if (/EACCES|permission denied/i.test(inst.stderr)) {
|
|
3796
|
+
throw new GlobalInstallPermissionError(
|
|
3797
|
+
`Can't write the global npm prefix. Run this once, then re-run install:
|
|
3798
|
+
npm i -g ${input.packageName}`
|
|
3799
|
+
);
|
|
3800
|
+
}
|
|
3801
|
+
throw new Error(`npm i -g ${pkgRoot} failed (exit ${inst.status})${inst.stderr ? `: ${inst.stderr.trim()}` : ""}`);
|
|
3802
|
+
}
|
|
3803
|
+
const root = deps.run("npm", ["root", "-g"]);
|
|
3804
|
+
if (root.status !== 0) throw new Error(`npm root -g failed (exit ${root.status})`);
|
|
3805
|
+
const stable = join2(root.stdout.trim(), input.packageName, "dist", "bin.js");
|
|
3806
|
+
if (!deps.fileExists(stable)) throw new Error(`global install did not produce ${stable}`);
|
|
3807
|
+
return stable;
|
|
3808
|
+
}
|
|
3809
|
+
function loadPackageMeta(importMetaUrl) {
|
|
3810
|
+
const require2 = createRequire(importMetaUrl);
|
|
3811
|
+
const pkg = require2("../package.json");
|
|
3812
|
+
return { name: pkg.name, version: pkg.version };
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// src/bin.ts
|
|
3816
|
+
function nodeMajorOk(version) {
|
|
3817
|
+
return Number(version.split(".")[0]) >= 24;
|
|
3818
|
+
}
|
|
3819
|
+
function argOrEnv(flag, env) {
|
|
3820
|
+
const i = process.argv.indexOf(flag);
|
|
3821
|
+
if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
|
|
3822
|
+
return process.env[env];
|
|
3823
|
+
}
|
|
3824
|
+
function parseVerb(argv) {
|
|
3825
|
+
const v = argv[2];
|
|
3826
|
+
return v === "install" || v === "uninstall" ? v : null;
|
|
3827
|
+
}
|
|
3828
|
+
function realServiceDeps() {
|
|
3829
|
+
return {
|
|
3830
|
+
platform: process.platform,
|
|
3831
|
+
run: (cmd, args2) => {
|
|
3832
|
+
const r = spawnSync(cmd, args2, { encoding: "utf8" });
|
|
3833
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
3834
|
+
},
|
|
3835
|
+
writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
|
|
3836
|
+
removeFile: (p) => rmSync(p, { force: true }),
|
|
3837
|
+
mkdirp: (p) => mkdirSync(p, { recursive: true }),
|
|
3838
|
+
fileExists: (p) => existsSync2(p),
|
|
3839
|
+
user: userInfo().username
|
|
3840
|
+
};
|
|
3841
|
+
}
|
|
3842
|
+
function realResolveDeps() {
|
|
3843
|
+
return {
|
|
3844
|
+
run: (cmd, args2) => {
|
|
3845
|
+
const r = spawnSync(cmd, args2, { encoding: "utf8" });
|
|
3846
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
3847
|
+
},
|
|
3848
|
+
fileExists: (p) => existsSync2(p)
|
|
3849
|
+
};
|
|
3850
|
+
}
|
|
3851
|
+
function runServiceVerb(verb) {
|
|
3852
|
+
const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
|
|
3853
|
+
const label = argOrEnv("--label", "SECURECOMS_LABEL");
|
|
3854
|
+
if (!credentialPath && !label) {
|
|
3855
|
+
process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N]
|
|
3856
|
+
`);
|
|
3857
|
+
process.exit(2);
|
|
3858
|
+
}
|
|
3859
|
+
const portStr = argOrEnv("--port", "SECURECOMS_PORT");
|
|
3860
|
+
let binScript = process.argv[1];
|
|
3861
|
+
if (verb === "install") {
|
|
3862
|
+
const meta = loadPackageMeta(import.meta.url);
|
|
3863
|
+
binScript = resolveStableBinScript({ binScript, packageName: meta.name }, realResolveDeps());
|
|
3864
|
+
}
|
|
3865
|
+
const input = {
|
|
3866
|
+
credentialPath: credentialPath ?? "",
|
|
3867
|
+
storePath: argOrEnv("--store", "SECURECOMS_STORE"),
|
|
3868
|
+
port: portStr ? Number(portStr) : void 0,
|
|
3869
|
+
label,
|
|
3870
|
+
execPath: process.execPath,
|
|
3871
|
+
binScript,
|
|
3872
|
+
home: homedir(),
|
|
3873
|
+
uid: userInfo().uid
|
|
3874
|
+
};
|
|
3875
|
+
try {
|
|
3876
|
+
if (verb === "install") {
|
|
3877
|
+
const r = installService(input, realServiceDeps());
|
|
3878
|
+
process.stdout.write(`Installed service ${r.label}
|
|
3879
|
+
file: ${r.filePath}
|
|
3880
|
+
logs: ${r.logHint}
|
|
3881
|
+
token: ${r.tokenHint} (the local BRIDGE_TOKEN)
|
|
3882
|
+
`);
|
|
3883
|
+
} else {
|
|
3884
|
+
const r = uninstallService(input, realServiceDeps());
|
|
3885
|
+
process.stdout.write(r.removed ? `Removed service (${r.filePath})
|
|
3886
|
+
` : `No service installed for this agent
|
|
3887
|
+
`);
|
|
3888
|
+
}
|
|
3889
|
+
process.exit(0);
|
|
3890
|
+
} catch (e) {
|
|
3891
|
+
const raw = e instanceof ServiceUnsupportedError || e instanceof GlobalInstallPermissionError;
|
|
3892
|
+
process.stderr.write(`${raw ? e.message : `service ${verb} failed: ${e.message ?? e}`}
|
|
3893
|
+
`);
|
|
3894
|
+
process.exit(1);
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
async function main() {
|
|
3898
|
+
if (!nodeMajorOk(process.versions.node)) {
|
|
3899
|
+
process.stderr.write(`[bridge] fatal: Node ${process.versions.node} unsupported \u2014 requires >= 24 (node:sqlite stable API)
|
|
3900
|
+
`);
|
|
3901
|
+
process.exit(1);
|
|
3902
|
+
}
|
|
3903
|
+
const verb = parseVerb(process.argv);
|
|
3904
|
+
if (verb) {
|
|
3905
|
+
runServiceVerb(verb);
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
|
|
3909
|
+
if (!credentialPath) {
|
|
3910
|
+
process.stderr.write("usage: connect-agent-bridge --credential <path> [--store <path>] [--port N] [--bind ADDR]\n or: agent-bridge install|uninstall --credential <path>\n");
|
|
3911
|
+
process.exit(2);
|
|
3912
|
+
}
|
|
3913
|
+
const storePath = argOrEnv("--store", "SECURECOMS_STORE");
|
|
3914
|
+
let cfg, daemon;
|
|
3915
|
+
try {
|
|
3916
|
+
cfg = loadConfig({ credentialPath, storePath });
|
|
3917
|
+
daemon = parseDaemonArgs(process.argv, process.env);
|
|
3918
|
+
validateBind(daemon.bind, daemon.tls);
|
|
3919
|
+
} catch (e) {
|
|
3920
|
+
process.stderr.write(`[bridge] config error: ${e.message ?? e}
|
|
3921
|
+
`);
|
|
3922
|
+
process.exit(1);
|
|
3923
|
+
}
|
|
3924
|
+
const tokenFile = daemon.tokenFile ?? join3(dirname4(credentialPath), "bridge-token");
|
|
3925
|
+
const token = resolveToken({ tokenFile, token: daemon.token });
|
|
3926
|
+
const agent = new BridgeAgent(cfg);
|
|
3927
|
+
await agent.start();
|
|
3928
|
+
const tls = daemon.tls ? { cert: readFileSync3(daemon.tlsCert, "utf8"), key: readFileSync3(daemon.tlsKey, "utf8") } : void 0;
|
|
3929
|
+
const { server, closeAllSseStreams } = createHttpServer(agent, { token, allowOrigins: daemon.allowOrigins, tls, websocket: true });
|
|
3930
|
+
const agentWs = attachAgentWebSocket(server, agent, { token, allowOrigins: daemon.allowOrigins });
|
|
3931
|
+
let down = false;
|
|
3932
|
+
const shutdown = () => {
|
|
3933
|
+
if (down) return;
|
|
3934
|
+
down = true;
|
|
3935
|
+
agentWs.close();
|
|
3936
|
+
server.close();
|
|
3937
|
+
closeAllSseStreams();
|
|
3938
|
+
void agent.close().finally(() => process.exit(0));
|
|
3939
|
+
};
|
|
3940
|
+
process.on("SIGINT", shutdown);
|
|
3941
|
+
process.on("SIGTERM", shutdown);
|
|
3942
|
+
server.listen(daemon.port, daemon.bind, () => {
|
|
3943
|
+
const scheme = tls ? "https" : "http";
|
|
3944
|
+
const wsScheme = tls ? "wss" : "ws";
|
|
3945
|
+
process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
|
|
3946
|
+
`);
|
|
3947
|
+
process.stderr.write(`BRIDGE_TOKEN=${token}
|
|
3948
|
+
`);
|
|
3949
|
+
});
|
|
3950
|
+
}
|
|
3951
|
+
var here = fileURLToPath(import.meta.url);
|
|
3952
|
+
var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync);
|
|
3953
|
+
if (isMain) {
|
|
3954
|
+
main().catch((e) => {
|
|
3955
|
+
process.stderr.write(`[bridge] fatal: ${e?.message ?? String(e)}
|
|
3956
|
+
`);
|
|
3957
|
+
process.exit(1);
|
|
3958
|
+
});
|
|
3959
|
+
}
|
|
3960
|
+
export {
|
|
3961
|
+
nodeMajorOk,
|
|
3962
|
+
parseVerb
|
|
3963
|
+
};
|