@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.
- package/bun.lock +3 -0
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -15
- package/src/__tests__/assistant-events-sse-hardening.test.ts +9 -3
- package/src/__tests__/call-controller.test.ts +80 -0
- package/src/__tests__/config-schema.test.ts +38 -178
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +4 -1
- package/src/__tests__/credential-security-invariants.test.ts +0 -2
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +2 -2
- package/src/__tests__/ipc-snapshot.test.ts +0 -9
- package/src/__tests__/onboarding-template-contract.test.ts +10 -20
- package/src/__tests__/relay-server.test.ts +3 -3
- package/src/__tests__/runtime-events-sse-parity.test.ts +10 -0
- package/src/__tests__/runtime-events-sse.test.ts +7 -0
- package/src/__tests__/session-runtime-assembly.test.ts +34 -8
- package/src/__tests__/system-prompt.test.ts +7 -1
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +12 -8
- package/src/__tests__/twilio-routes-twiml.test.ts +2 -2
- package/src/__tests__/twilio-routes.test.ts +2 -3
- package/src/__tests__/voice-quality.test.ts +21 -132
- package/src/calls/call-controller.ts +34 -29
- package/src/calls/relay-server.ts +11 -5
- package/src/calls/twilio-routes.ts +4 -38
- package/src/calls/voice-quality.ts +7 -63
- package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +7 -10
- package/src/config/bundled-skills/messaging/SKILL.md +3 -5
- package/src/config/bundled-skills/phone-calls/SKILL.md +144 -83
- package/src/config/bundled-skills/sms-setup/SKILL.md +0 -20
- package/src/config/bundled-skills/twilio-setup/SKILL.md +9 -17
- package/src/config/bundled-skills/voice-setup/SKILL.md +36 -1
- package/src/config/bundled-skills/voice-setup/icon.svg +20 -0
- package/src/config/calls-schema.ts +3 -53
- package/src/config/elevenlabs-schema.ts +33 -0
- package/src/config/schema.ts +183 -137
- package/src/config/types.ts +0 -1
- package/src/daemon/handlers/browser.ts +1 -6
- package/src/daemon/ipc-contract/browser.ts +5 -14
- package/src/daemon/ipc-contract-inventory.json +0 -2
- package/src/daemon/session-agent-loop-handlers.ts +3 -0
- package/src/daemon/session-runtime-assembly.ts +9 -7
- package/src/mcp/client.ts +2 -1
- package/src/memory/conversation-crud.ts +339 -166
- package/src/runtime/auth/middleware.ts +87 -26
- package/src/runtime/routes/events-routes.ts +7 -0
- package/src/runtime/routes/inbound-message-handler.ts +3 -4
- package/src/schedule/scheduler.ts +159 -45
- package/src/security/secure-keys.ts +3 -3
- package/src/tools/browser/browser-manager.ts +72 -228
- package/src/tools/browser/browser-screencast.ts +0 -5
- package/src/tools/network/script-proxy/certs.ts +7 -237
- package/src/tools/network/script-proxy/connect-tunnel.ts +1 -82
- package/src/tools/network/script-proxy/http-forwarder.ts +2 -151
- package/src/tools/network/script-proxy/logging.ts +12 -196
- package/src/tools/network/script-proxy/mitm-handler.ts +2 -270
- package/src/tools/network/script-proxy/policy.ts +4 -152
- package/src/tools/network/script-proxy/router.ts +2 -60
- package/src/tools/network/script-proxy/server.ts +5 -137
- package/src/tools/network/script-proxy/types.ts +19 -125
- package/src/tools/system/voice-config.ts +23 -1
- package/src/util/logger.ts +4 -1
- package/src/__tests__/elevenlabs-config.test.ts +0 -95
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +0 -407
- package/src/calls/elevenlabs-config.ts +0 -32
|
@@ -1,270 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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";
|