@securecoms/agent-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +378 -0
- package/dist/attachments.d.ts +48 -0
- package/dist/attachments.js +49 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +56 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +3963 -0
- package/dist/bridge-agent.d.ts +259 -0
- package/dist/bridge-agent.js +831 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +74 -0
- package/dist/coordination.d.ts +24 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3646 -0
- package/dist/latency.d.ts +26 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.d.ts +17 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.d.ts +23 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.d.ts +28 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.d.ts +4 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/resolve-bin.d.ts +35 -0
- package/dist/service/systemd.d.ts +4 -0
- package/dist/service/systemd.js +25 -0
- package/dist/ws-server.d.ts +42 -0
- package/dist/ws-server.js +254 -0
- package/package.json +48 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { ChannelPendingError, RateLimitedError } from "./bridge-agent.js";
|
|
3
|
+
import { tokenMatches, bearerFrom, originHostAllowed } from "./auth.js";
|
|
4
|
+
import { wsErrorFrame } from "./errors.js";
|
|
5
|
+
const PING_INTERVAL_MS = 30_000;
|
|
6
|
+
/** Pull the local token from `?token=` first, then `Authorization: Bearer`. */
|
|
7
|
+
function tokenFromUpgrade(req) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
10
|
+
const q = url.searchParams.get("token");
|
|
11
|
+
if (q)
|
|
12
|
+
return q;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
/* malformed URL — fall through to header */
|
|
16
|
+
}
|
|
17
|
+
return bearerFrom(req.headers.authorization);
|
|
18
|
+
}
|
|
19
|
+
function authedUpgrade(req, opts) {
|
|
20
|
+
if (!tokenMatches(tokenFromUpgrade(req), opts.token))
|
|
21
|
+
return false;
|
|
22
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
export function attachAgentWebSocket(server, agent, opts) {
|
|
28
|
+
const wss = new WebSocketServer({ server, path: "/v1/ws" });
|
|
29
|
+
// Server-side keepalive: ping every interval, drop sockets that miss a pong.
|
|
30
|
+
const keepalive = setInterval(() => {
|
|
31
|
+
for (const client of wss.clients) {
|
|
32
|
+
const sock = client;
|
|
33
|
+
if (sock.isAlive === false) {
|
|
34
|
+
sock.terminate();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
sock.isAlive = false;
|
|
38
|
+
try {
|
|
39
|
+
sock.ping();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* socket on its way out */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}, PING_INTERVAL_MS);
|
|
46
|
+
// Don't let the interval pin the process open.
|
|
47
|
+
if (typeof keepalive.unref === "function")
|
|
48
|
+
keepalive.unref();
|
|
49
|
+
// One agent per process: the live connection currently owning the inbound
|
|
50
|
+
// stream, carrying its cleanup so a replacement can drain it synchronously.
|
|
51
|
+
let current = null;
|
|
52
|
+
wss.on("connection", (sock, req) => {
|
|
53
|
+
// Auth is enforced here (not verifyClient) so we can close with a clean
|
|
54
|
+
// 1008 policy-violation code that the client observes, rather than a bare
|
|
55
|
+
// HTTP handshake rejection.
|
|
56
|
+
if (!authedUpgrade(req, opts)) {
|
|
57
|
+
sock.close(1008, "unauthorized");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Single consumer, newest wins (mirrors the SSE path). Close the prior
|
|
61
|
+
// socket AND run its cleanup synchronously, so its un-acked in-flight is
|
|
62
|
+
// re-buffered (in FIFO order) BEFORE this socket's initial flush — otherwise
|
|
63
|
+
// the replaced socket's async close could re-buffer those messages after
|
|
64
|
+
// this socket has already consumed newer ones, reordering delivery.
|
|
65
|
+
if (current && current.sock !== sock) {
|
|
66
|
+
const prior = current;
|
|
67
|
+
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
68
|
+
prior.sock.close(1000, "replaced by newer connection");
|
|
69
|
+
}
|
|
70
|
+
prior.cleanup();
|
|
71
|
+
}
|
|
72
|
+
sock.isAlive = true;
|
|
73
|
+
sock.on("pong", () => {
|
|
74
|
+
sock.isAlive = true;
|
|
75
|
+
});
|
|
76
|
+
// Delivered-but-unacked messages, keyed by message_id in delivery order.
|
|
77
|
+
// Held until the agent acks; re-buffered on disconnect (at-least-once).
|
|
78
|
+
const inflight = new Map();
|
|
79
|
+
const flush = () => {
|
|
80
|
+
if (sock.readyState !== WebSocket.OPEN)
|
|
81
|
+
return;
|
|
82
|
+
const batch = agent.takeBuffered();
|
|
83
|
+
for (let i = 0; i < batch.length; i++) {
|
|
84
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
85
|
+
// Socket closing mid-flush — give the rest back to the buffer.
|
|
86
|
+
agent.requeueFront(batch.slice(i));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const frame = { type: "message", ...batch[i] };
|
|
90
|
+
try {
|
|
91
|
+
sock.send(JSON.stringify(frame));
|
|
92
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
agent.requeueFront(batch.slice(i));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const unsubscribe = agent.subscribe(flush);
|
|
101
|
+
let cleaned = false;
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (cleaned)
|
|
104
|
+
return;
|
|
105
|
+
cleaned = true;
|
|
106
|
+
unsubscribe();
|
|
107
|
+
// Re-buffer anything delivered but never acked, in original order, so the
|
|
108
|
+
// next consumer redelivers it rather than the daemon dropping it. Poke
|
|
109
|
+
// listeners so a still-connected replacement consumer drains it now.
|
|
110
|
+
if (inflight.size) {
|
|
111
|
+
agent.requeueFront([...inflight.values()]);
|
|
112
|
+
inflight.clear();
|
|
113
|
+
agent.wakeListeners();
|
|
114
|
+
}
|
|
115
|
+
if (current?.sock === sock)
|
|
116
|
+
current = null;
|
|
117
|
+
};
|
|
118
|
+
current = { sock, cleanup };
|
|
119
|
+
sock.on("close", cleanup);
|
|
120
|
+
sock.on("error", cleanup);
|
|
121
|
+
sock.on("message", (data) => {
|
|
122
|
+
void handleInbound(sock, agent, data, inflight);
|
|
123
|
+
});
|
|
124
|
+
// Flush anything already buffered for an agent that connects after delivery.
|
|
125
|
+
flush();
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
close() {
|
|
129
|
+
clearInterval(keepalive);
|
|
130
|
+
for (const client of wss.clients) {
|
|
131
|
+
try {
|
|
132
|
+
client.terminate();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* already gone */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
wss.close();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function sendJson(sock, body) {
|
|
143
|
+
if (sock.readyState !== WebSocket.OPEN)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
sock.send(JSON.stringify(body));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* socket closing */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function handleInbound(sock, agent, data, inflight) {
|
|
153
|
+
let msg;
|
|
154
|
+
try {
|
|
155
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (typeof msg !== "object" || msg === null) {
|
|
162
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const m = msg;
|
|
166
|
+
if (m.type === "ack") {
|
|
167
|
+
// Consume confirmation: drop the message from in-flight so it is not
|
|
168
|
+
// re-buffered on disconnect. Fire-and-forget (no reply frame).
|
|
169
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
170
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
inflight.delete(m.message_id);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (m.type === "send") {
|
|
177
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
178
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (m.a2a && typeof m.a2a === "object") {
|
|
182
|
+
const a = m.a2a;
|
|
183
|
+
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) {
|
|
184
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
|
|
189
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (e instanceof ChannelPendingError) {
|
|
193
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
194
|
+
}
|
|
195
|
+
else if (e instanceof RateLimitedError) {
|
|
196
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (typeof m.text !== "string" || !m.text) {
|
|
205
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : undefined;
|
|
209
|
+
try {
|
|
210
|
+
const r = await agent.send(m.channel_id, m.text, idempotencyKey);
|
|
211
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (e instanceof ChannelPendingError) {
|
|
215
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
216
|
+
}
|
|
217
|
+
else if (e instanceof RateLimitedError) {
|
|
218
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (m.type === "status") {
|
|
227
|
+
const states = ["idle", "working", "stalled", "error"];
|
|
228
|
+
if (typeof m.channel_id !== "string" ||
|
|
229
|
+
!m.channel_id ||
|
|
230
|
+
typeof m.state !== "string" ||
|
|
231
|
+
!states.includes(m.state)) {
|
|
232
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const detail = typeof m.detail === "string" ? m.detail : undefined;
|
|
236
|
+
try {
|
|
237
|
+
await agent.reportStatus(m.channel_id, m.state, detail);
|
|
238
|
+
sendJson(sock, { type: "ok" });
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (e instanceof ChannelPendingError) {
|
|
242
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
243
|
+
}
|
|
244
|
+
else if (e instanceof RateLimitedError) {
|
|
245
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
254
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@securecoms/agent-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"agent-bridge": "dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc && node esbuild.config.mjs",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@agentvault/crypto": "^0.4.1",
|
|
33
|
+
"@noble/curves": "^1.9.7",
|
|
34
|
+
"@noble/hashes": "^1.4.0",
|
|
35
|
+
"@noble/ciphers": "^1.3.0",
|
|
36
|
+
"ajv": "^8.18.0",
|
|
37
|
+
"ajv-formats": "^3.0.1",
|
|
38
|
+
"ws": "^8.21.0",
|
|
39
|
+
"zod": "^3.23.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"@types/ws": "^8.18.1",
|
|
44
|
+
"esbuild": "0.24.0",
|
|
45
|
+
"typescript": "^5.4.0",
|
|
46
|
+
"vitest": "^2.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|