@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
|
@@ -5,9 +5,20 @@ import { getLogger } from "../../util/logger.js";
|
|
|
5
5
|
import { getDataDir } from "../../util/platform.js";
|
|
6
6
|
import { authSessionCache } from "./auth-cache.js";
|
|
7
7
|
import type { ExtractedCredential } from "./network-recording-types.js";
|
|
8
|
+
import { checkBrowserRuntime } from "./runtime-check.js";
|
|
8
9
|
|
|
9
10
|
const log = getLogger("browser-manager");
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Returns true when the host has a GUI capable of displaying a browser window.
|
|
14
|
+
* macOS and Windows always have a display; Linux requires DISPLAY or WAYLAND_DISPLAY.
|
|
15
|
+
*/
|
|
16
|
+
function canDisplayGui(): boolean {
|
|
17
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
18
|
+
return true;
|
|
19
|
+
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
function getDownloadsDir(): string {
|
|
12
23
|
const dir = join(getDataDir(), "browser-downloads");
|
|
13
24
|
mkdirSync(dir, { recursive: true });
|
|
@@ -118,6 +129,11 @@ export function setLaunchFn(fn: LaunchFn | null): void {
|
|
|
118
129
|
launchPersistentContext = fn;
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
async function getDefaultLaunchFn(): Promise<LaunchFn> {
|
|
133
|
+
const pw = await import("playwright");
|
|
134
|
+
return pw.chromium.launchPersistentContext.bind(pw.chromium);
|
|
135
|
+
}
|
|
136
|
+
|
|
121
137
|
function getProfileDir(): string {
|
|
122
138
|
return join(getDataDir(), "browser-profile");
|
|
123
139
|
}
|
|
@@ -130,180 +146,78 @@ class BrowserManager {
|
|
|
130
146
|
private rawPages = new Map<string, unknown>();
|
|
131
147
|
private cdpSessions = new Map<string, CDPSession>();
|
|
132
148
|
private snapshotMaps = new Map<string, Map<string, string>>();
|
|
133
|
-
private cdpUrl: string = "http://localhost:9222";
|
|
134
|
-
private cdpBrowser: unknown = null; // Store CDP browser reference separately
|
|
135
|
-
private _browserLaunched = false; // true when browser was launched via test injection (vs connected via CDP)
|
|
136
149
|
private browserCdpSession: CDPSession | null = null;
|
|
137
150
|
private browserWindowId: number | null = null;
|
|
138
|
-
private cdpRequestResolvers = new Map<
|
|
139
|
-
string,
|
|
140
|
-
(response: { success: boolean; declined?: boolean }) => void
|
|
141
|
-
>();
|
|
142
151
|
private interactiveModeSessions = new Set<string>();
|
|
143
152
|
private handoffResolvers = new Map<string, () => void>();
|
|
144
|
-
private sessionSenders = new Map<
|
|
145
|
-
string,
|
|
146
|
-
(msg: { type: string; sessionId: string }) => void
|
|
147
|
-
>();
|
|
148
153
|
private downloads = new Map<string, DownloadInfo[]>();
|
|
149
154
|
private pendingDownloads = new Map<
|
|
150
155
|
string,
|
|
151
156
|
{ resolve: (info: DownloadInfo) => void; reject: (err: Error) => void }[]
|
|
152
157
|
>();
|
|
153
158
|
|
|
154
|
-
/** Whether page.route() is supported.
|
|
159
|
+
/** Whether page.route() is supported. Always true for launched browsers. */
|
|
155
160
|
get supportsRouteInterception(): boolean {
|
|
156
|
-
|
|
157
|
-
return this._browserLaunched;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
registerSender(
|
|
161
|
-
sessionId: string,
|
|
162
|
-
sendToClient: (msg: { type: string; sessionId: string }) => void,
|
|
163
|
-
): void {
|
|
164
|
-
this.sessionSenders.set(sessionId, sendToClient);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
unregisterSender(sessionId: string): void {
|
|
168
|
-
this.sessionSenders.delete(sessionId);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async detectCDP(url?: string): Promise<boolean> {
|
|
172
|
-
const target = url || this.cdpUrl;
|
|
173
|
-
try {
|
|
174
|
-
const response = await fetch(`${target}/json/version`, {
|
|
175
|
-
signal: AbortSignal.timeout(3000),
|
|
176
|
-
});
|
|
177
|
-
return response.ok;
|
|
178
|
-
} catch {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Request Chrome restart from client via IPC. Returns true if client confirmed and CDP is now available.
|
|
185
|
-
* The sendToClient callback sends the request, and resolveCDPResponse() is called when the response arrives.
|
|
186
|
-
*/
|
|
187
|
-
async requestCDPFromClient(
|
|
188
|
-
sessionId: string,
|
|
189
|
-
sendToClient: (msg: { type: string; sessionId: string }) => void,
|
|
190
|
-
): Promise<boolean> {
|
|
191
|
-
// Cancel any existing pending request for this session to avoid leaked promises
|
|
192
|
-
const existing = this.cdpRequestResolvers.get(sessionId);
|
|
193
|
-
if (existing) {
|
|
194
|
-
existing({ success: false });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return new Promise<boolean>((resolve) => {
|
|
198
|
-
const resolver = (response: { success: boolean; declined?: boolean }) => {
|
|
199
|
-
clearTimeout(timer);
|
|
200
|
-
// Only act if we're still the active resolver for this session
|
|
201
|
-
if (this.cdpRequestResolvers.get(sessionId) === resolver) {
|
|
202
|
-
this.cdpRequestResolvers.delete(sessionId);
|
|
203
|
-
}
|
|
204
|
-
resolve(response.success);
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
// Set a timeout in case the client never responds
|
|
208
|
-
const timer = setTimeout(() => {
|
|
209
|
-
if (this.cdpRequestResolvers.get(sessionId) === resolver) {
|
|
210
|
-
this.cdpRequestResolvers.delete(sessionId);
|
|
211
|
-
}
|
|
212
|
-
resolve(false);
|
|
213
|
-
}, 15_000);
|
|
214
|
-
|
|
215
|
-
this.cdpRequestResolvers.set(sessionId, resolver);
|
|
216
|
-
sendToClient({ type: "browser_cdp_request", sessionId });
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Called when a browser_cdp_response message arrives from the client.
|
|
222
|
-
*/
|
|
223
|
-
resolveCDPResponse(
|
|
224
|
-
sessionId: string,
|
|
225
|
-
success: boolean,
|
|
226
|
-
declined?: boolean,
|
|
227
|
-
): void {
|
|
228
|
-
const resolver = this.cdpRequestResolvers.get(sessionId);
|
|
229
|
-
if (resolver) {
|
|
230
|
-
resolver({ success, declined });
|
|
231
|
-
}
|
|
161
|
+
return true;
|
|
232
162
|
}
|
|
233
163
|
|
|
234
|
-
private async ensureContext(
|
|
235
|
-
invokingSessionId?: string,
|
|
236
|
-
): Promise<BrowserContext> {
|
|
164
|
+
private async ensureContext(): Promise<BrowserContext> {
|
|
237
165
|
if (this.context) return this.context;
|
|
238
166
|
if (this.contextCreating) return this.contextCreating;
|
|
239
167
|
|
|
240
168
|
this.contextCreating = (async () => {
|
|
241
|
-
|
|
242
|
-
// bypass ambient CDP probing/negotiation and use the injected launcher.
|
|
243
|
-
if (launchPersistentContext != null) {
|
|
244
|
-
const profileDir = getProfileDir();
|
|
245
|
-
mkdirSync(profileDir, { recursive: true });
|
|
246
|
-
await authSessionCache.load();
|
|
247
|
-
const ctx = await launchPersistentContext(profileDir, {
|
|
248
|
-
headless: false,
|
|
249
|
-
});
|
|
250
|
-
this._browserLaunched = true;
|
|
251
|
-
log.info({ profileDir }, "Browser context created (test injection)");
|
|
252
|
-
return ctx;
|
|
253
|
-
}
|
|
169
|
+
await authSessionCache.load();
|
|
254
170
|
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
171
|
+
// Auto-install Chromium if needed (skip when test launcher is injected)
|
|
172
|
+
if (!launchPersistentContext) {
|
|
173
|
+
const status = await checkBrowserRuntime();
|
|
174
|
+
if (status.playwrightAvailable && !status.chromiumInstalled) {
|
|
175
|
+
log.info("Chromium not installed, installing via playwright...");
|
|
176
|
+
const proc = Bun.spawn(
|
|
177
|
+
["bunx", "playwright", "install", "chromium"],
|
|
178
|
+
{
|
|
179
|
+
stdout: "pipe",
|
|
180
|
+
stderr: "pipe",
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
const timeoutMs = 120_000;
|
|
184
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
185
|
+
const exitCode = await Promise.race([
|
|
186
|
+
proc.exited.finally(() => clearTimeout(timer)),
|
|
187
|
+
new Promise<never>(
|
|
188
|
+
(_, reject) =>
|
|
189
|
+
(timer = setTimeout(() => {
|
|
190
|
+
proc.kill();
|
|
191
|
+
reject(
|
|
192
|
+
new Error(
|
|
193
|
+
`Chromium install timed out after ${timeoutMs / 1000}s`,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}, timeoutMs)),
|
|
197
|
+
),
|
|
198
|
+
]);
|
|
199
|
+
if (exitCode === 0) {
|
|
200
|
+
log.info("Chromium installed successfully");
|
|
201
|
+
} else {
|
|
202
|
+
const stderr = await new Response(proc.stderr).text();
|
|
203
|
+
const msg = stderr.trim() || `exited with code ${exitCode}`;
|
|
204
|
+
throw new Error(`Failed to install Chromium: ${msg}`);
|
|
275
205
|
}
|
|
276
|
-
} else {
|
|
277
|
-
log.info("Client declined CDP request");
|
|
278
|
-
throw new Error("Browser access was declined by user");
|
|
279
206
|
}
|
|
280
207
|
}
|
|
281
208
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
timeout: 10_000,
|
|
295
|
-
});
|
|
296
|
-
this.cdpBrowser = browser;
|
|
297
|
-
this._browserLaunched = false;
|
|
298
|
-
const contexts = browser.contexts();
|
|
299
|
-
const ctx = contexts[0] || (await browser.newContext());
|
|
300
|
-
await this.initBrowserCdpSession();
|
|
301
|
-
log.info({ cdpUrl: this.cdpUrl }, "Connected to Chrome via CDP");
|
|
302
|
-
return ctx as unknown as BrowserContext;
|
|
303
|
-
} catch (err) {
|
|
304
|
-
log.warn({ err }, "CDP connectOverCDP failed");
|
|
305
|
-
throw new Error(`Failed to connect to Chrome via CDP: ${err}`);
|
|
306
|
-
}
|
|
209
|
+
const profileDir = getProfileDir();
|
|
210
|
+
mkdirSync(profileDir, { recursive: true });
|
|
211
|
+
const launch = launchPersistentContext ?? (await getDefaultLaunchFn());
|
|
212
|
+
const headless = !canDisplayGui();
|
|
213
|
+
const ctx = await launch(profileDir, { headless });
|
|
214
|
+
log.info(
|
|
215
|
+
{ profileDir, headless },
|
|
216
|
+
headless
|
|
217
|
+
? "Browser context created (headless)"
|
|
218
|
+
: "Browser context created (visible)",
|
|
219
|
+
);
|
|
220
|
+
return ctx;
|
|
307
221
|
})();
|
|
308
222
|
|
|
309
223
|
try {
|
|
@@ -322,8 +236,6 @@ class BrowserManager {
|
|
|
322
236
|
this.contextCloseHandler = null;
|
|
323
237
|
this.browserCdpSession = null;
|
|
324
238
|
this.browserWindowId = null;
|
|
325
|
-
this.cdpBrowser = null;
|
|
326
|
-
this._browserLaunched = false;
|
|
327
239
|
// Resolve any pending handoffs before clearing state
|
|
328
240
|
for (const resolver of this.handoffResolvers.values()) {
|
|
329
241
|
resolver();
|
|
@@ -352,7 +264,7 @@ class BrowserManager {
|
|
|
352
264
|
}
|
|
353
265
|
|
|
354
266
|
async getOrCreateSessionPage(sessionId: string): Promise<Page> {
|
|
355
|
-
const context = await this.ensureContext(
|
|
267
|
+
const context = await this.ensureContext();
|
|
356
268
|
|
|
357
269
|
const existing = this.pages.get(sessionId);
|
|
358
270
|
if (existing && !existing.isClosed()) {
|
|
@@ -363,43 +275,17 @@ class BrowserManager {
|
|
|
363
275
|
this.snapshotMaps.delete(sessionId);
|
|
364
276
|
await this.stopScreencast(sessionId);
|
|
365
277
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// In connectOverCDP mode, Chrome often starts with a pre-opened blank tab.
|
|
369
|
-
// Only reuse blank/new-tab pages to avoid hijacking active user tabs, which
|
|
370
|
-
// could cause user-visible disruption or data loss when the session closes.
|
|
371
|
-
if (!this._browserLaunched && typeof context.pages === "function") {
|
|
372
|
-
const BLANK_TAB_URLS = new Set([
|
|
373
|
-
"about:blank",
|
|
374
|
-
"chrome://newtab/",
|
|
375
|
-
"chrome://new-tab-page/",
|
|
376
|
-
]);
|
|
377
|
-
const claimedPages = new Set(this.pages.values());
|
|
378
|
-
const reusable = context.pages().find((p) => {
|
|
379
|
-
if (p.isClosed() || claimedPages.has(p)) return false;
|
|
380
|
-
const url = p.url();
|
|
381
|
-
return BLANK_TAB_URLS.has(url) || url === "";
|
|
382
|
-
});
|
|
383
|
-
if (reusable) {
|
|
384
|
-
page = reusable;
|
|
385
|
-
log.debug(
|
|
386
|
-
{ sessionId, url: reusable.url() },
|
|
387
|
-
"Reusing blank CDP tab instead of creating a new page",
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
page ??= await context.newPage();
|
|
278
|
+
const page = await context.newPage();
|
|
393
279
|
this.pages.set(sessionId, page);
|
|
394
280
|
this.rawPages.set(sessionId, page);
|
|
395
281
|
|
|
396
282
|
// Track downloads for this page
|
|
397
283
|
this.setupDownloadTracking(sessionId, page);
|
|
398
284
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
if (!this.browserCdpSession
|
|
285
|
+
// Create a page-level CDP session for window positioning.
|
|
286
|
+
// Browser domain commands (setWindowBounds, getWindowForTarget) are accessible
|
|
287
|
+
// from page-level CDP sessions.
|
|
288
|
+
if (!this.browserCdpSession) {
|
|
403
289
|
try {
|
|
404
290
|
const rawPage = page as unknown as RawPlaywrightPage;
|
|
405
291
|
this.browserCdpSession = await rawPage.context().newCDPSession(rawPage);
|
|
@@ -509,28 +395,6 @@ class BrowserManager {
|
|
|
509
395
|
this.browserCdpSession = null;
|
|
510
396
|
this.browserWindowId = null;
|
|
511
397
|
}
|
|
512
|
-
|
|
513
|
-
// Close or disconnect CDP browser connection if present
|
|
514
|
-
if (this.cdpBrowser) {
|
|
515
|
-
const b = this.cdpBrowser as {
|
|
516
|
-
close?: () => Promise<void>;
|
|
517
|
-
disconnect?: () => Promise<void>;
|
|
518
|
-
};
|
|
519
|
-
const wasLaunched = this._browserLaunched;
|
|
520
|
-
this.cdpBrowser = null;
|
|
521
|
-
this._browserLaunched = false;
|
|
522
|
-
try {
|
|
523
|
-
if (wasLaunched) {
|
|
524
|
-
// Launched browsers must be closed to terminate the process
|
|
525
|
-
await b.close?.();
|
|
526
|
-
} else {
|
|
527
|
-
// CDP-connected browsers should be disconnected, not closed
|
|
528
|
-
await b.disconnect?.();
|
|
529
|
-
}
|
|
530
|
-
} catch (err) {
|
|
531
|
-
log.warn({ err }, "Failed to close/disconnect CDP browser");
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
398
|
}
|
|
535
399
|
|
|
536
400
|
async stopScreencast(sessionId: string): Promise<void> {
|
|
@@ -563,26 +427,6 @@ class BrowserManager {
|
|
|
563
427
|
return map.get(elementId) ?? null;
|
|
564
428
|
}
|
|
565
429
|
|
|
566
|
-
/**
|
|
567
|
-
* Create a browser-level CDP session and discover the window ID.
|
|
568
|
-
* Called once after browser launch/connect so positionWindowSidebar/moveWindowOnscreen can work.
|
|
569
|
-
*/
|
|
570
|
-
private async initBrowserCdpSession(): Promise<void> {
|
|
571
|
-
if (!this.cdpBrowser) return;
|
|
572
|
-
try {
|
|
573
|
-
const browser = this.cdpBrowser as {
|
|
574
|
-
newBrowserCDPSession?: () => Promise<CDPSession>;
|
|
575
|
-
};
|
|
576
|
-
if (typeof browser.newBrowserCDPSession !== "function") return;
|
|
577
|
-
|
|
578
|
-
this.browserCdpSession = await browser.newBrowserCDPSession();
|
|
579
|
-
this.browserWindowId = null;
|
|
580
|
-
await this.ensureBrowserWindowId();
|
|
581
|
-
} catch (err) {
|
|
582
|
-
log.warn({ err }, "Failed to init browser CDP session");
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
430
|
private async ensureBrowserWindowId(): Promise<number | null> {
|
|
587
431
|
if (!this.browserCdpSession) return null;
|
|
588
432
|
if (this.browserWindowId != null) return this.browserWindowId;
|
|
@@ -16,10 +16,6 @@ export function registerSessionSender(
|
|
|
16
16
|
sendToClient: (msg: ServerMessage) => void,
|
|
17
17
|
): void {
|
|
18
18
|
sessionSenders.set(sessionId, sendToClient);
|
|
19
|
-
browserManager.registerSender(
|
|
20
|
-
sessionId,
|
|
21
|
-
sendToClient as (msg: { type: string; sessionId: string }) => void,
|
|
22
|
-
);
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
/**
|
|
@@ -27,7 +23,6 @@ export function registerSessionSender(
|
|
|
27
23
|
*/
|
|
28
24
|
export function unregisterSessionSender(sessionId: string): void {
|
|
29
25
|
sessionSenders.delete(sessionId);
|
|
30
|
-
browserManager.unregisterSender(sessionId);
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
function getSender(
|
|
@@ -1,237 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const ISSUED_DIR = 'issued';
|
|
9
|
-
|
|
10
|
-
/** Well-known system CA bundle paths by platform. */
|
|
11
|
-
const SYSTEM_CA_PATHS = [
|
|
12
|
-
'/etc/ssl/cert.pem', // macOS
|
|
13
|
-
'/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
|
|
14
|
-
'/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS/Fedora
|
|
15
|
-
'/etc/ssl/ca-bundle.pem', // openSUSE
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
// Only allow valid hostname characters: alphanumeric, hyphens, dots, and wildcards
|
|
19
|
-
const HOSTNAME_RE = /^[a-zA-Z0-9.*-]+$/;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Ensure a self-signed CA cert+key exists in `{dataDir}/proxy-ca/`.
|
|
23
|
-
* Idempotent: skips generation if both files already exist.
|
|
24
|
-
*/
|
|
25
|
-
export async function ensureLocalCA(dataDir: string): Promise<void> {
|
|
26
|
-
const caDir = join(dataDir, 'proxy-ca');
|
|
27
|
-
const certPath = join(caDir, CA_CERT_FILENAME);
|
|
28
|
-
const keyPath = join(caDir, CA_KEY_FILENAME);
|
|
29
|
-
|
|
30
|
-
// Check if both files already exist
|
|
31
|
-
const [certExists, keyExists] = await Promise.all([
|
|
32
|
-
stat(certPath).then(() => true, () => false),
|
|
33
|
-
stat(keyPath).then(() => true, () => false),
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
if (certExists && keyExists) return;
|
|
37
|
-
|
|
38
|
-
await mkdir(caDir, { recursive: true });
|
|
39
|
-
|
|
40
|
-
// Generate CA key
|
|
41
|
-
const keyProc = Bun.spawn([
|
|
42
|
-
'openssl', 'genrsa', '-out', keyPath, '2048',
|
|
43
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
44
|
-
const keyExit = await keyProc.exited;
|
|
45
|
-
if (keyExit !== 0) {
|
|
46
|
-
const stderr = await new Response(keyProc.stderr).text();
|
|
47
|
-
throw new Error(`Failed to generate CA key: ${stderr}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Generate self-signed CA cert (valid 10 years)
|
|
51
|
-
const certProc = Bun.spawn([
|
|
52
|
-
'openssl', 'req', '-new', '-x509',
|
|
53
|
-
'-key', keyPath,
|
|
54
|
-
'-out', certPath,
|
|
55
|
-
'-days', '3650',
|
|
56
|
-
'-subj', '/CN=Vellum Local Proxy CA',
|
|
57
|
-
'-addext', 'basicConstraints=critical,CA:TRUE',
|
|
58
|
-
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
|
|
59
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
60
|
-
const certExit = await certProc.exited;
|
|
61
|
-
if (certExit !== 0) {
|
|
62
|
-
const stderr = await new Response(certProc.stderr).text();
|
|
63
|
-
throw new Error(`Failed to generate CA cert: ${stderr}`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Set strict permissions
|
|
67
|
-
await Promise.all([
|
|
68
|
-
chmod(keyPath, 0o600),
|
|
69
|
-
chmod(certPath, 0o644),
|
|
70
|
-
]);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Issue a leaf certificate signed by the local CA for the given hostname.
|
|
75
|
-
* Caches issued certs in `{caDir}/issued/{hostname}.pem`.
|
|
76
|
-
* Returns PEM strings for the cert and key.
|
|
77
|
-
*/
|
|
78
|
-
export async function issueLeafCert(
|
|
79
|
-
caDir: string,
|
|
80
|
-
hostname: string,
|
|
81
|
-
): Promise<{ cert: string; key: string }> {
|
|
82
|
-
if (!HOSTNAME_RE.test(hostname)) {
|
|
83
|
-
throw new Error(`Invalid hostname: ${hostname}`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const issuedDir = join(caDir, ISSUED_DIR);
|
|
87
|
-
const leafCertPath = join(issuedDir, `${hostname}.pem`);
|
|
88
|
-
const leafKeyPath = join(issuedDir, `${hostname}-key.pem`);
|
|
89
|
-
|
|
90
|
-
// Return cached cert if it exists and is signed by the current CA
|
|
91
|
-
const [certExists, keyExists] = await Promise.all([
|
|
92
|
-
stat(leafCertPath).then(() => true, () => false),
|
|
93
|
-
stat(leafKeyPath).then(() => true, () => false),
|
|
94
|
-
]);
|
|
95
|
-
|
|
96
|
-
if (certExists && keyExists) {
|
|
97
|
-
const [cert, key, caCert] = await Promise.all([
|
|
98
|
-
readFile(leafCertPath, 'utf-8'),
|
|
99
|
-
readFile(leafKeyPath, 'utf-8'),
|
|
100
|
-
readFile(join(caDir, CA_CERT_FILENAME), 'utf-8'),
|
|
101
|
-
]);
|
|
102
|
-
|
|
103
|
-
// Verify cached cert was signed by the current CA, not a previous one
|
|
104
|
-
try {
|
|
105
|
-
const leaf = new X509Certificate(cert);
|
|
106
|
-
const ca = new X509Certificate(caCert);
|
|
107
|
-
if (leaf.checkIssued(ca)) {
|
|
108
|
-
return { cert, key };
|
|
109
|
-
}
|
|
110
|
-
} catch {
|
|
111
|
-
// Cert parsing failed — fall through to re-issue
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
await mkdir(issuedDir, { recursive: true });
|
|
116
|
-
|
|
117
|
-
const caCertPath = join(caDir, CA_CERT_FILENAME);
|
|
118
|
-
const caKeyPath = join(caDir, CA_KEY_FILENAME);
|
|
119
|
-
|
|
120
|
-
// Generate leaf key
|
|
121
|
-
const keyProc = Bun.spawn([
|
|
122
|
-
'openssl', 'genrsa', '-out', leafKeyPath, '2048',
|
|
123
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
124
|
-
const keyExit = await keyProc.exited;
|
|
125
|
-
if (keyExit !== 0) {
|
|
126
|
-
const stderr = await new Response(keyProc.stderr).text();
|
|
127
|
-
throw new Error(`Failed to generate leaf key for ${hostname}: ${stderr}`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Generate CSR
|
|
131
|
-
const csrPath = join(issuedDir, `${hostname}.csr`);
|
|
132
|
-
const csrProc = Bun.spawn([
|
|
133
|
-
'openssl', 'req', '-new',
|
|
134
|
-
'-key', leafKeyPath,
|
|
135
|
-
'-out', csrPath,
|
|
136
|
-
'-subj', `/CN=${hostname}`,
|
|
137
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
138
|
-
const csrExit = await csrProc.exited;
|
|
139
|
-
if (csrExit !== 0) {
|
|
140
|
-
const stderr = await new Response(csrProc.stderr).text();
|
|
141
|
-
throw new Error(`Failed to generate CSR for ${hostname}: ${stderr}`);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Write SAN extension config to a temp file
|
|
145
|
-
const extPath = join(issuedDir, `${hostname}-ext.cnf`);
|
|
146
|
-
await writeFile(extPath, `subjectAltName=DNS:${hostname}\n`);
|
|
147
|
-
|
|
148
|
-
// Sign with CA (valid 1 year)
|
|
149
|
-
const signProc = Bun.spawn([
|
|
150
|
-
'openssl', 'x509', '-req',
|
|
151
|
-
'-in', csrPath,
|
|
152
|
-
'-CA', caCertPath,
|
|
153
|
-
'-CAkey', caKeyPath,
|
|
154
|
-
'-CAcreateserial',
|
|
155
|
-
'-out', leafCertPath,
|
|
156
|
-
'-days', '365',
|
|
157
|
-
'-extfile', extPath,
|
|
158
|
-
], { stdout: 'pipe', stderr: 'pipe' });
|
|
159
|
-
const signExit = await signProc.exited;
|
|
160
|
-
if (signExit !== 0) {
|
|
161
|
-
const stderr = await new Response(signProc.stderr).text();
|
|
162
|
-
throw new Error(`Failed to sign leaf cert for ${hostname}: ${stderr}`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const [cert, key] = await Promise.all([
|
|
166
|
-
readFile(leafCertPath, 'utf-8'),
|
|
167
|
-
readFile(leafKeyPath, 'utf-8'),
|
|
168
|
-
]);
|
|
169
|
-
return { cert, key };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Create a combined CA bundle that includes both system root CAs and the
|
|
174
|
-
* proxy CA cert. This is needed for non-Node clients (curl, Python, Go)
|
|
175
|
-
* that don't honor NODE_EXTRA_CA_CERTS — they use SSL_CERT_FILE instead,
|
|
176
|
-
* which replaces (rather than supplements) the default CA bundle.
|
|
177
|
-
*
|
|
178
|
-
* Idempotent: skips regeneration if the combined bundle is newer than
|
|
179
|
-
* both the system bundle and the proxy CA cert.
|
|
180
|
-
*/
|
|
181
|
-
export async function ensureCombinedCABundle(dataDir: string): Promise<string | null> {
|
|
182
|
-
const caDir = join(dataDir, 'proxy-ca');
|
|
183
|
-
const caCertPath = join(caDir, CA_CERT_FILENAME);
|
|
184
|
-
const combinedPath = join(caDir, COMBINED_CA_FILENAME);
|
|
185
|
-
|
|
186
|
-
// Find the system CA bundle
|
|
187
|
-
let systemBundlePath: string | null = null;
|
|
188
|
-
for (const p of SYSTEM_CA_PATHS) {
|
|
189
|
-
try {
|
|
190
|
-
await stat(p);
|
|
191
|
-
systemBundlePath = p;
|
|
192
|
-
break;
|
|
193
|
-
} catch {
|
|
194
|
-
// not found, try next
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (!systemBundlePath) return null;
|
|
198
|
-
|
|
199
|
-
// Check if combined bundle already exists and is newer than both sources
|
|
200
|
-
try {
|
|
201
|
-
const [combinedSt, caSt, systemSt] = await Promise.all([
|
|
202
|
-
stat(combinedPath),
|
|
203
|
-
stat(caCertPath),
|
|
204
|
-
stat(systemBundlePath),
|
|
205
|
-
]);
|
|
206
|
-
if (combinedSt.mtimeMs > caSt.mtimeMs && combinedSt.mtimeMs > systemSt.mtimeMs) {
|
|
207
|
-
return combinedPath;
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// One or more files missing — fall through to create
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
const [systemCAs, proxyCACert] = await Promise.all([
|
|
215
|
-
readFile(systemBundlePath, 'utf-8'),
|
|
216
|
-
readFile(caCertPath, 'utf-8'),
|
|
217
|
-
]);
|
|
218
|
-
await writeFile(combinedPath, systemCAs + '\n' + proxyCACert);
|
|
219
|
-
return combinedPath;
|
|
220
|
-
} catch {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Return the path to the combined CA bundle for use as SSL_CERT_FILE.
|
|
227
|
-
*/
|
|
228
|
-
export function getCombinedCAPath(dataDir: string): string {
|
|
229
|
-
return join(dataDir, 'proxy-ca', COMBINED_CA_FILENAME);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Return the path to the local CA cert for use as NODE_EXTRA_CA_CERTS.
|
|
234
|
-
*/
|
|
235
|
-
export function getCAPath(dataDir: string): string {
|
|
236
|
-
return join(dataDir, 'proxy-ca', CA_CERT_FILENAME);
|
|
237
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
ensureCombinedCABundle,
|
|
3
|
+
ensureLocalCA,
|
|
4
|
+
getCAPath,
|
|
5
|
+
getCombinedCAPath,
|
|
6
|
+
issueLeafCert,
|
|
7
|
+
} from "@vellumai/proxy-sidecar";
|