libretto 0.6.7 → 0.6.8

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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Browser daemon process.
3
+ *
4
+ * Launched as a detached child process by `runOpen()` in `browser.ts`.
5
+ * Receives configuration as a JSON string in `process.argv[2]`.
6
+ *
7
+ * Responsibilities:
8
+ * - Launch Chromium with the specified settings
9
+ * - Create a browser context and page
10
+ * - Install session telemetry (network/action logging)
11
+ * - Navigate to the requested URL
12
+ * - Stay alive until the browser disconnects or a signal is received
13
+ */
14
+
15
+ import { chromium } from "playwright";
16
+ import { mkdir, unlink } from "node:fs/promises";
17
+ import { appendFileSync } from "node:fs";
18
+ import { installSessionTelemetry } from "./session-telemetry.js";
19
+ import {
20
+ getSessionDir,
21
+ getSessionLogsPath,
22
+ getSessionNetworkLogPath,
23
+ getSessionActionsLogPath,
24
+ getSessionStatePath,
25
+ } from "./context.js";
26
+
27
+ // ── Config schema ──────────────────────────────────────────────────────
28
+
29
+ type DaemonConfig = {
30
+ port: number;
31
+ url: string;
32
+ session: string;
33
+ headed: boolean;
34
+ viewport: { width: number; height: number };
35
+ storageStatePath?: string;
36
+ windowPosition?: { x: number; y: number };
37
+ };
38
+
39
+ const config: DaemonConfig = JSON.parse(process.argv[2]);
40
+
41
+ // ── Derived paths ──────────────────────────────────────────────────────
42
+
43
+ const sessionDir = getSessionDir(config.session);
44
+ await mkdir(sessionDir, { recursive: true });
45
+
46
+ const logFile = getSessionLogsPath(config.session);
47
+ const networkLogFile = getSessionNetworkLogPath(config.session);
48
+ const actionsLogFile = getSessionActionsLogPath(config.session);
49
+
50
+ type TelemetryEntry = Record<string, unknown>;
51
+
52
+ function childLog(
53
+ level: string,
54
+ event: string,
55
+ data: Record<string, unknown> = {},
56
+ ): void {
57
+ const entry = JSON.stringify({
58
+ timestamp: new Date().toISOString(),
59
+ id: Math.random().toString(36).slice(2, 10),
60
+ level,
61
+ scope: "libretto.child",
62
+ event,
63
+ data,
64
+ });
65
+ appendFileSync(logFile, entry + "\n");
66
+ }
67
+
68
+ function logAction(entry: TelemetryEntry): void {
69
+ appendFileSync(actionsLogFile, JSON.stringify(entry) + "\n");
70
+ }
71
+
72
+ function logNetwork(entry: TelemetryEntry): void {
73
+ appendFileSync(networkLogFile, JSON.stringify(entry) + "\n");
74
+ }
75
+
76
+ // ── Launch browser ─────────────────────────────────────────────────────
77
+
78
+ const windowPositionArg = config.windowPosition
79
+ ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
80
+ : undefined;
81
+
82
+ const launchArgs = [
83
+ "--disable-blink-features=AutomationControlled",
84
+ `--remote-debugging-port=${config.port}`,
85
+ "--remote-debugging-address=127.0.0.1",
86
+ "--no-focus-on-check",
87
+ ...(windowPositionArg ? [windowPositionArg] : []),
88
+ ];
89
+
90
+ const browser = await chromium.launch({
91
+ headless: !config.headed,
92
+ args: launchArgs,
93
+ });
94
+
95
+ async function cleanupSessionState(): Promise<void> {
96
+ const sessionStatePath = getSessionStatePath(config.session);
97
+ try {
98
+ await unlink(sessionStatePath);
99
+ } catch (err) {
100
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
101
+ }
102
+ }
103
+
104
+ let shuttingDown = false;
105
+ let wakeDaemon: () => void;
106
+ const sleepPromise = new Promise<void>((resolve) => {
107
+ wakeDaemon = resolve;
108
+ });
109
+
110
+ async function shutdown(
111
+ reason: string,
112
+ closeBrowser: boolean,
113
+ ): Promise<void> {
114
+ if (shuttingDown) return;
115
+ shuttingDown = true;
116
+ try {
117
+ childLog("info", reason, { port: config.port });
118
+ await cleanupSessionState();
119
+ if (closeBrowser) await browser.close();
120
+ } finally {
121
+ wakeDaemon();
122
+ }
123
+ }
124
+
125
+ browser.on("disconnected", () => {
126
+ void shutdown("browser-disconnected-exiting", false);
127
+ });
128
+
129
+ // ── Create context & page ──────────────────────────────────────────────
130
+
131
+ const context = await browser.newContext({
132
+ ...(config.storageStatePath ? { storageState: config.storageStatePath } : {}),
133
+ viewport: {
134
+ width: config.viewport.width,
135
+ height: config.viewport.height,
136
+ },
137
+ userAgent:
138
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
139
+ });
140
+
141
+ const page = await context.newPage();
142
+
143
+ // ── Page defaults & telemetry ──────────────────────────────────────────
144
+
145
+ page.setDefaultTimeout(30000);
146
+ page.setDefaultNavigationTimeout(45000);
147
+
148
+ await installSessionTelemetry({
149
+ context,
150
+ initialPage: page,
151
+ includeUserDomActions: true,
152
+ logAction,
153
+ logNetwork,
154
+ });
155
+
156
+ // ── Navigate ───────────────────────────────────────────────────────────
157
+
158
+ await page.goto(config.url);
159
+
160
+ // ── Process lifecycle ──────────────────────────────────────────────────
161
+
162
+ process.on("SIGTERM", () => {
163
+ void shutdown("child-sigterm", true);
164
+ });
165
+
166
+ process.on("SIGINT", () => {
167
+ void shutdown("child-sigint", true);
168
+ });
169
+
170
+ process.on("uncaughtException", (err) => {
171
+ childLog("error", "uncaught-exception", {
172
+ message: err.message,
173
+ stack: err.stack,
174
+ });
175
+ process.exit(1);
176
+ });
177
+
178
+ process.on("unhandledRejection", (reason) => {
179
+ childLog("warn", "unhandled-rejection", { reason: String(reason) });
180
+ });
181
+
182
+ process.on("exit", (code) => {
183
+ childLog("info", "child-exit", {
184
+ code,
185
+ pid: process.pid,
186
+ port: config.port,
187
+ });
188
+ });
189
+
190
+ childLog("info", "child-launched", {
191
+ port: config.port,
192
+ pid: process.pid,
193
+ session: config.session,
194
+ });
195
+
196
+ // Keep the daemon alive until the browser disconnects or a signal arrives.
197
+ await sleepPromise;
198
+ process.exit(0);
@@ -5,27 +5,15 @@ import {
5
5
  type CDPSession,
6
6
  type Page,
7
7
  } from "playwright";
8
- import { openSync, existsSync, writeFileSync } from "node:fs";
9
- import { basename, dirname, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
8
+ import { openSync, closeSync, existsSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { createRequire } from "node:module";
12
12
  import { createServer } from "node:net";
13
13
  import { spawn } from "node:child_process";
14
14
  import type { LoggerApi } from "../../shared/logger/index.js";
15
15
  import type { SessionAccessMode } from "../../shared/state/index.js";
16
- import {
17
- filterSemanticClasses,
18
- INTERACTIVE_ROLE_NAMES,
19
- INTERACTIVE_TAG_NAMES,
20
- isObfuscatedClass,
21
- TEST_ATTRIBUTE_NAMES,
22
- TRUSTED_ATTRIBUTE_NAMES,
23
- } from "../../shared/dom-semantics.js";
24
- import {
25
- getSessionActionsLogPath,
26
- getSessionNetworkLogPath,
27
- PROFILES_DIR,
28
- } from "./context.js";
16
+ import { PROFILES_DIR } from "./context.js";
29
17
  import { readLibrettoConfig } from "./config.js";
30
18
  import {
31
19
  assertSessionAvailableForStart,
@@ -39,7 +27,6 @@ import {
39
27
  } from "./session.js";
40
28
  import type { ProviderApi } from "./providers/types.js";
41
29
  import { getCloudProviderApi } from "./providers/index.js";
42
- import { installSessionTelemetry } from "./session-telemetry.js";
43
30
 
44
31
  const CLOSE_WAIT_MS = 1_500;
45
32
  const FORCE_CLOSE_WAIT_MS = 300;
@@ -434,8 +421,6 @@ export async function runOpen(
434
421
 
435
422
  const port = await pickFreePort();
436
423
  const runLogPath = logFileForSession(session);
437
- const networkLogPath = getSessionNetworkLogPath(session);
438
- const actionsLogPath = getSessionActionsLogPath(session);
439
424
 
440
425
  const browserMode = headed ? "headed" : "headless";
441
426
  const supportsSavedProfile =
@@ -459,178 +444,33 @@ export async function runOpen(
459
444
  }
460
445
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
461
446
 
462
- const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
463
- const storageStateCode = useProfile
464
- ? `storageState: '${profilePath!
465
- .replace(/\\/g, "\\\\")
466
- .replace(/'/g, "\\'")}',`
467
- : "";
468
-
469
- const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
470
- const escapedNetworkLogPath = networkLogPath
471
- .replace(/\\/g, "\\\\")
472
- .replace(/'/g, "\\'");
473
- const escapedActionsLogPath = actionsLogPath
474
- .replace(/\\/g, "\\\\")
475
- .replace(/'/g, "\\'");
476
- const windowPositionArg = windowPosition
477
- ? `, '--window-position=${windowPosition.x},${windowPosition.y}'`
478
- : "";
479
- const windowBoundsSetupCode = windowPosition
480
- ? `
481
- const requestedWindowBounds = { left: ${windowPosition.x}, top: ${windowPosition.y}, windowState: 'normal' };
482
- const pageCdp = await context.newCDPSession(page);
483
- let browserCdp;
484
- try {
485
- const targetInfo = await pageCdp.send('Target.getTargetInfo');
486
- const targetId = targetInfo?.targetInfo?.targetId;
487
- browserCdp = await browser.newBrowserCDPSession();
488
- const windowResult = await browserCdp.send(
489
- 'Browser.getWindowForTarget',
490
- targetId ? { targetId } : {},
491
- );
492
- await browserCdp.send('Browser.setWindowBounds', {
493
- windowId: windowResult.windowId,
494
- bounds: requestedWindowBounds,
495
- });
496
- await new Promise((resolve) => setTimeout(resolve, 250));
497
- const actualWindow = await browserCdp.send('Browser.getWindowBounds', {
498
- windowId: windowResult.windowId,
499
- });
500
- childLog('info', 'window-bounds-set', {
501
- windowId: windowResult.windowId,
502
- requestedBounds: requestedWindowBounds,
503
- actualBounds: actualWindow.bounds,
504
- });
505
- } catch (error) {
506
- childLog('warn', 'window-bounds-set-failed', {
507
- requestedBounds: requestedWindowBounds,
508
- message: error instanceof Error ? error.message : String(error),
509
- stack: error instanceof Error ? error.stack : undefined,
510
- });
511
- } finally {
512
- await pageCdp.detach().catch(() => {});
513
- if (browserCdp) {
514
- await browserCdp.detach().catch(() => {});
515
- }
516
- }
517
- `
518
- : "";
519
-
520
- const launcherCode = `
521
- import { chromium } from 'playwright';
522
- import { appendFileSync, mkdirSync } from 'node:fs';
523
- import { dirname } from 'node:path';
524
-
525
- const LOG_FILE = '${escapedLogPath}';
526
- const NETWORK_LOG = '${escapedNetworkLogPath}';
527
- const ACTIONS_LOG = '${escapedActionsLogPath}';
528
- mkdirSync(dirname(NETWORK_LOG), { recursive: true });
529
-
530
- // tsx/esbuild may emit __name() wrappers in Function#toString output.
531
- const __name = (target, value) =>
532
- Object.defineProperty(target, 'name', { value, configurable: true });
533
-
534
- const TEST_ATTRIBUTE_NAMES = ${JSON.stringify([...TEST_ATTRIBUTE_NAMES])};
535
- const TRUSTED_ATTRIBUTE_NAMES = ${JSON.stringify([...TRUSTED_ATTRIBUTE_NAMES])};
536
- const INTERACTIVE_TAG_NAMES = ${JSON.stringify([...INTERACTIVE_TAG_NAMES])};
537
- const INTERACTIVE_ROLE_NAMES = ${JSON.stringify([...INTERACTIVE_ROLE_NAMES])};
538
- const filterSemanticClasses = ${filterSemanticClasses.toString()};
539
- const isObfuscatedClass = ${isObfuscatedClass.toString()};
540
-
541
- ${installSessionTelemetry.toString()}
542
-
543
- function logAction(entry) {
544
- appendFileSync(ACTIONS_LOG, JSON.stringify(entry) + '\\n');
545
- }
546
-
547
- function logNetwork(entry) {
548
- appendFileSync(NETWORK_LOG, JSON.stringify(entry) + '\\n');
549
- }
550
-
551
- function childLog(level, event, data = {}) {
552
- try {
553
- const entry = JSON.stringify({
554
- timestamp: new Date().toISOString(),
555
- id: Math.random().toString(36).slice(2, 10),
556
- level,
557
- scope: 'libretto.child',
558
- event,
559
- data,
560
- });
561
- appendFileSync(LOG_FILE, entry + '\\n');
562
- } catch {}
563
- }
564
-
565
- const browser = await chromium.launch({
566
- headless: ${!headed},
567
- args: ['--disable-blink-features=AutomationControlled', '--remote-debugging-port=${port}', '--remote-debugging-address=127.0.0.1', '--no-focus-on-check'${windowPositionArg}],
568
- });
569
-
570
- browser.on('disconnected', () => {
571
- childLog('warn', 'browser-disconnected', { port: ${port} });
572
- });
573
-
574
- const context = await browser.newContext({
575
- ${storageStateCode}
576
- viewport: { width: ${viewport.width}, height: ${viewport.height} },
577
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
578
- });
579
-
580
- const page = await context.newPage();
581
- ${windowBoundsSetupCode}
582
- page.setDefaultTimeout(30000);
583
- page.setDefaultNavigationTimeout(45000);
584
-
585
- await installSessionTelemetry({
586
- context,
587
- initialPage: page,
588
- includeUserDomActions: true,
589
- logAction,
590
- logNetwork,
591
- });
592
-
593
-
594
- await page.goto('${escapedUrl}');
595
-
596
- process.on('SIGTERM', async () => {
597
- childLog('info', 'child-sigterm');
598
- await browser.close();
599
- process.exit(0);
600
- });
601
-
602
- process.on('SIGINT', async () => {
603
- childLog('info', 'child-sigint');
604
- await browser.close();
605
- process.exit(0);
606
- });
607
-
608
- process.on('uncaughtException', (err) => {
609
- childLog('error', 'uncaught-exception', { message: err.message, stack: err.stack });
610
- process.exit(1);
611
- });
612
-
613
- process.on('unhandledRejection', (reason) => {
614
- childLog('warn', 'unhandled-rejection', { reason: String(reason) });
615
- });
616
-
617
- process.on('exit', (code) => {
618
- childLog('info', 'child-exit', { code, pid: process.pid, port: ${port} });
619
- });
620
-
621
- childLog('info', 'child-launched', { port: ${port}, pid: process.pid, session: '${session}' });
622
-
623
- await new Promise(() => {});
624
- `;
447
+ const daemonEntryPath = fileURLToPath(
448
+ new URL("./browser-daemon.js", import.meta.url),
449
+ );
450
+ const require = createRequire(import.meta.url);
451
+ const tsxImportPath = pathToFileURL(require.resolve("tsx/esm")).href;
452
+ const daemonConfig = {
453
+ port,
454
+ url,
455
+ session,
456
+ headed,
457
+ viewport,
458
+ storageStatePath: useProfile ? profilePath : undefined,
459
+ windowPosition,
460
+ };
625
461
 
626
462
  const childStderrFd = openSync(runLogPath, "a");
627
463
 
628
- const child = spawn("node", ["--input-type=module", "-e", launcherCode], {
629
- detached: true,
630
- stdio: ["ignore", "ignore", childStderrFd],
631
- cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../.."),
632
- });
464
+ const child = spawn(
465
+ process.execPath,
466
+ ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
467
+ {
468
+ detached: true,
469
+ stdio: ["ignore", "ignore", childStderrFd],
470
+ },
471
+ );
633
472
  child.unref();
473
+ closeSync(childStderrFd);
634
474
 
635
475
  logger.info("open-child-spawned", { pid: child.pid, port, session });
636
476
 
@@ -67,7 +67,7 @@ function invalidConfigError(configPath: string, detail?: string): Error {
67
67
  ' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
68
68
  ' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
69
69
  "Fix the file to match this shape, or delete it and rerun:",
70
- ` npx libretto ai configure openai | anthropic | gemini | vertex`,
70
+ ` npx libretto ai configure openai | anthropic | gemini | vertex | openrouter`,
71
71
  ]
72
72
  .filter(Boolean)
73
73
  .join("\n"),
@@ -13,6 +13,8 @@ export function createLibrettoCloudProvider(): ProviderApi {
13
13
  );
14
14
  const endpoint = apiUrl.replace(/\/$/, "");
15
15
 
16
+ // The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
17
+ // must be wrapped as { json: ... } and outputs arrive the same way.
16
18
  return {
17
19
  async createSession() {
18
20
  const timeoutSeconds = Number(
@@ -24,7 +26,9 @@ export function createLibrettoCloudProvider(): ProviderApi {
24
26
  "x-api-key": apiKey,
25
27
  "Content-Type": "application/json",
26
28
  },
27
- body: JSON.stringify({ timeout_seconds: timeoutSeconds }),
29
+ body: JSON.stringify({
30
+ json: { timeout_seconds: timeoutSeconds },
31
+ }),
28
32
  });
29
33
  if (!resp.ok) {
30
34
  const body = await resp.text();
@@ -32,9 +36,8 @@ export function createLibrettoCloudProvider(): ProviderApi {
32
36
  `Libretto Cloud API error (${resp.status}): ${body}`,
33
37
  );
34
38
  }
35
- const json = (await resp.json()) as {
36
- session_id: string;
37
- cdp_url: string;
39
+ const { json } = (await resp.json()) as {
40
+ json: { session_id: string; cdp_url: string };
38
41
  };
39
42
  return {
40
43
  sessionId: json.session_id,
@@ -48,7 +51,7 @@ export function createLibrettoCloudProvider(): ProviderApi {
48
51
  "x-api-key": apiKey,
49
52
  "Content-Type": "application/json",
50
53
  },
51
- body: JSON.stringify({ session_id: sessionId }),
54
+ body: JSON.stringify({ json: { session_id: sessionId } }),
52
55
  });
53
56
  if (!resp.ok) {
54
57
  const body = await resp.text();
@@ -1,6 +1,6 @@
1
1
  import type { LanguageModel } from "ai";
2
2
 
3
- export type Provider = "google" | "vertex" | "anthropic" | "openai";
3
+ export type Provider = "google" | "vertex" | "anthropic" | "openai" | "openrouter";
4
4
 
5
5
  const GEMINI_API_KEY_ENV_VARS = [
6
6
  "GEMINI_API_KEY",
@@ -19,6 +19,7 @@ const SUPPORTED_PROVIDER_ALIASES = {
19
19
  anthropic: "anthropic",
20
20
  codex: "openai",
21
21
  openai: "openai",
22
+ openrouter: "openrouter",
22
23
  } as const satisfies Record<string, Provider>;
23
24
 
24
25
  function readFirstEnvValue(
@@ -51,7 +52,7 @@ export function parseModel(model: string): {
51
52
 
52
53
  if (!provider) {
53
54
  throw new Error(
54
- `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), and vertex.`,
55
+ `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), vertex, and openrouter.`,
55
56
  );
56
57
  }
57
58
 
@@ -71,6 +72,8 @@ export function hasProviderCredentials(
71
72
  return Boolean(env.ANTHROPIC_API_KEY?.trim());
72
73
  case "openai":
73
74
  return Boolean(env.OPENAI_API_KEY?.trim());
75
+ case "openrouter":
76
+ return Boolean(env.OPENROUTER_API_KEY?.trim());
74
77
  }
75
78
  }
76
79
 
@@ -86,6 +89,9 @@ export function missingProviderCredentialsMessage(provider: Provider): string {
86
89
  case "openai": {
87
90
  return "OpenAI API key is missing. Set OPENAI_API_KEY.";
88
91
  }
92
+ case "openrouter": {
93
+ return "OpenRouter API key is missing. Set OPENROUTER_API_KEY.";
94
+ }
89
95
  }
90
96
  }
91
97
 
@@ -133,6 +139,18 @@ async function getProviderModel(
133
139
  const openai = createOpenAI({ apiKey });
134
140
  return openai(modelId);
135
141
  }
142
+ case "openrouter": {
143
+ const apiKey = process.env.OPENROUTER_API_KEY?.trim();
144
+ if (!apiKey) {
145
+ throw new Error(missingProviderCredentialsMessage(provider));
146
+ }
147
+ const { createOpenAI } = await import("@ai-sdk/openai");
148
+ const openrouter = createOpenAI({
149
+ apiKey,
150
+ baseURL: "https://openrouter.ai/api/v1",
151
+ });
152
+ return openrouter(modelId);
153
+ }
136
154
  }
137
155
  }
138
156
 
@@ -269,6 +269,16 @@ async function runIntegrationInternal(
269
269
  },
270
270
  });
271
271
 
272
+ // tsx/esbuild injects __name() wrappers when keepNames is true. Playwright
273
+ // serializes callbacks via Function#toString() into the browser context which
274
+ // lacks __name, causing ReferenceError. Inject a no-op polyfill into every page.
275
+ await browserSession.context.addInitScript(() => {
276
+ (globalThis as Record<string, unknown>).__name = (
277
+ target: unknown,
278
+ value: string,
279
+ ) => Object.defineProperty(target as object, "name", { value, configurable: true });
280
+ });
281
+
272
282
  const workflowContext: LibrettoWorkflowContext = {
273
283
  session: args.session,
274
284
  page: browserSession.page,
@@ -58,7 +58,6 @@ export function isObfuscatedClass(cls: string): boolean {
58
58
  if (cls.length > 80) return true;
59
59
  if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
60
60
  if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
61
- if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
62
61
 
63
62
  const digits = (cls.match(/[0-9]/g) || []).length;
64
63
  const letters = (cls.match(/[a-zA-Z]/g) || []).length;