@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -1,381 +0,0 @@
|
|
|
1
|
-
import { buildWsUpstreamUrl } from "@vellumai/assistant-client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
validateEdgeToken,
|
|
5
|
-
mintServiceToken,
|
|
6
|
-
} from "../../auth/token-exchange.js";
|
|
7
|
-
import { parseSub } from "../../auth/subject.js";
|
|
8
|
-
import type { GatewayConfig } from "../../config.js";
|
|
9
|
-
import { getLogger } from "../../logger.js";
|
|
10
|
-
import { isLoopbackAddress } from "../../util/is-loopback-address.js";
|
|
11
|
-
|
|
12
|
-
const log = getLogger("browser-relay-ws");
|
|
13
|
-
|
|
14
|
-
// Cap buffered messages to prevent unbounded memory growth if upstream stalls
|
|
15
|
-
const MAX_PENDING_MESSAGES = 100;
|
|
16
|
-
|
|
17
|
-
function isPrivateAddress(addr: string): boolean {
|
|
18
|
-
const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
19
|
-
const normalized = v4Mapped ? v4Mapped[1] : addr;
|
|
20
|
-
|
|
21
|
-
if (normalized.includes(".")) {
|
|
22
|
-
const parts = normalized.split(".").map(Number);
|
|
23
|
-
if (
|
|
24
|
-
parts.length !== 4 ||
|
|
25
|
-
parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
|
|
26
|
-
)
|
|
27
|
-
return false;
|
|
28
|
-
|
|
29
|
-
if (parts[0] === 127) return true;
|
|
30
|
-
if (parts[0] === 10) return true;
|
|
31
|
-
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
32
|
-
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
33
|
-
if (parts[0] === 169 && parts[1] === 254) return true;
|
|
34
|
-
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const lower = normalized.toLowerCase();
|
|
39
|
-
if (lower === "::1") return true;
|
|
40
|
-
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
41
|
-
if (lower.startsWith("fe80")) return true;
|
|
42
|
-
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isPrivateNetworkPeer(
|
|
47
|
-
server: import("bun").Server<unknown>,
|
|
48
|
-
req: Request,
|
|
49
|
-
): boolean {
|
|
50
|
-
const ip = server.requestIP(req);
|
|
51
|
-
if (!ip) return false;
|
|
52
|
-
return isPrivateAddress(ip.address);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function isLoopbackPeer(
|
|
56
|
-
server: import("bun").Server<unknown>,
|
|
57
|
-
req: Request,
|
|
58
|
-
opts?: { trustProxy?: boolean },
|
|
59
|
-
): boolean {
|
|
60
|
-
if (opts?.trustProxy) {
|
|
61
|
-
const forwarded = req.headers.get("x-forwarded-for");
|
|
62
|
-
if (forwarded) {
|
|
63
|
-
const first = forwarded.split(",")[0]?.trim();
|
|
64
|
-
if (!first) return false;
|
|
65
|
-
return isLoopbackAddress(first);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const peer = server.requestIP(req);
|
|
70
|
-
if (!peer) return false;
|
|
71
|
-
return isLoopbackAddress(peer.address);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Resolved auth context carried into the upstream WS open handler.
|
|
76
|
-
*
|
|
77
|
-
* `guardianId` is populated whenever the downstream edge token carries an
|
|
78
|
-
* actor principal (i.e. the sub is `actor:<assistantId>:<actorPrincipalId>`).
|
|
79
|
-
* Service tokens — which intentionally do not carry a guardian — will have
|
|
80
|
-
* `guardianId === undefined` and are expected to be rejected at upstream
|
|
81
|
-
* upgrade time unless an alternate guardian-plumbing path is wired up.
|
|
82
|
-
*
|
|
83
|
-
* `clientInstanceId` is a stable per-extension-install identifier lifted
|
|
84
|
-
* off the downstream handshake and forwarded to the runtime verbatim so
|
|
85
|
-
* the multi-instance registry can key connections by (guardianId,
|
|
86
|
-
* clientInstanceId). Without this forwarding, cloud reconnects from the
|
|
87
|
-
* same extension install would each land in a fresh
|
|
88
|
-
* `legacy:<connectionId>` slot and fail to supersede the prior socket.
|
|
89
|
-
*/
|
|
90
|
-
export interface BrowserRelayAuthContext {
|
|
91
|
-
guardianId?: string;
|
|
92
|
-
/**
|
|
93
|
-
* Per-install identifier sourced from the downstream handshake — either
|
|
94
|
-
* the `clientInstanceId` query param (primary path, set by the Chrome
|
|
95
|
-
* extension via `buildUrl`) or the `x-client-instance-id` header.
|
|
96
|
-
* Propagated verbatim to the upstream runtime URL.
|
|
97
|
-
*/
|
|
98
|
-
clientInstanceId?: string;
|
|
99
|
-
/** True when auth was checked and succeeded. */
|
|
100
|
-
authenticated: boolean;
|
|
101
|
-
/** True when runtime proxy auth is globally disabled (dev bypass). */
|
|
102
|
-
authBypassed: boolean;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export type BrowserRelaySocketData = {
|
|
106
|
-
wsType: "browser-relay";
|
|
107
|
-
config: GatewayConfig;
|
|
108
|
-
auth: BrowserRelayAuthContext;
|
|
109
|
-
upstream?: WebSocket;
|
|
110
|
-
pendingMessages?: (string | ArrayBuffer | Uint8Array)[];
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Create a WebSocket upgrade handler that proxies browser-relay frames between
|
|
115
|
-
* the local Chrome extension and the runtime's /v1/browser-relay endpoint.
|
|
116
|
-
*/
|
|
117
|
-
export function createBrowserRelayWebsocketHandler(config: GatewayConfig) {
|
|
118
|
-
return function handleUpgrade(
|
|
119
|
-
req: Request,
|
|
120
|
-
server: import("bun").Server<unknown>,
|
|
121
|
-
): Response | undefined {
|
|
122
|
-
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
123
|
-
return new Response("Upgrade Required", { status: 426 });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const url = new URL(req.url);
|
|
127
|
-
|
|
128
|
-
// Trust actual peer IP, not request host headers, for local/private gating.
|
|
129
|
-
if (!isPrivateNetworkPeer(server, req)) {
|
|
130
|
-
return new Response(
|
|
131
|
-
"Browser relay only accepts connections from localhost",
|
|
132
|
-
{ status: 403 },
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const authResult = checkBrowserRelayAuth(req, url, config);
|
|
137
|
-
if (!authResult.ok) return authResult.response;
|
|
138
|
-
|
|
139
|
-
const upgraded = server.upgrade(req, {
|
|
140
|
-
data: {
|
|
141
|
-
wsType: "browser-relay",
|
|
142
|
-
config,
|
|
143
|
-
auth: authResult.context,
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
if (!upgraded) {
|
|
148
|
-
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Return undefined to indicate upgrade was handled
|
|
152
|
-
return undefined;
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
type AuthCheckResult =
|
|
157
|
-
| { ok: true; context: BrowserRelayAuthContext }
|
|
158
|
-
| { ok: false; response: Response };
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Parse the downstream edge token and return a structured auth context.
|
|
162
|
-
*
|
|
163
|
-
* The gateway accepts the token via `Authorization: Bearer <jwt>` or a
|
|
164
|
-
* `?token=` query parameter (browser WebSocket upgrades cannot set custom
|
|
165
|
-
* headers, so the query param is the primary mechanism for the Chrome
|
|
166
|
-
* extension). Tokens must carry an actor principal in `sub`; service
|
|
167
|
-
* subjects (`svc:*:*`) are rejected on this route. The extracted
|
|
168
|
-
* `actorPrincipalId` is propagated forward as the guardian identity so
|
|
169
|
-
* the runtime can register the connection under the correct guardian.
|
|
170
|
-
*/
|
|
171
|
-
export function checkBrowserRelayAuth(
|
|
172
|
-
req: Request,
|
|
173
|
-
url: URL,
|
|
174
|
-
config: GatewayConfig,
|
|
175
|
-
): AuthCheckResult {
|
|
176
|
-
// Lift the per-install identifier off the downstream handshake so we
|
|
177
|
-
// can propagate it to the runtime regardless of the auth path below.
|
|
178
|
-
// The Chrome extension sets it as a query param via `buildUrl`; we
|
|
179
|
-
// also accept the `x-client-instance-id` header for parity with the
|
|
180
|
-
// runtime's own accepted forms. Empty strings are treated as absent
|
|
181
|
-
// so sparse clients don't all collapse into the same key.
|
|
182
|
-
const clientInstanceId = extractClientInstanceId(req, url);
|
|
183
|
-
|
|
184
|
-
if (!config.runtimeProxyRequireAuth) {
|
|
185
|
-
return {
|
|
186
|
-
ok: true,
|
|
187
|
-
context: {
|
|
188
|
-
authenticated: false,
|
|
189
|
-
authBypassed: true,
|
|
190
|
-
clientInstanceId,
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const authHeader = req.headers.get("authorization");
|
|
196
|
-
const queryToken = url.searchParams.get("token");
|
|
197
|
-
const rawToken = authHeader
|
|
198
|
-
? authHeader.toLowerCase().startsWith("bearer ")
|
|
199
|
-
? authHeader.slice(7)
|
|
200
|
-
: null
|
|
201
|
-
: queryToken;
|
|
202
|
-
|
|
203
|
-
if (!rawToken) {
|
|
204
|
-
log.warn("Browser relay WS: no token provided");
|
|
205
|
-
return {
|
|
206
|
-
ok: false,
|
|
207
|
-
response: new Response("Unauthorized", { status: 401 }),
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const result = validateEdgeToken(rawToken);
|
|
212
|
-
if (!result.ok) {
|
|
213
|
-
log.warn(
|
|
214
|
-
{ reason: result.reason },
|
|
215
|
-
"Browser relay WS: authentication failed",
|
|
216
|
-
);
|
|
217
|
-
return {
|
|
218
|
-
ok: false,
|
|
219
|
-
response: new Response("Unauthorized", { status: 401 }),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const parsed = parseSub(result.claims.sub);
|
|
224
|
-
if (
|
|
225
|
-
!parsed.ok ||
|
|
226
|
-
parsed.principalType !== "actor" ||
|
|
227
|
-
!parsed.actorPrincipalId
|
|
228
|
-
) {
|
|
229
|
-
// Fail closed: browser-relay sockets must be guardian-scoped.
|
|
230
|
-
// Service tokens (`svc:*:*`) do not carry an actor principal and are
|
|
231
|
-
// therefore rejected on this path.
|
|
232
|
-
log.warn(
|
|
233
|
-
{
|
|
234
|
-
reason: parsed.ok ? "missing_actor_principal" : parsed.reason,
|
|
235
|
-
sub: result.claims.sub,
|
|
236
|
-
},
|
|
237
|
-
"Browser relay WS: denied token without actor principal",
|
|
238
|
-
);
|
|
239
|
-
return {
|
|
240
|
-
ok: false,
|
|
241
|
-
response: new Response("Unauthorized", { status: 401 }),
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
ok: true,
|
|
247
|
-
context: {
|
|
248
|
-
authenticated: true,
|
|
249
|
-
authBypassed: false,
|
|
250
|
-
guardianId: parsed.actorPrincipalId,
|
|
251
|
-
clientInstanceId,
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Extract the per-extension-install identifier from a downstream
|
|
258
|
-
* browser-relay WS handshake. Query param is the primary path (the
|
|
259
|
-
* Chrome extension sets it via `buildUrl` because WebSocket upgrades
|
|
260
|
-
* cannot set custom headers from the browser); the header form is
|
|
261
|
-
* accepted for completeness so non-browser clients have a consistent
|
|
262
|
-
* contract. Empty strings are normalized to `undefined`.
|
|
263
|
-
*/
|
|
264
|
-
function extractClientInstanceId(req: Request, url: URL): string | undefined {
|
|
265
|
-
const rawHeader = req.headers.get("x-client-instance-id")?.trim();
|
|
266
|
-
const rawQuery = url.searchParams.get("clientInstanceId")?.trim();
|
|
267
|
-
const picked = (rawHeader ?? "") || (rawQuery ?? "") || undefined;
|
|
268
|
-
return picked;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* WebSocket handler config for Bun.serve() that proxies frames to runtime.
|
|
273
|
-
*/
|
|
274
|
-
export function getBrowserRelayWebsocketHandlers() {
|
|
275
|
-
return {
|
|
276
|
-
open(ws: import("bun").ServerWebSocket<BrowserRelaySocketData>) {
|
|
277
|
-
const { config, auth } = ws.data;
|
|
278
|
-
|
|
279
|
-
// Initialize message buffer for frames arriving before upstream connects
|
|
280
|
-
ws.data.pendingMessages = [];
|
|
281
|
-
|
|
282
|
-
// Build upstream URL with service-token auth and optional guardian/
|
|
283
|
-
// client-instance identifiers. The per-install identifier is forwarded
|
|
284
|
-
// so the runtime's multi-instance registry can key connections by
|
|
285
|
-
// (guardianId, clientInstanceId). Without this, cloud reconnects from
|
|
286
|
-
// the same extension install would each fall through to a fresh
|
|
287
|
-
// `legacy:<connectionId>` key and fail to supersede the prior socket.
|
|
288
|
-
const extraParams: Record<string, string> = {};
|
|
289
|
-
if (auth.guardianId) {
|
|
290
|
-
extraParams.guardianId = auth.guardianId;
|
|
291
|
-
}
|
|
292
|
-
if (auth.clientInstanceId) {
|
|
293
|
-
extraParams.clientInstanceId = auth.clientInstanceId;
|
|
294
|
-
}
|
|
295
|
-
const { url: upstreamUrl, logSafeUrl: logSafeUpstreamUrl } =
|
|
296
|
-
buildWsUpstreamUrl({
|
|
297
|
-
baseUrl: config.assistantRuntimeBaseUrl,
|
|
298
|
-
path: "/v1/browser-relay",
|
|
299
|
-
serviceToken: mintServiceToken(),
|
|
300
|
-
extraParams,
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
log.info(
|
|
304
|
-
{
|
|
305
|
-
upstreamUrl: logSafeUpstreamUrl,
|
|
306
|
-
guardianId: auth.guardianId,
|
|
307
|
-
clientInstanceId: auth.clientInstanceId,
|
|
308
|
-
},
|
|
309
|
-
"Opening upstream browser relay WS to runtime",
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
const upstream = new WebSocket(upstreamUrl);
|
|
313
|
-
ws.data.upstream = upstream;
|
|
314
|
-
|
|
315
|
-
upstream.addEventListener("open", () => {
|
|
316
|
-
log.info("Upstream browser relay WS connected");
|
|
317
|
-
const pending = ws.data.pendingMessages;
|
|
318
|
-
if (pending) {
|
|
319
|
-
for (const msg of pending) {
|
|
320
|
-
upstream.send(msg);
|
|
321
|
-
}
|
|
322
|
-
ws.data.pendingMessages = undefined;
|
|
323
|
-
}
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
upstream.addEventListener("message", (event) => {
|
|
327
|
-
const data =
|
|
328
|
-
typeof event.data === "string"
|
|
329
|
-
? event.data
|
|
330
|
-
: new Uint8Array(event.data as ArrayBuffer);
|
|
331
|
-
ws.send(data);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
upstream.addEventListener("close", (event) => {
|
|
335
|
-
log.info({ code: event.code }, "Upstream browser relay WS closed");
|
|
336
|
-
ws.close(event.code, event.reason);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
upstream.addEventListener("error", (event) => {
|
|
340
|
-
log.error({ error: event }, "Upstream browser relay WS error");
|
|
341
|
-
ws.close(1011, "Upstream error");
|
|
342
|
-
});
|
|
343
|
-
},
|
|
344
|
-
|
|
345
|
-
message(
|
|
346
|
-
ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
|
|
347
|
-
message: string | ArrayBuffer | Uint8Array,
|
|
348
|
-
) {
|
|
349
|
-
const upstream = ws.data.upstream;
|
|
350
|
-
if (upstream && upstream.readyState === WebSocket.OPEN) {
|
|
351
|
-
upstream.send(message);
|
|
352
|
-
} else if (ws.data.pendingMessages) {
|
|
353
|
-
if (ws.data.pendingMessages.length >= MAX_PENDING_MESSAGES) {
|
|
354
|
-
log.warn(
|
|
355
|
-
"Browser relay pending message buffer overflow — closing connection",
|
|
356
|
-
);
|
|
357
|
-
ws.close(1008, "Buffer overflow");
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
ws.data.pendingMessages.push(message);
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
|
|
364
|
-
close(
|
|
365
|
-
ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
|
|
366
|
-
code: number,
|
|
367
|
-
reason: string,
|
|
368
|
-
) {
|
|
369
|
-
const { upstream } = ws.data;
|
|
370
|
-
log.info({ code, reason }, "Browser relay downstream WS closed");
|
|
371
|
-
ws.data.pendingMessages = undefined;
|
|
372
|
-
if (
|
|
373
|
-
upstream &&
|
|
374
|
-
(upstream.readyState === WebSocket.OPEN ||
|
|
375
|
-
upstream.readyState === WebSocket.CONNECTING)
|
|
376
|
-
) {
|
|
377
|
-
upstream.close(code, reason);
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
};
|
|
381
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync,
|
|
3
|
-
writeFileSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
renameSync,
|
|
7
|
-
} from "node:fs";
|
|
8
|
-
import { join, dirname } from "node:path";
|
|
9
|
-
import { randomBytes } from "node:crypto";
|
|
10
|
-
import { getWorkspaceDir } from "../../credential-reader.js";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Serializes config writes so concurrent PATCH requests don't race on
|
|
14
|
-
* read-modify-write. Each write awaits the previous one before proceeding.
|
|
15
|
-
*
|
|
16
|
-
* This chain is shared across all config mutation routes (feature flags,
|
|
17
|
-
* privacy config, etc.) to prevent concurrent writes to the same
|
|
18
|
-
* config.json from overwriting each other's changes.
|
|
19
|
-
*/
|
|
20
|
-
let configWriteChain: Promise<void> = Promise.resolve();
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Enqueue a write operation onto the shared config write chain.
|
|
24
|
-
* The callback runs only after all previously enqueued writes have finished.
|
|
25
|
-
*/
|
|
26
|
-
export function enqueueConfigWrite(fn: () => void): void {
|
|
27
|
-
configWriteChain = configWriteChain.then(fn);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function getConfigPath(): string {
|
|
31
|
-
return join(getWorkspaceDir(), "config.json");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type ConfigReadResult =
|
|
35
|
-
| { ok: true; data: Record<string, unknown> }
|
|
36
|
-
| { ok: false; reason: "malformed"; detail: string };
|
|
37
|
-
|
|
38
|
-
export function readConfigFile(): ConfigReadResult {
|
|
39
|
-
const cfgPath = getConfigPath();
|
|
40
|
-
if (!existsSync(cfgPath)) {
|
|
41
|
-
return { ok: true, data: {} };
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
const raw = readFileSync(cfgPath, "utf-8");
|
|
45
|
-
const parsed = JSON.parse(raw);
|
|
46
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
47
|
-
return {
|
|
48
|
-
ok: false,
|
|
49
|
-
reason: "malformed",
|
|
50
|
-
detail: "Config file is not a JSON object",
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
return { ok: true, data: parsed };
|
|
54
|
-
} catch (err) {
|
|
55
|
-
return { ok: false, reason: "malformed", detail: String(err) };
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Atomically write the config file: write to a temporary file in the same
|
|
61
|
-
* directory, then rename. This avoids partial-file corruption if the process
|
|
62
|
-
* crashes mid-write.
|
|
63
|
-
*/
|
|
64
|
-
export function writeConfigFileAtomic(data: Record<string, unknown>): void {
|
|
65
|
-
const cfgPath = getConfigPath();
|
|
66
|
-
const dir = dirname(cfgPath);
|
|
67
|
-
if (!existsSync(dir)) {
|
|
68
|
-
mkdirSync(dir, { recursive: true });
|
|
69
|
-
}
|
|
70
|
-
const tmpPath = join(dir, `.config.${randomBytes(6).toString("hex")}.tmp`);
|
|
71
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
72
|
-
renameSync(tmpPath, cfgPath);
|
|
73
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IPC route definitions for capability token operations.
|
|
3
|
-
*
|
|
4
|
-
* Exposes token verification to the assistant daemon so it can
|
|
5
|
-
* authenticate browser-relay WebSocket handshakes without reading
|
|
6
|
-
* the HMAC secret directly from the filesystem.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { z } from "zod";
|
|
10
|
-
|
|
11
|
-
import { verifyHostBrowserCapability } from "../auth/capability-tokens.js";
|
|
12
|
-
import type { IpcRoute } from "./server.js";
|
|
13
|
-
|
|
14
|
-
const VerifyCapabilityTokenParamsSchema = z.object({
|
|
15
|
-
token: z.string(),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export const capabilityTokenRoutes: IpcRoute[] = [
|
|
19
|
-
{
|
|
20
|
-
method: "verify_capability_token",
|
|
21
|
-
schema: VerifyCapabilityTokenParamsSchema,
|
|
22
|
-
handler: (params) => {
|
|
23
|
-
const { token } = params as z.infer<
|
|
24
|
-
typeof VerifyCapabilityTokenParamsSchema
|
|
25
|
-
>;
|
|
26
|
-
const claims = verifyHostBrowserCapability(token);
|
|
27
|
-
return claims ?? { valid: false };
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
];
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persistent store for always-allowed paired devices.
|
|
3
|
-
*
|
|
4
|
-
* Persisted to $GATEWAY_SECURITY_DIR/approved-devices.json using an
|
|
5
|
-
* atomic-write pattern (write .tmp → rename → chmod).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import {
|
|
10
|
-
chmodSync,
|
|
11
|
-
existsSync,
|
|
12
|
-
mkdirSync,
|
|
13
|
-
readFileSync,
|
|
14
|
-
renameSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from "node:fs";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
import { getLogger } from "../logger.js";
|
|
20
|
-
import { getGatewaySecurityDir } from "../paths.js";
|
|
21
|
-
|
|
22
|
-
const log = getLogger("approved-devices-store");
|
|
23
|
-
|
|
24
|
-
export interface ApprovedDevice {
|
|
25
|
-
hashedDeviceId: string;
|
|
26
|
-
deviceName: string;
|
|
27
|
-
lastPairedAt: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface ApprovedDevicesFile {
|
|
31
|
-
version: 1;
|
|
32
|
-
devices: ApprovedDevice[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function getStorePath(): string {
|
|
36
|
-
return join(getGatewaySecurityDir(), "approved-devices.json");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Hash a raw deviceId for storage. */
|
|
40
|
-
export function hashDeviceId(deviceId: string): string {
|
|
41
|
-
return createHash("sha256").update(deviceId).digest("hex");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let cachedDevices: Map<string, ApprovedDevice> | null = null;
|
|
45
|
-
|
|
46
|
-
function loadFromDisk(): Map<string, ApprovedDevice> {
|
|
47
|
-
const path = getStorePath();
|
|
48
|
-
if (!existsSync(path)) {
|
|
49
|
-
return new Map();
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
const raw = readFileSync(path, "utf-8");
|
|
53
|
-
const data = JSON.parse(raw) as ApprovedDevicesFile;
|
|
54
|
-
if (data.version !== 1 || !Array.isArray(data.devices)) {
|
|
55
|
-
log.warn("Invalid approved-devices.json format, starting fresh");
|
|
56
|
-
return new Map();
|
|
57
|
-
}
|
|
58
|
-
const map = new Map<string, ApprovedDevice>();
|
|
59
|
-
for (const device of data.devices) {
|
|
60
|
-
map.set(device.hashedDeviceId, device);
|
|
61
|
-
}
|
|
62
|
-
return map;
|
|
63
|
-
} catch (err) {
|
|
64
|
-
log.error({ err }, "Failed to load approved-devices.json");
|
|
65
|
-
return new Map();
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function saveToDisk(devices: Map<string, ApprovedDevice>): void {
|
|
70
|
-
const path = getStorePath();
|
|
71
|
-
const dir = dirname(path);
|
|
72
|
-
if (!existsSync(dir)) {
|
|
73
|
-
mkdirSync(dir, { recursive: true });
|
|
74
|
-
}
|
|
75
|
-
const data: ApprovedDevicesFile = {
|
|
76
|
-
version: 1,
|
|
77
|
-
devices: Array.from(devices.values()),
|
|
78
|
-
};
|
|
79
|
-
const tmpPath = path + ".tmp." + process.pid;
|
|
80
|
-
writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
81
|
-
renameSync(tmpPath, path);
|
|
82
|
-
chmodSync(path, 0o600);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getDevices(): Map<string, ApprovedDevice> {
|
|
86
|
-
if (cachedDevices == null) {
|
|
87
|
-
cachedDevices = loadFromDisk();
|
|
88
|
-
}
|
|
89
|
-
return cachedDevices;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Check if a hashed device ID is in the allowlist. */
|
|
93
|
-
export function isDeviceApproved(hashedDeviceId: string): boolean {
|
|
94
|
-
return getDevices().has(hashedDeviceId);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Update lastPairedAt and deviceName for an existing device (auto-approve refresh). */
|
|
98
|
-
export function refreshDevice(
|
|
99
|
-
hashedDeviceId: string,
|
|
100
|
-
deviceName: string,
|
|
101
|
-
): void {
|
|
102
|
-
const devices = getDevices();
|
|
103
|
-
const existing = devices.get(hashedDeviceId);
|
|
104
|
-
if (existing) {
|
|
105
|
-
existing.deviceName = deviceName;
|
|
106
|
-
existing.lastPairedAt = Date.now();
|
|
107
|
-
saveToDisk(devices);
|
|
108
|
-
log.info({ hashedDeviceId }, "Device metadata refreshed");
|
|
109
|
-
}
|
|
110
|
-
}
|