@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
@@ -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. False for connectOverCDP browsers. */
159
+ /** Whether page.route() is supported. Always true for launched browsers. */
155
160
  get supportsRouteInterception(): boolean {
156
- // page.route() only works with launched browsers (test injection), not CDP-connected ones
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
- // Deterministic test mode: when launch is injected via setLaunchFn,
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
- // Try to detect an existing Chrome with --remote-debugging-port,
256
- // or ask the client to launch Chrome with CDP enabled.
257
- const sender = invokingSessionId
258
- ? this.sessionSenders.get(invokingSessionId)
259
- : undefined;
260
- let cdpAvailable = await this.detectCDP();
261
-
262
- if (!cdpAvailable && invokingSessionId && sender) {
263
- log.info(
264
- { sessionId: invokingSessionId },
265
- "Requesting CDP from client",
266
- );
267
- const accepted = await this.requestCDPFromClient(
268
- invokingSessionId,
269
- sender,
270
- );
271
- if (accepted) {
272
- cdpAvailable = await this.detectCDP();
273
- if (!cdpAvailable) {
274
- log.warn("Client accepted CDP request but CDP not detected");
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
- if (!cdpAvailable) {
283
- throw new Error(
284
- "Chrome with remote debugging is not available. Please launch Chrome with --remote-debugging-port=9222.",
285
- );
286
- }
287
-
288
- // Initialize auth session cache alongside browser context
289
- await authSessionCache.load();
290
-
291
- try {
292
- const pw = await import("playwright");
293
- const browser = await pw.chromium.connectOverCDP(this.cdpUrl, {
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(sessionId);
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
- let page: Page | undefined;
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
- // For launched browsers (not CDP-connected), create a page-level CDP session
400
- // so we can position the browser window. Browser domain commands (setWindowBounds,
401
- // getWindowForTarget) are accessible from page-level CDP sessions.
402
- if (!this.browserCdpSession && this._browserLaunched) {
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
- import { X509Certificate } from 'node:crypto';
2
- import { chmod,mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
-
5
- const CA_CERT_FILENAME = 'ca.pem';
6
- const CA_KEY_FILENAME = 'ca-key.pem';
7
- const COMBINED_CA_FILENAME = 'combined-ca-bundle.pem';
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";