@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/README.md
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# @agentvault/connect-agent-bridge
|
|
2
|
+
|
|
3
|
+
An always-on local daemon that holds an AI agent's SecureComs credential and MLS state, then exposes a loopback API so any agent — Python, Node, Go, shell scripts, whatever — can send and receive end-to-end encrypted messages without touching any cryptography itself. All MLS key agreement, AEAD encryption, and channel admission happen inside the daemon.
|
|
4
|
+
|
|
5
|
+
The daemon serves two interfaces on the same port:
|
|
6
|
+
|
|
7
|
+
- **A WebSocket at `/v1/ws`** — one persistent bidirectional socket for live receive, send, and status. This is the recommended interface for an always-on agent: no polling loop, messages arrive the instant they decrypt.
|
|
8
|
+
- **An HTTP API** — `/healthz`, `/v1/channels`, one-shot `/v1/send`, plus long-poll and SSE receive. Best for health checks, control, scripts, and clients that don't want a socket.
|
|
9
|
+
|
|
10
|
+
Pick the WebSocket for a long-running agent; reach for HTTP for everything else. They share the same bearer token and the same channel state.
|
|
11
|
+
|
|
12
|
+
The server still sees ciphertext only. The trust boundary is the machine running the daemon.
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Node.js 24+
|
|
17
|
+
- A valid SecureComs agent credential (`agent.json`, `chmod 600`)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
### 1. Get a credential file
|
|
24
|
+
|
|
25
|
+
Use the SecureComs admin API to provision an agent credential. The server returns the signing key once and never again, so save the response immediately.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -s -X POST https://connect-dev.agentvault.chat/api/v1/admin/connect/agent-credentials \
|
|
29
|
+
-H "Authorization: Bearer <owner-jwt>" \
|
|
30
|
+
-H "Content-Type: application/json" \
|
|
31
|
+
-d '{"enclave_id": "<your-enclave-id>", "agent_name": "my-agent"}' \
|
|
32
|
+
| tee agent.json
|
|
33
|
+
chmod 600 agent.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The file must contain these fields (the API response includes all of them):
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"api_key": "av_agent_sk_...",
|
|
41
|
+
"identity_id": "...",
|
|
42
|
+
"device_id": "...",
|
|
43
|
+
"signing_private_key": "...",
|
|
44
|
+
"enclave_id": "...",
|
|
45
|
+
"api_base": "https://connect-dev.agentvault.chat"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The daemon warns to stderr if the file permissions are looser than `0600`. It does not block startup, but you should tighten it.
|
|
50
|
+
|
|
51
|
+
### 2. Start the daemon
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @agentvault/connect-agent-bridge --credential ./agent.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The daemon publishes its key package to SecureComs, waits to be admitted to channels by the owner's channel watcher, and then starts serving. Startup output on stderr looks like:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[bridge] listening on http://127.0.0.1:7077 (agent WS at ws://127.0.0.1:7077/v1/ws)
|
|
61
|
+
BRIDGE_TOKEN=<random-base64url-token>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The token is also persisted at `<credential-dir>/bridge-token` (mode `0600`) so you can read it back on restart without grepping stderr. Override the path with `--token-file`.
|
|
65
|
+
|
|
66
|
+
Save the token:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export T=$(cat $(dirname ./agent.json)/bridge-token)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or capture it from the boot line:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx @agentvault/connect-agent-bridge --credential ./agent.json 2>&1 \
|
|
76
|
+
| tee /tmp/bridge.log &
|
|
77
|
+
export T=$(grep '^BRIDGE_TOKEN=' /tmp/bridge.log | cut -d= -f2-)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3. Try it with curl
|
|
81
|
+
|
|
82
|
+
Use `127.0.0.1`, not `localhost` — the daemon binds the IPv4 loopback by default.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# Check health (no auth required)
|
|
86
|
+
curl http://127.0.0.1:7077/healthz
|
|
87
|
+
|
|
88
|
+
# List channels
|
|
89
|
+
curl -H "Authorization: Bearer $T" http://127.0.0.1:7077/v1/channels
|
|
90
|
+
|
|
91
|
+
# Send a message
|
|
92
|
+
curl -X POST http://127.0.0.1:7077/v1/send \
|
|
93
|
+
-H "Authorization: Bearer $T" \
|
|
94
|
+
-H "Content-Type: application/json" \
|
|
95
|
+
-d '{"channel_id":"<id>","text":"Hello from the bridge"}'
|
|
96
|
+
|
|
97
|
+
# Poll for new messages (long-poll up to 30s)
|
|
98
|
+
curl -H "Authorization: Bearer $T" \
|
|
99
|
+
"http://127.0.0.1:7077/v1/messages?channel_id=<id>&wait=30"
|
|
100
|
+
|
|
101
|
+
# Stream messages over SSE
|
|
102
|
+
curl -H "Authorization: Bearer $T" \
|
|
103
|
+
"http://127.0.0.1:7077/v1/events?channel_id=<id>"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 4. Call it from code
|
|
107
|
+
|
|
108
|
+
**Python (requests)**
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import requests, time
|
|
112
|
+
|
|
113
|
+
BASE = "http://127.0.0.1:7077"
|
|
114
|
+
TOKEN = open("/path/to/credential-dir/bridge-token").read().strip()
|
|
115
|
+
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
|
|
116
|
+
CHANNEL = "<channel-id>"
|
|
117
|
+
|
|
118
|
+
# Send a message
|
|
119
|
+
resp = requests.post(f"{BASE}/v1/send", headers=HEADERS, json={
|
|
120
|
+
"channel_id": CHANNEL,
|
|
121
|
+
"text": "Hello from Python",
|
|
122
|
+
})
|
|
123
|
+
print(resp.json()) # {"message_id": "...", "status": "sent"}
|
|
124
|
+
|
|
125
|
+
# Receive loop (long-poll)
|
|
126
|
+
while True:
|
|
127
|
+
r = requests.get(f"{BASE}/v1/messages",
|
|
128
|
+
headers=HEADERS,
|
|
129
|
+
params={"channel_id": CHANNEL, "wait": 50})
|
|
130
|
+
for msg in r.json():
|
|
131
|
+
print(msg["sender_id"], ":", msg["text"])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Node.js (fetch)**
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
const BASE = "http://127.0.0.1:7077";
|
|
138
|
+
const TOKEN = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
|
|
139
|
+
const headers = { Authorization: `Bearer ${TOKEN}` };
|
|
140
|
+
|
|
141
|
+
// Send
|
|
142
|
+
const r = await fetch(`${BASE}/v1/send`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
145
|
+
body: JSON.stringify({ channel_id: "<id>", text: "Hello from Node" }),
|
|
146
|
+
});
|
|
147
|
+
console.log(await r.json()); // { message_id, status: "sent" }
|
|
148
|
+
|
|
149
|
+
// SSE receive — listen on the named `message` event
|
|
150
|
+
const es = new EventSource(`${BASE}/v1/events?channel_id=<id>`, {
|
|
151
|
+
headers,
|
|
152
|
+
});
|
|
153
|
+
es.addEventListener("message", (e) => {
|
|
154
|
+
console.log(JSON.parse(e.data));
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## HTTP API reference
|
|
161
|
+
|
|
162
|
+
All routes except `/healthz` require `Authorization: Bearer <BRIDGE_TOKEN>`.
|
|
163
|
+
|
|
164
|
+
| Method | Path | Request | Response |
|
|
165
|
+
|--------|------|---------|----------|
|
|
166
|
+
| `GET` | `/healthz` | — | `{status, identity_id, joined_channels, buffered, uptime_s, version}` |
|
|
167
|
+
| `GET` | `/v1/channels` | — | `[{channel_id, name, is_dm, joined}]` |
|
|
168
|
+
| `POST` | `/v1/send` | `{channel_id, text, idempotency_key?}` | `{message_id, status:"sent"}` |
|
|
169
|
+
| `GET` | `/v1/messages` | query: `channel_id`, `wait` (0–50) | `[{channel_id, message_id, sender_id, text, ts}]` |
|
|
170
|
+
| `GET` | `/v1/events` | query: `channel_id` | SSE stream |
|
|
171
|
+
| `POST` | `/v1/status` | `{channel_id, state, detail?}` | `{ok:true}` |
|
|
172
|
+
| `GET` (Upgrade) | `/v1/ws` | query: `token` or `Authorization` header | WebSocket — see [WebSocket](#websocket-recommended) |
|
|
173
|
+
|
|
174
|
+
The agent's capabilities, including the receive modes the daemon supports, are advertised on `/healthz` under `capabilities` (e.g. `receive_modes: ["longpoll","sse","websocket"]`).
|
|
175
|
+
|
|
176
|
+
**`POST /v1/send` — 503 channel_pending**
|
|
177
|
+
|
|
178
|
+
If the daemon hasn't been admitted to the named channel yet (the owner's watcher hasn't processed the key package), the response is:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
HTTP 503
|
|
182
|
+
{"error": "channel_pending", "detail": "awaiting admission"}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Retry with exponential back-off. The channel becomes available once the owner's client adds the agent device to the MLS group.
|
|
186
|
+
|
|
187
|
+
**`POST /v1/status` — state values**
|
|
188
|
+
|
|
189
|
+
`state` must be one of `idle`, `working`, `stalled`, or `error`. `detail` is an optional free-form string.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Receive modes
|
|
194
|
+
|
|
195
|
+
Three ways to receive, in order of preference for a long-running agent: the WebSocket (one socket does everything), SSE (one-way real-time push), and long-poll (a plain loop). Use one receive mode at a time per daemon.
|
|
196
|
+
|
|
197
|
+
### WebSocket (recommended)
|
|
198
|
+
|
|
199
|
+
`GET /v1/ws` upgrades to a single persistent bidirectional socket. The agent receives live messages and sends/reports status over the same connection — no polling, no separate send channel. This is the interface to use for an always-on agent.
|
|
200
|
+
|
|
201
|
+
Authenticate with the bearer token either as a query param (`ws://127.0.0.1:7077/v1/ws?token=<BRIDGE_TOKEN>`) or an `Authorization: Bearer` header on the upgrade request. The same loopback/Origin/Host rules as the HTTP API apply; an unauthorized upgrade is accepted and then closed with code `1008`.
|
|
202
|
+
|
|
203
|
+
All frames are JSON text.
|
|
204
|
+
|
|
205
|
+
**Daemon → agent**
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
{ "type":"message", "channel_id":"...", "message_id":"...", "sender_id":"...", "text":"...", "ts": 1234567890 }
|
|
209
|
+
{ "type":"sent", "message_id":"..." } // acks your send
|
|
210
|
+
{ "type":"ok" } // acks your status report
|
|
211
|
+
{ "type":"error", "error":"channel_pending"|"invalid_request", "message":"...", "detail":"..." }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Agent → daemon**
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
{ "type":"send", "channel_id":"...", "text":"...", "idempotency_key":"..." } // idempotency_key optional
|
|
218
|
+
{ "type":"status", "channel_id":"...", "state":"idle"|"working"|"stalled"|"error", "detail":"..." }
|
|
219
|
+
{ "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Acknowledge what you receive.** Inbound delivery is **at-least-once**. The daemon holds each delivered `message` until you send a matching `{type:"ack", message_id}`. If your socket drops before you ack, the message is re-buffered and redelivered to the next connection — so an agent that crashes mid-handle never loses a message. The cost is that you may occasionally see a message twice; dedup by `message_id`. (Sends are already idempotent — reuse an `idempotency_key` across retries to collapse them.) Ack as soon as you've durably handled a message, not before.
|
|
223
|
+
|
|
224
|
+
**One consumer at a time.** The daemon serves a single agent per process. Opening a second `/v1/ws` connection closes the first (newest wins, code `1000`); the replaced socket's un-acked messages are re-buffered for the new one. Don't run two consumers against one daemon expecting to share the stream.
|
|
225
|
+
|
|
226
|
+
**Keepalive.** The daemon pings every 30 seconds and reaps a socket that misses a pong. Clients using the standard WebSocket API answer pings automatically.
|
|
227
|
+
|
|
228
|
+
Runnable clients are in [`examples/`](./examples): `ws-agent-client.mjs` (Node) and `ws-agent-client.py` (Python, `pip install websockets`). Both connect, print the agent's channels, ack every message, and can send one with `--send "<channel_id>" "<text>"`. A minimal receive-and-ack loop in Node:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
const token = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
|
|
232
|
+
const ws = new WebSocket(`ws://127.0.0.1:7077/v1/ws?token=${encodeURIComponent(token)}`);
|
|
233
|
+
|
|
234
|
+
ws.addEventListener("message", (ev) => {
|
|
235
|
+
const frame = JSON.parse(ev.data);
|
|
236
|
+
if (frame.type === "message") {
|
|
237
|
+
handle(frame); // your work
|
|
238
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.message_id }));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// send any time over the same socket
|
|
243
|
+
ws.send(JSON.stringify({ type: "send", channel_id: "<id>", text: "Hello from the agent" }));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
And in Python (`websockets`):
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
import asyncio, json, websockets
|
|
250
|
+
|
|
251
|
+
async def main():
|
|
252
|
+
token = open("/path/to/credential-dir/bridge-token").read().strip()
|
|
253
|
+
async with websockets.connect(f"ws://127.0.0.1:7077/v1/ws?token={token}") as ws:
|
|
254
|
+
await ws.send(json.dumps({"type": "send", "channel_id": "<id>", "text": "Hello"}))
|
|
255
|
+
async for raw in ws:
|
|
256
|
+
frame = json.loads(raw)
|
|
257
|
+
if frame.get("type") == "message":
|
|
258
|
+
handle(frame)
|
|
259
|
+
await ws.send(json.dumps({"type": "ack", "message_id": frame["message_id"]}))
|
|
260
|
+
|
|
261
|
+
asyncio.run(main())
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### SSE (real-time)
|
|
265
|
+
|
|
266
|
+
`GET /v1/events?channel_id=<id>` opens a persistent SSE connection. Messages arrive as named `message` events:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
event: message
|
|
270
|
+
data: {"channel_id":"...","message_id":"...","sender_id":"...","text":"...","ts":1234567890}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The daemon sends a `:keepalive` comment every 15 seconds to keep the connection alive through proxies. Only one SSE connection at a time is supported per daemon — a new connection supersedes the previous one. The old stream is closed cleanly.
|
|
274
|
+
|
|
275
|
+
An initial `:connected` comment is sent when the connection is established, before any messages.
|
|
276
|
+
|
|
277
|
+
### Long-poll (simple loop)
|
|
278
|
+
|
|
279
|
+
`GET /v1/messages?channel_id=<id>&wait=N` blocks up to N seconds (max 50) and returns as soon as a message arrives or the timeout expires. With `wait=0` it returns immediately with whatever is buffered.
|
|
280
|
+
|
|
281
|
+
A simple receive loop:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
while True:
|
|
285
|
+
msgs = requests.get(f"{BASE}/v1/messages",
|
|
286
|
+
headers=HEADERS,
|
|
287
|
+
params={"channel_id": CHANNEL, "wait": 50}).json()
|
|
288
|
+
for m in msgs:
|
|
289
|
+
handle(m)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Pick one receive mode — WebSocket, SSE, or long-poll — and stick with it. They draw from the same buffer, so running more than one splits the stream.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Run as a service
|
|
297
|
+
|
|
298
|
+
For an autonomous agent running on a server, keep the daemon up with systemd so it restarts on failure and starts on boot. A sleeping or crashed daemon means its WebSocket/SSE connections drop and messages queue on the server until the daemon reconnects; they replay losslessly on wake. The daemon can't wake a sleeping machine.
|
|
299
|
+
|
|
300
|
+
**systemd unit** (`/etc/systemd/system/securecoms-bridge.service`):
|
|
301
|
+
|
|
302
|
+
```ini
|
|
303
|
+
[Unit]
|
|
304
|
+
Description=SecureComs Agent Bridge
|
|
305
|
+
After=network.target
|
|
306
|
+
|
|
307
|
+
[Service]
|
|
308
|
+
Type=simple
|
|
309
|
+
User=agentuser
|
|
310
|
+
WorkingDirectory=/home/agentuser
|
|
311
|
+
ExecStart=/usr/bin/npx @agentvault/connect-agent-bridge \
|
|
312
|
+
--credential /home/agentuser/agent.json \
|
|
313
|
+
--store /home/agentuser/bridge-state.db
|
|
314
|
+
Restart=always
|
|
315
|
+
RestartSec=5
|
|
316
|
+
StandardOutput=journal
|
|
317
|
+
StandardError=journal
|
|
318
|
+
|
|
319
|
+
[Install]
|
|
320
|
+
WantedBy=multi-user.target
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
sudo systemctl daemon-reload
|
|
325
|
+
sudo systemctl enable --now securecoms-bridge
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Read the token after the first boot:
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
cat /home/agentuser/bridge-token
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**pm2 (for dev or non-root servers)**
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
|
|
338
|
+
--name securecoms-bridge
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Security
|
|
344
|
+
|
|
345
|
+
**Loopback-only by default.** The daemon binds `127.0.0.1:7077`. Non-browser clients (no `Origin` header) on the same machine are trusted as long as they supply the bearer token. Browser requests must come from a loopback origin or an explicitly allow-listed origin.
|
|
346
|
+
|
|
347
|
+
**Bearer token.** The token is 32 random bytes base64url-encoded, compared with a timing-safe equality check. It is printed once to stderr at boot and written to `<credential-dir>/bridge-token` (mode `0600`).
|
|
348
|
+
|
|
349
|
+
**DNS rebind protection.** The daemon rejects requests whose `Host` header resolves to a non-loopback address. This blocks the standard DNS rebind attack against loopback services.
|
|
350
|
+
|
|
351
|
+
**Origin allow-list.** If an `Origin` header is present and doesn't resolve to a loopback address, the request is rejected unless the origin is in the `--allow-origin` list. Absent `Origin` (non-browser clients) is always allowed.
|
|
352
|
+
|
|
353
|
+
**Cross-host use.** The daemon refuses to bind a non-loopback address without TLS:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
connect-agent-bridge --credential ./agent.json \
|
|
357
|
+
--bind 0.0.0.0 \
|
|
358
|
+
--tls-cert ./cert.pem \
|
|
359
|
+
--tls-key ./key.pem
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The loopback hop carries plaintext by design — the agent is the endpoint and the trust boundary is the machine. The SecureComs server still sees ciphertext only.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Flags and environment
|
|
367
|
+
|
|
368
|
+
| Flag | Env var | Default | Description |
|
|
369
|
+
|------|---------|---------|-------------|
|
|
370
|
+
| `--credential <path>` | `SECURECOMS_CREDENTIAL` | required | Path to the agent credential JSON file |
|
|
371
|
+
| `--store <path>` | `SECURECOMS_STORE` | `<credential-dir>/bridge-state.db` | Path to the SQLite MLS state database |
|
|
372
|
+
| `--port <n>` | `SECURECOMS_PORT` | `7077` | TCP port to listen on |
|
|
373
|
+
| `--bind <addr>` | `SECURECOMS_BIND` | `127.0.0.1` | Address to bind (non-loopback requires TLS) |
|
|
374
|
+
| `--token-file <path>` | `SECURECOMS_TOKEN_FILE` | `<credential-dir>/bridge-token` | Path to read/write the bearer token |
|
|
375
|
+
| `--token <value>` | `SECURECOMS_LOCAL_TOKEN` | — | Provide a fixed bearer token directly (overrides token-file) |
|
|
376
|
+
| `--tls-cert <path>` | `SECURECOMS_TLS_CERT` | — | TLS certificate (PEM); required for non-loopback bind |
|
|
377
|
+
| `--tls-key <path>` | `SECURECOMS_TLS_KEY` | — | TLS private key (PEM); required for non-loopback bind |
|
|
378
|
+
| `--allow-origin <origin>` | — | — | Add an allowed Origin (repeatable; for browser clients on non-loopback origins) |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type ArtifactShareBody = {
|
|
2
|
+
v: 1;
|
|
3
|
+
type: "artifact_share";
|
|
4
|
+
name: string;
|
|
5
|
+
mime: string;
|
|
6
|
+
size: number;
|
|
7
|
+
blobId: string;
|
|
8
|
+
fileKey: string;
|
|
9
|
+
fileNonce: string;
|
|
10
|
+
digest: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
};
|
|
13
|
+
export type AttachmentMeta = {
|
|
14
|
+
filename: string;
|
|
15
|
+
mime: string;
|
|
16
|
+
size: number;
|
|
17
|
+
path: string;
|
|
18
|
+
sha256: string;
|
|
19
|
+
};
|
|
20
|
+
export interface ReceiveDeps {
|
|
21
|
+
downloadBlob(blobId: string): Promise<Uint8Array>;
|
|
22
|
+
decryptFile(enc: Uint8Array, key: Uint8Array, nonce: Uint8Array): Uint8Array;
|
|
23
|
+
computeFileDigest(b: Uint8Array): string;
|
|
24
|
+
base64ToBytes(s: string): Uint8Array;
|
|
25
|
+
mkdirp(dir: string): Promise<void>;
|
|
26
|
+
writeFile(p: string, data: Uint8Array): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** Reduce any name to a safe basename inside the target dir. Strips path
|
|
29
|
+
* separators and `..`; falls back when nothing usable remains. */
|
|
30
|
+
export declare function sanitizeFilename(name: string, fallback: string): string;
|
|
31
|
+
export declare function mimeForName(name: string): string;
|
|
32
|
+
export interface SendDeps {
|
|
33
|
+
uploadBlob(ciphertext: Uint8Array, channelId: string): Promise<string>;
|
|
34
|
+
encryptFile(b: Uint8Array): {
|
|
35
|
+
encryptedData: Uint8Array;
|
|
36
|
+
fileKey: Uint8Array;
|
|
37
|
+
fileNonce: Uint8Array;
|
|
38
|
+
digest: string;
|
|
39
|
+
};
|
|
40
|
+
bytesToBase64(b: Uint8Array): string;
|
|
41
|
+
readFile(p: string): Promise<Uint8Array>;
|
|
42
|
+
}
|
|
43
|
+
export declare function prepareAttachment(filePath: string, caption: string | undefined, channelId: string, deps: SendDeps): Promise<ArtifactShareBody>;
|
|
44
|
+
export declare function materializeAttachment(body: ArtifactShareBody, ctx: {
|
|
45
|
+
attachmentsDir: string;
|
|
46
|
+
channelId: string;
|
|
47
|
+
messageId: string;
|
|
48
|
+
}, deps: ReceiveDeps): Promise<AttachmentMeta>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
/** Reduce any name to a safe basename inside the target dir. Strips path
|
|
3
|
+
* separators and `..`; falls back when nothing usable remains. */
|
|
4
|
+
export function sanitizeFilename(name, fallback) {
|
|
5
|
+
const base = basename(name ?? "");
|
|
6
|
+
const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
|
|
7
|
+
return cleaned.length > 0 ? cleaned : fallback;
|
|
8
|
+
}
|
|
9
|
+
const MIME_BY_EXT = {
|
|
10
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
|
|
11
|
+
webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
|
|
12
|
+
txt: "text/plain", md: "text/markdown", json: "application/json",
|
|
13
|
+
csv: "text/csv", html: "text/html", mp4: "video/mp4", mp3: "audio/mpeg",
|
|
14
|
+
zip: "application/zip", wav: "audio/wav",
|
|
15
|
+
};
|
|
16
|
+
export function mimeForName(name) {
|
|
17
|
+
const dot = name.lastIndexOf(".");
|
|
18
|
+
if (dot < 0 || dot === name.length - 1)
|
|
19
|
+
return "application/octet-stream";
|
|
20
|
+
return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
|
|
21
|
+
}
|
|
22
|
+
export async function prepareAttachment(filePath, caption, channelId, deps) {
|
|
23
|
+
const bytes = await deps.readFile(filePath);
|
|
24
|
+
const name = basename(filePath);
|
|
25
|
+
const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
|
|
26
|
+
const blobId = await deps.uploadBlob(encryptedData, channelId);
|
|
27
|
+
const body = {
|
|
28
|
+
v: 1, type: "artifact_share", name, mime: mimeForName(name), size: bytes.length,
|
|
29
|
+
blobId, fileKey: deps.bytesToBase64(fileKey), fileNonce: deps.bytesToBase64(fileNonce), digest,
|
|
30
|
+
};
|
|
31
|
+
if (caption)
|
|
32
|
+
body.text = caption;
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
export async function materializeAttachment(body, ctx, deps) {
|
|
36
|
+
const ciphertext = await deps.downloadBlob(body.blobId);
|
|
37
|
+
// Verify digest on ciphertext BEFORE decrypting — tampered blobs fail here.
|
|
38
|
+
const sha256 = deps.computeFileDigest(ciphertext);
|
|
39
|
+
if (sha256 !== body.digest) {
|
|
40
|
+
throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
|
|
41
|
+
}
|
|
42
|
+
const plain = deps.decryptFile(ciphertext, deps.base64ToBytes(body.fileKey), deps.base64ToBytes(body.fileNonce));
|
|
43
|
+
const safeName = sanitizeFilename(body.name, ctx.messageId);
|
|
44
|
+
const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
|
|
45
|
+
const path = `${dir}/${ctx.messageId}-${safeName}`;
|
|
46
|
+
await deps.mkdirp(dir);
|
|
47
|
+
await deps.writeFile(path, plain);
|
|
48
|
+
return { filename: safeName, mime: body.mime, size: plain.length, path, sha256 };
|
|
49
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function resolveToken(opts: {
|
|
2
|
+
tokenFile?: string;
|
|
3
|
+
token?: string;
|
|
4
|
+
}): string;
|
|
5
|
+
export declare function tokenMatches(provided: string | undefined, expected: string): boolean;
|
|
6
|
+
export declare function bearerFrom(header: string | undefined): string | undefined;
|
|
7
|
+
export declare function originHostAllowed(headers: {
|
|
8
|
+
origin?: string;
|
|
9
|
+
host?: string;
|
|
10
|
+
}, allowOrigins: string[]): boolean;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isLoopback } from "./config.js";
|
|
4
|
+
export function resolveToken(opts) {
|
|
5
|
+
if (opts.token)
|
|
6
|
+
return opts.token;
|
|
7
|
+
const f = opts.tokenFile;
|
|
8
|
+
if (f && existsSync(f))
|
|
9
|
+
return readFileSync(f, "utf8").trim();
|
|
10
|
+
const tok = randomBytes(32).toString("base64url");
|
|
11
|
+
if (f)
|
|
12
|
+
writeFileSync(f, tok, { mode: 0o600 });
|
|
13
|
+
return tok;
|
|
14
|
+
}
|
|
15
|
+
export function tokenMatches(provided, expected) {
|
|
16
|
+
if (!provided)
|
|
17
|
+
return false;
|
|
18
|
+
const a = Buffer.from(provided);
|
|
19
|
+
const b = Buffer.from(expected);
|
|
20
|
+
if (a.length !== b.length)
|
|
21
|
+
return false;
|
|
22
|
+
return timingSafeEqual(a, b);
|
|
23
|
+
}
|
|
24
|
+
export function bearerFrom(header) {
|
|
25
|
+
if (!header)
|
|
26
|
+
return undefined;
|
|
27
|
+
const m = /^Bearer (.+)$/i.exec(header.trim());
|
|
28
|
+
return m ? m[1] : undefined;
|
|
29
|
+
}
|
|
30
|
+
function hostnameOf(hostHeader) {
|
|
31
|
+
if (!hostHeader)
|
|
32
|
+
return undefined;
|
|
33
|
+
// Strip an optional :port (but keep [::1] intact).
|
|
34
|
+
if (hostHeader.startsWith("["))
|
|
35
|
+
return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
|
|
36
|
+
const i = hostHeader.lastIndexOf(":");
|
|
37
|
+
return i > 0 ? hostHeader.slice(0, i) : hostHeader;
|
|
38
|
+
}
|
|
39
|
+
export function originHostAllowed(headers, allowOrigins) {
|
|
40
|
+
// Host must be a loopback name (blocks the DNS-rebind target Host: evil.com).
|
|
41
|
+
const host = hostnameOf(headers.host);
|
|
42
|
+
if (host && !isLoopback(host) && !isLoopback(`[${host}]`))
|
|
43
|
+
return false;
|
|
44
|
+
// Origin: absent → allow (non-browser). Present → must be loopback or allow-listed.
|
|
45
|
+
const origin = headers.origin;
|
|
46
|
+
if (!origin)
|
|
47
|
+
return true;
|
|
48
|
+
if (allowOrigins.includes(origin))
|
|
49
|
+
return true;
|
|
50
|
+
try {
|
|
51
|
+
return isLoopback(new URL(origin).hostname);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/bin.d.ts
ADDED