@vellumai/assistant 0.4.23 → 0.4.26

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.
Files changed (63) hide show
  1. package/bun.lock +3 -0
  2. package/package.json +2 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -15
  4. package/src/__tests__/assistant-events-sse-hardening.test.ts +9 -3
  5. package/src/__tests__/call-controller.test.ts +80 -0
  6. package/src/__tests__/config-schema.test.ts +38 -178
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +4 -1
  8. package/src/__tests__/credential-security-invariants.test.ts +0 -2
  9. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +2 -2
  10. package/src/__tests__/ipc-snapshot.test.ts +0 -9
  11. package/src/__tests__/onboarding-template-contract.test.ts +10 -20
  12. package/src/__tests__/relay-server.test.ts +3 -3
  13. package/src/__tests__/runtime-events-sse-parity.test.ts +10 -0
  14. package/src/__tests__/runtime-events-sse.test.ts +7 -0
  15. package/src/__tests__/session-runtime-assembly.test.ts +34 -8
  16. package/src/__tests__/system-prompt.test.ts +7 -1
  17. package/src/__tests__/trusted-contact-approval-notifier.test.ts +12 -8
  18. package/src/__tests__/twilio-routes-twiml.test.ts +2 -2
  19. package/src/__tests__/twilio-routes.test.ts +2 -3
  20. package/src/__tests__/voice-quality.test.ts +21 -132
  21. package/src/calls/call-controller.ts +34 -29
  22. package/src/calls/relay-server.ts +11 -5
  23. package/src/calls/twilio-routes.ts +4 -38
  24. package/src/calls/voice-quality.ts +7 -63
  25. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +7 -10
  26. package/src/config/bundled-skills/messaging/SKILL.md +3 -5
  27. package/src/config/bundled-skills/phone-calls/SKILL.md +144 -83
  28. package/src/config/bundled-skills/sms-setup/SKILL.md +0 -20
  29. package/src/config/bundled-skills/twilio-setup/SKILL.md +9 -17
  30. package/src/config/bundled-skills/voice-setup/SKILL.md +36 -1
  31. package/src/config/bundled-skills/voice-setup/icon.svg +20 -0
  32. package/src/config/calls-schema.ts +3 -53
  33. package/src/config/elevenlabs-schema.ts +33 -0
  34. package/src/config/schema.ts +183 -137
  35. package/src/config/types.ts +0 -1
  36. package/src/daemon/handlers/browser.ts +1 -6
  37. package/src/daemon/ipc-contract/browser.ts +5 -14
  38. package/src/daemon/ipc-contract-inventory.json +0 -2
  39. package/src/daemon/session-agent-loop-handlers.ts +3 -0
  40. package/src/daemon/session-runtime-assembly.ts +9 -7
  41. package/src/mcp/client.ts +2 -1
  42. package/src/memory/conversation-crud.ts +339 -166
  43. package/src/runtime/auth/middleware.ts +87 -26
  44. package/src/runtime/routes/events-routes.ts +7 -0
  45. package/src/runtime/routes/inbound-message-handler.ts +3 -4
  46. package/src/schedule/scheduler.ts +159 -45
  47. package/src/security/secure-keys.ts +3 -3
  48. package/src/tools/browser/browser-manager.ts +72 -228
  49. package/src/tools/browser/browser-screencast.ts +0 -5
  50. package/src/tools/network/script-proxy/certs.ts +7 -237
  51. package/src/tools/network/script-proxy/connect-tunnel.ts +1 -82
  52. package/src/tools/network/script-proxy/http-forwarder.ts +2 -151
  53. package/src/tools/network/script-proxy/logging.ts +12 -196
  54. package/src/tools/network/script-proxy/mitm-handler.ts +2 -270
  55. package/src/tools/network/script-proxy/policy.ts +4 -152
  56. package/src/tools/network/script-proxy/router.ts +2 -60
  57. package/src/tools/network/script-proxy/server.ts +5 -137
  58. package/src/tools/network/script-proxy/types.ts +19 -125
  59. package/src/tools/system/voice-config.ts +23 -1
  60. package/src/util/logger.ts +4 -1
  61. package/src/__tests__/elevenlabs-config.test.ts +0 -95
  62. package/src/__tests__/twilio-routes-elevenlabs.test.ts +0 -407
  63. package/src/calls/elevenlabs-config.ts +0 -32
@@ -1,270 +1,2 @@
1
- /**
2
- * MITM handler intercepts HTTPS CONNECT requests by terminating TLS
3
- * with a dynamically-issued leaf certificate, allowing the proxy to
4
- * read and rewrite the decrypted HTTP request before forwarding it
5
- * upstream over a fresh TLS connection.
6
- *
7
- * Uses a loopback TLS server on an ephemeral port with manual data
8
- * forwarding because Bun does not support in-process TLS termination
9
- * via `new TLSSocket(socket, { isServer })` or `tlsServer.emit('connection')`.
10
- * Additionally, pipe() has timing issues in Bun for this use case,
11
- * so we use explicit data event forwarding instead.
12
- */
13
-
14
- import { connect as netConnect, type Socket } from 'node:net';
15
- import {
16
- connect as tlsConnect,
17
- type ConnectionOptions,
18
- createServer as createTlsServer,
19
- type TLSSocket,
20
- } from 'node:tls';
21
-
22
- import { issueLeafCert } from './certs.js';
23
-
24
- /**
25
- * Hop-by-hop headers stripped during forwarding.
26
- * transfer-encoding is intentionally preserved: we forward body bytes raw,
27
- * so stripping it would cause upstream to misparse chunked bodies.
28
- */
29
- const HOP_BY_HOP = new Set([
30
- 'connection',
31
- 'keep-alive',
32
- 'proxy-authenticate',
33
- 'proxy-authorization',
34
- 'proxy-connection',
35
- 'te',
36
- 'trailer',
37
- 'upgrade',
38
- ]);
39
-
40
- /**
41
- * Callback that receives the parsed request and returns headers to merge.
42
- * Return null to reject the request with 403.
43
- */
44
- export type RewriteCallback = (req: {
45
- method: string;
46
- path: string;
47
- headers: Record<string, string>;
48
- hostname: string;
49
- port: number;
50
- }) => Promise<Record<string, string> | null>;
51
-
52
- interface ParsedRequest {
53
- method: string;
54
- path: string;
55
- httpVersion: string;
56
- headers: Record<string, string>;
57
- bodyPrefix: Buffer;
58
- }
59
-
60
- function parseHttpRequest(buf: Buffer): ParsedRequest | null {
61
- const headerEnd = buf.indexOf('\r\n\r\n');
62
- if (headerEnd === -1) return null;
63
-
64
- const headerBlock = buf.subarray(0, headerEnd).toString('utf-8');
65
- const lines = headerBlock.split('\r\n');
66
- const [method, path, httpVersion] = lines[0].split(' ', 3);
67
-
68
- const headers: Record<string, string> = {};
69
- for (let i = 1; i < lines.length; i++) {
70
- const colonIdx = lines[i].indexOf(':');
71
- if (colonIdx === -1) continue;
72
- const key = lines[i].slice(0, colonIdx).trim().toLowerCase();
73
- const value = lines[i].slice(colonIdx + 1).trim();
74
- headers[key] = value;
75
- }
76
-
77
- return { method, path, httpVersion, headers, bodyPrefix: buf.subarray(headerEnd + 4) };
78
- }
79
-
80
- function serializeRequestHead(
81
- method: string,
82
- path: string,
83
- httpVersion: string,
84
- headers: Record<string, string>,
85
- ): Buffer {
86
- let head = `${method} ${path} ${httpVersion}\r\n`;
87
- for (const [key, value] of Object.entries(headers)) {
88
- head += `${key}: ${value}\r\n`;
89
- }
90
- head += '\r\n';
91
- return Buffer.from(head);
92
- }
93
-
94
- function filterHeaders(raw: Record<string, string>): Record<string, string> {
95
- const out: Record<string, string> = {};
96
- for (const [key, value] of Object.entries(raw)) {
97
- if (!HOP_BY_HOP.has(key.toLowerCase())) {
98
- out[key] = value;
99
- }
100
- }
101
- // Prevent request-smuggling: when Transfer-Encoding is present,
102
- // Content-Length creates ambiguous framing. Drop it per RFC 7230 §3.3.3.
103
- if (out['transfer-encoding']) {
104
- delete out['content-length'];
105
- }
106
- return out;
107
- }
108
-
109
- /**
110
- * Handle a CONNECT request via MITM TLS interception.
111
- */
112
- export async function handleMitm(
113
- clientSocket: Socket,
114
- head: Buffer,
115
- hostname: string,
116
- port: number,
117
- caDir: string,
118
- rewriteCallback: RewriteCallback,
119
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
120
- ): Promise<void> {
121
- const { cert, key } = await issueLeafCert(caDir, hostname);
122
-
123
- clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
124
-
125
- const tlsServer = createTlsServer({ cert, key }, (tlsSocket: TLSSocket) => {
126
- tlsServer.close();
127
- handleDecryptedConnection(tlsSocket, hostname, port, rewriteCallback, upstreamTlsOptions);
128
- });
129
-
130
- await new Promise<void>((resolve, reject) => {
131
- tlsServer.listen(0, '127.0.0.1', () => resolve());
132
- tlsServer.on('error', reject);
133
- });
134
-
135
- const addr = tlsServer.address();
136
- if (!addr || typeof addr === 'string') {
137
- clientSocket.destroy();
138
- tlsServer.close();
139
- return;
140
- }
141
-
142
- const bridge = netConnect(addr.port, '127.0.0.1', () => {
143
- if (head.length > 0) {
144
- bridge.write(head);
145
- }
146
- });
147
-
148
- // Manual bidirectional forwarding — pipe() has timing issues in Bun
149
- clientSocket.on('data', (chunk) => bridge.write(chunk));
150
- bridge.on('data', (chunk) => clientSocket.write(chunk));
151
-
152
- bridge.on('end', () => clientSocket.end());
153
- clientSocket.on('end', () => bridge.end());
154
-
155
- bridge.on('error', () => { clientSocket.destroy(); tlsServer.close(); });
156
- clientSocket.on('error', () => {
157
- bridge.destroy();
158
- tlsServer.close();
159
- });
160
- }
161
-
162
- function handleDecryptedConnection(
163
- tlsSocket: TLSSocket,
164
- hostname: string,
165
- port: number,
166
- rewriteCallback: RewriteCallback,
167
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
168
- ): void {
169
- const chunks: Buffer[] = [];
170
-
171
- const onData = (chunk: Buffer) => {
172
- chunks.push(chunk);
173
- const combined = Buffer.concat(chunks);
174
- const parsed = parseHttpRequest(combined);
175
- if (!parsed) return;
176
-
177
- tlsSocket.removeListener('data', onData);
178
- tlsSocket.pause();
179
- processRequest(tlsSocket, parsed, hostname, port, rewriteCallback, upstreamTlsOptions);
180
- };
181
-
182
- tlsSocket.on('data', onData);
183
- tlsSocket.on('error', () => tlsSocket.destroy());
184
- }
185
-
186
- async function processRequest(
187
- tlsSocket: TLSSocket,
188
- parsed: ParsedRequest,
189
- hostname: string,
190
- port: number,
191
- rewriteCallback: RewriteCallback,
192
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>,
193
- ): Promise<void> {
194
- try {
195
- const filteredHeaders = filterHeaders(parsed.headers);
196
- const rewriteResult = await rewriteCallback({
197
- method: parsed.method,
198
- path: parsed.path,
199
- headers: { ...filteredHeaders },
200
- hostname,
201
- port,
202
- });
203
-
204
- if (rewriteResult == null) {
205
- const body = 'Forbidden';
206
- tlsSocket.write(
207
- `HTTP/1.1 403 Forbidden\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
208
- );
209
- tlsSocket.end();
210
- return;
211
- }
212
-
213
- const finalHeaders = { ...filteredHeaders, ...rewriteResult };
214
- if (!finalHeaders['host']) {
215
- finalHeaders['host'] = port === 443 ? hostname : `${hostname}:${port}`;
216
- }
217
- // Force close so each request gets a fresh MITM cycle with rewrite
218
- finalHeaders['connection'] = 'close';
219
-
220
- const upstream = tlsConnect(
221
- {
222
- host: hostname,
223
- port,
224
- servername: hostname,
225
- ...upstreamTlsOptions,
226
- },
227
- () => {
228
- const headBuf = serializeRequestHead(
229
- parsed.method,
230
- parsed.path,
231
- parsed.httpVersion,
232
- finalHeaders,
233
- );
234
- upstream.write(headBuf);
235
-
236
- if (parsed.bodyPrefix.length > 0) {
237
- upstream.write(parsed.bodyPrefix);
238
- }
239
-
240
- // Manual forwarding — no pipe()
241
- tlsSocket.on('data', (chunk) => upstream.write(chunk));
242
- tlsSocket.resume();
243
- upstream.on('data', (chunk) => tlsSocket.write(chunk));
244
-
245
- upstream.on('end', () => tlsSocket.end());
246
- tlsSocket.on('end', () => upstream.end());
247
- },
248
- );
249
-
250
- upstream.on('error', () => {
251
- if (tlsSocket.writable) {
252
- const body = 'Bad Gateway';
253
- tlsSocket.write(
254
- `HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
255
- );
256
- }
257
- tlsSocket.end();
258
- });
259
-
260
- tlsSocket.on('error', () => upstream.destroy());
261
- } catch {
262
- if (tlsSocket.writable) {
263
- const body = 'Internal Server Error';
264
- tlsSocket.write(
265
- `HTTP/1.1 500 Internal Server Error\r\nContent-Length: ${body.length}\r\nContent-Type: text/plain\r\n\r\n${body}`,
266
- );
267
- }
268
- tlsSocket.end();
269
- }
270
- }
1
+ export type { RewriteCallback } from "@vellumai/proxy-sidecar";
2
+ export { handleMitm } from "@vellumai/proxy-sidecar";
@@ -1,152 +1,4 @@
1
- /**
2
- * Proxy policy engine — matches outbound request targets to credential
3
- * injection templates and emits deterministic policy decisions.
4
- */
5
-
6
- import { compareMatchSpecificity, type HostMatchKind,matchHostPattern } from '../../credentials/host-pattern-match.js';
7
- import type { CredentialInjectionTemplate } from '../../credentials/policy-types.js';
8
- import type { PolicyDecision, RequestTargetContext } from './types.js';
9
-
10
- interface MatchCandidate {
11
- credentialId: string;
12
- template: CredentialInjectionTemplate;
13
- }
14
-
15
- /**
16
- * Evaluate an outbound request against credential injection templates.
17
- *
18
- * @param hostname Target hostname (e.g. "api.fal.ai")
19
- * @param _path Request path — reserved for future path-level matching
20
- * @param credentialIds Credential IDs the session is authorized to use
21
- * @param templates Map from credentialId → injection templates
22
- */
23
- export function evaluateRequest(
24
- hostname: string,
25
- _path: string,
26
- credentialIds: string[],
27
- templates: Map<string, CredentialInjectionTemplate[]>,
28
- ): PolicyDecision {
29
- if (credentialIds.length === 0) {
30
- return { kind: 'unauthenticated' };
31
- }
32
-
33
- // For each credential, find the best matching header template by specificity.
34
- // Query templates are excluded — they're handled via URL rewriting in the
35
- // MITM path and can't be injected by the HTTP forwarder.
36
- const perCredentialBest: MatchCandidate[] = [];
37
-
38
- for (const id of credentialIds) {
39
- const tpls = templates.get(id);
40
- if (!tpls) continue;
41
-
42
- let bestMatch: HostMatchKind = 'none';
43
- let bestCandidates: CredentialInjectionTemplate[] = [];
44
-
45
- for (const tpl of tpls) {
46
- if (tpl.injectionType === 'query') continue;
47
- const match = matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true });
48
- if (match === 'none') continue;
49
-
50
- const cmp = compareMatchSpecificity(match, bestMatch);
51
- if (cmp < 0) {
52
- // Strictly more specific — replace
53
- bestMatch = match;
54
- bestCandidates = [tpl];
55
- } else if (cmp === 0) {
56
- // Same specificity — accumulate (potential intra-credential tie)
57
- bestCandidates.push(tpl);
58
- }
59
- // cmp > 0 means less specific — skip
60
- }
61
-
62
- if (bestCandidates.length === 1) {
63
- perCredentialBest.push({ credentialId: id, template: bestCandidates[0] });
64
- } else if (bestCandidates.length > 1) {
65
- // Same credential has multiple templates at the same specificity — ambiguous
66
- return {
67
- kind: 'ambiguous',
68
- candidates: bestCandidates.map((tpl) => ({ credentialId: id, template: tpl })),
69
- };
70
- }
71
- }
72
-
73
- if (perCredentialBest.length === 0) {
74
- return { kind: 'missing' };
75
- }
76
-
77
- if (perCredentialBest.length === 1) {
78
- return {
79
- kind: 'matched',
80
- credentialId: perCredentialBest[0].credentialId,
81
- template: perCredentialBest[0].template,
82
- };
83
- }
84
-
85
- // Multiple credentials match — cross-credential ambiguity
86
- return { kind: 'ambiguous', candidates: perCredentialBest };
87
- }
88
-
89
- /**
90
- * Evaluate an outbound request with approval-hook awareness.
91
- *
92
- * This wraps `evaluateRequest` and, when the base decision is `missing` or
93
- * `unauthenticated`, consults the full credential template registry to
94
- * determine whether an approval prompt should be surfaced:
95
- *
96
- * - `ask_missing_credential` — the target host matches at least one known
97
- * template pattern in the registry, but the session has no credential
98
- * bound for it.
99
- * - `ask_unauthenticated` — the request doesn't match any known template
100
- * in the full registry and the session has no credentials.
101
- *
102
- * For `matched` and `ambiguous` decisions the result passes through unchanged.
103
- *
104
- * @param hostname Target hostname
105
- * @param port Target port (null when the default for the scheme)
106
- * @param path Request path
107
- * @param credentialIds Credential IDs the session is authorized to use
108
- * @param sessionTemplates Templates for the session's credential IDs
109
- * @param allKnownTemplates All credential injection templates across every
110
- * credential in the system — used to detect whether
111
- * the target host is "known" even if the session
112
- * doesn't have the right credential bound.
113
- */
114
- export function evaluateRequestWithApproval(
115
- hostname: string,
116
- port: number | null,
117
- path: string,
118
- credentialIds: string[],
119
- sessionTemplates: Map<string, CredentialInjectionTemplate[]>,
120
- allKnownTemplates: CredentialInjectionTemplate[],
121
- scheme: 'http' | 'https' = 'https',
122
- ): PolicyDecision {
123
- const base = evaluateRequest(hostname, path, credentialIds, sessionTemplates);
124
-
125
- if (base.kind !== 'missing' && base.kind !== 'unauthenticated') {
126
- return base;
127
- }
128
-
129
- const target: RequestTargetContext = { hostname, port, path, scheme };
130
-
131
- // Check whether any non-query template in the full registry covers this
132
- // host. Query templates are excluded for consistency with evaluateRequest
133
- // — they're handled via URL rewriting in the MITM path and shouldn't
134
- // cause a false ask_missing_credential on the HTTP forwarder path.
135
- const matchingPatterns: string[] = [];
136
- for (const tpl of allKnownTemplates) {
137
- if (tpl.injectionType === 'query') continue;
138
- if (matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true }) !== 'none') {
139
- matchingPatterns.push(tpl.hostPattern);
140
- }
141
- }
142
- // Deduplicate — multiple credentials may share the same host pattern.
143
- const uniquePatterns = [...new Set(matchingPatterns)];
144
-
145
- if (uniquePatterns.length > 0) {
146
- // A known host pattern exists but no credential is bound to this session.
147
- return { kind: 'ask_missing_credential', target, matchingPatterns: uniquePatterns };
148
- }
149
-
150
- // Completely unknown host — prompt for unauthenticated access.
151
- return { kind: 'ask_unauthenticated', target };
152
- }
1
+ export {
2
+ evaluateRequest,
3
+ evaluateRequestWithApproval,
4
+ } from "@vellumai/proxy-sidecar";
@@ -1,60 +1,2 @@
1
- /**
2
- * Hybrid proxy router — decides per-CONNECT request whether to MITM-intercept
3
- * (for credential injection) or use a plain CONNECT tunnel (no rewrite needed).
4
- *
5
- * The router checks whether any credential injection template matches the
6
- * target hostname. Only when a credential rewrite is required does the proxy
7
- * pay the cost of TLS termination, cert issuance, and request rewriting.
8
- */
9
-
10
- import { matchHostPattern } from '../../credentials/host-pattern-match.js';
11
- import type { CredentialInjectionTemplate } from '../../credentials/policy-types.js';
12
-
13
- // ---- Public types ----------------------------------------------------------
14
-
15
- /** Deterministic reason codes for auditing and testing. */
16
- export type RouteReason =
17
- | 'mitm:credential_injection'
18
- | 'tunnel:no_rewrite'
19
- | 'tunnel:no_credentials';
20
-
21
- export interface RouteDecision {
22
- action: 'mitm' | 'tunnel';
23
- reason: RouteReason;
24
- }
25
-
26
- // ---- Router ----------------------------------------------------------------
27
-
28
- /**
29
- * Decide whether a CONNECT target requires MITM interception.
30
- *
31
- * @param hostname Target hostname (e.g. "api.fal.ai")
32
- * @param _port Target port — reserved for future port-level rules
33
- * @param credentialIds Credential IDs the session is authorized to use
34
- * @param templates Map from credentialId to injection templates
35
- */
36
- export function routeConnection(
37
- hostname: string,
38
- _port: number,
39
- credentialIds: string[],
40
- templates: ReadonlyMap<string, readonly CredentialInjectionTemplate[]>,
41
- ): RouteDecision {
42
- // No credentials configured — nothing to inject, tunnel through.
43
- if (credentialIds.length === 0) {
44
- return { action: 'tunnel', reason: 'tunnel:no_credentials' };
45
- }
46
-
47
- for (const id of credentialIds) {
48
- const tpls = templates.get(id);
49
- if (!tpls) continue;
50
-
51
- for (const tpl of tpls) {
52
- if (matchHostPattern(hostname, tpl.hostPattern, { includeApexForWildcard: true }) !== 'none') {
53
- return { action: 'mitm', reason: 'mitm:credential_injection' };
54
- }
55
- }
56
- }
57
-
58
- // Credentials exist but none match this host — no rewrite needed.
59
- return { action: 'tunnel', reason: 'tunnel:no_rewrite' };
60
- }
1
+ export type { RouteDecision, RouteReason } from "@vellumai/proxy-sidecar";
2
+ export { routeConnection } from "@vellumai/proxy-sidecar";
@@ -1,137 +1,5 @@
1
- /**
2
- * Proxy server factory — creates an HTTP server configured to handle
3
- * plain HTTP proxy requests via the forwarder, plain CONNECT tunnelling,
4
- * and optional MITM interception for credential-injected HTTPS requests.
5
- */
6
-
7
- import { createServer, type Server } from 'node:http';
8
- import type { Socket } from 'node:net';
9
- import type { ConnectionOptions } from 'node:tls';
10
-
11
- import { handleConnect } from './connect-tunnel.js';
12
- import { forwardHttpRequest, type PolicyCallback } from './http-forwarder.js';
13
- import { handleMitm, type RewriteCallback } from './mitm-handler.js';
14
- import type { RouteDecision } from './router.js';
15
-
16
- export interface MitmHandlerConfig {
17
- /** Path to the local CA directory containing ca.pem / ca-key.pem. */
18
- caDir: string;
19
- /**
20
- * Decide whether the CONNECT target should be MITM-intercepted.
21
- * Returns a RouteDecision with action ('mitm' | 'tunnel') and a
22
- * deterministic reason code for auditing.
23
- */
24
- shouldIntercept: (hostname: string, port: number) => RouteDecision;
25
- /** Called with the decrypted request; returns headers to merge or null to reject. */
26
- rewriteCallback: RewriteCallback;
27
- /** Extra TLS options for the upstream connection (e.g. custom CA for testing). */
28
- upstreamTlsOptions?: Pick<ConnectionOptions, 'ca' | 'rejectUnauthorized'>;
29
- }
30
-
31
- export interface ProxyServerConfig {
32
- /** Optional policy callback for credential injection / access control. */
33
- policyCallback?: PolicyCallback;
34
- /** Called on every forwarded request for logging. */
35
- onRequest?: (method: string, url: string) => void;
36
- /** When provided, CONNECT requests matching shouldIntercept are MITM-handled. */
37
- mitmHandler?: MitmHandlerConfig;
38
- }
39
-
40
- /**
41
- * Parse a CONNECT target of the form `host:port`.
42
- */
43
- function parseConnectTarget(url: string | undefined): { host: string; port: number } | null {
44
- if (!url) return null;
45
- const colonIdx = url.lastIndexOf(':');
46
- if (colonIdx <= 0) return null;
47
- let host = url.slice(0, colonIdx);
48
- const portStr = url.slice(colonIdx + 1);
49
- if (!host || !portStr) return null;
50
- const port = Number(portStr);
51
- if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
52
- // Strip brackets from IPv6 literals — net.connect expects the raw address
53
- if (host.startsWith('[') && host.endsWith(']')) {
54
- host = host.slice(1, -1);
55
- if (!host) return null;
56
- }
57
- return { host, port };
58
- }
59
-
60
- /**
61
- * Create an HTTP server that acts as a forward proxy for plain HTTP
62
- * requests (absolute-URL form), CONNECT tunnelling for HTTPS pass-through,
63
- * and optional MITM interception for credential-injected HTTPS requests.
64
- */
65
- export function createProxyServer(config: ProxyServerConfig = {}): Server {
66
- const server = createServer((req, res) => {
67
- if (config.onRequest && req.method && req.url) {
68
- config.onRequest(req.method, req.url);
69
- }
70
-
71
- forwardHttpRequest(req, res, config.policyCallback);
72
- });
73
-
74
- server.on('connect', (req, clientSocket: Socket, head: Buffer) => {
75
- if (config.mitmHandler) {
76
- const target = parseConnectTarget(req.url);
77
- const decision = target
78
- ? config.mitmHandler.shouldIntercept(target.host, target.port)
79
- : undefined;
80
-
81
- if (target && decision?.action === 'mitm') {
82
- handleMitm(
83
- clientSocket,
84
- head,
85
- target.host,
86
- target.port,
87
- config.mitmHandler.caDir,
88
- config.mitmHandler.rewriteCallback,
89
- config.mitmHandler.upstreamTlsOptions,
90
- ).catch(() => {
91
- if (clientSocket.writable) {
92
- clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
93
- }
94
- clientSocket.destroy();
95
- });
96
- return;
97
- }
98
- }
99
-
100
- // Gate CONNECT tunnels through policyCallback the same way HTTP requests are gated
101
- if (config.policyCallback) {
102
- const connectTarget = parseConnectTarget(req.url);
103
- if (!connectTarget) {
104
- clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
105
- clientSocket.destroy();
106
- return;
107
- }
108
-
109
- if (config.onRequest) {
110
- config.onRequest('CONNECT', req.url!);
111
- }
112
-
113
- config.policyCallback(connectTarget.host, connectTarget.port === 443 ? null : connectTarget.port, '/', 'https')
114
- .then((extraHeaders) => {
115
- if (extraHeaders == null) {
116
- clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
117
- clientSocket.destroy();
118
- return;
119
- }
120
- handleConnect(req, clientSocket, head);
121
- })
122
- .catch(() => {
123
- if (clientSocket.writable) {
124
- clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
125
- }
126
- clientSocket.destroy();
127
- });
128
- } else {
129
- if (config.onRequest && req.url) {
130
- config.onRequest('CONNECT', req.url);
131
- }
132
- handleConnect(req, clientSocket, head);
133
- }
134
- });
135
-
136
- return server;
137
- }
1
+ export type {
2
+ MitmHandlerConfig,
3
+ ProxyServerConfig,
4
+ } from "@vellumai/proxy-sidecar";
5
+ export { createProxyServer } from "@vellumai/proxy-sidecar";