libretto 0.6.9 → 0.6.10

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 (60) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +99 -136
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +128 -202
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. package/src/cli/core/browser-daemon.ts +0 -198
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Browser daemon process.
3
+ *
4
+ * Launched as a detached child process by `runOpen()` or `runConnect()` in
5
+ * `browser.ts`. Receives configuration as a JSON string in `process.argv[2]`.
6
+ *
7
+ * Two modes:
8
+ * - **Launch** (`libretto open`): launches Chromium, owns the browser
9
+ * lifecycle, and closes it on shutdown.
10
+ * - **Connect** (`libretto connect`): connects to an existing CDP endpoint,
11
+ * discovers pages, and disconnects (without closing the browser) on
12
+ * shutdown. The browser is externally managed.
13
+ *
14
+ * In both modes the daemon:
15
+ * - Installs session telemetry (network/action logging)
16
+ * - Serves IPC commands (exec, readonly-exec, pages, snapshot) over a
17
+ * Unix domain socket
18
+ * - Stays alive until the browser disconnects or a signal is received
19
+ */
20
+
21
+ import {
22
+ chromium,
23
+ type Browser,
24
+ type BrowserContext,
25
+ type Page,
26
+ } from "playwright";
27
+ import { mkdir } from "node:fs/promises";
28
+ import { appendFileSync } from "node:fs";
29
+ import { installSessionTelemetry } from "../session-telemetry.js";
30
+ import {
31
+ createLoggerForSession,
32
+ getSessionDir,
33
+ getSessionNetworkLogPath,
34
+ getSessionActionsLogPath,
35
+ } from "../context.js";
36
+ import type { LoggerApi } from "../../../shared/logger/index.js";
37
+ import {
38
+ DaemonServer,
39
+ getDaemonSocketPath,
40
+ type DaemonRequest,
41
+ } from "./ipc.js";
42
+ import { wrapPageForActionLogging } from "../telemetry.js";
43
+ import { handlePages } from "./pages.js";
44
+ import { handleExec, handleReadonlyExec } from "./exec.js";
45
+ import { handleSnapshot } from "./snapshot.js";
46
+ import {
47
+ isConnectConfig,
48
+ type DaemonConfig,
49
+ type DaemonLaunchConfig,
50
+ type DaemonConnectConfig,
51
+ } from "./config.js";
52
+
53
+ function isOperationalPage(page: Page): boolean {
54
+ const url = page.url();
55
+ return !url.startsWith("devtools://") && !url.startsWith("chrome-error://");
56
+ }
57
+
58
+ type TelemetryEntry = Record<string, unknown>;
59
+
60
+ // ── BrowserDaemon ──────────────────────────────────────────────────────
61
+
62
+ const PROTOCOL_VERSION = 1;
63
+ const REQUEST_TIMEOUT_MS = 60_000;
64
+
65
+ class BrowserDaemon {
66
+ readonly logger: LoggerApi;
67
+ private readonly execState: Record<string, unknown> = {};
68
+ private readonly pageById = new Map<string, Page>();
69
+
70
+ private constructor(
71
+ private readonly session: string,
72
+ private readonly externallyManaged: boolean,
73
+ private readonly browser: Browser,
74
+ private readonly context: BrowserContext,
75
+ private readonly page: Page,
76
+ private readonly ipcServer: DaemonServer,
77
+ logger: LoggerApi,
78
+ ) {
79
+ this.logger = logger.withScope("child");
80
+ }
81
+
82
+ private shuttingDown = false;
83
+
84
+ private trackPage(page: Page): string {
85
+ const id = `page-${Math.random().toString(36).slice(2, 5)}`;
86
+ this.pageById.set(id, page);
87
+ page.on("close", () => this.pageById.delete(id));
88
+ return id;
89
+ }
90
+
91
+ // ── Shared initialization ──────────────────────────────────────────
92
+
93
+ /**
94
+ * Common setup after the mode-specific code has obtained a browser,
95
+ * context, and page(s). Installs telemetry, action logging, IPC
96
+ * server, page tracking, and the browser disconnect handler.
97
+ */
98
+ private static async initialize(args: {
99
+ session: string;
100
+ externallyManaged: boolean;
101
+ browser: Browser;
102
+ context: BrowserContext;
103
+ page: Page;
104
+ initialPages: Page[];
105
+ /** If set, navigate to this URL after telemetry but before starting IPC. */
106
+ navigateUrl?: string;
107
+ }): Promise<BrowserDaemon> {
108
+ const {
109
+ session,
110
+ externallyManaged,
111
+ browser,
112
+ context,
113
+ page,
114
+ initialPages,
115
+ navigateUrl,
116
+ } = args;
117
+
118
+ await mkdir(getSessionDir(session), { recursive: true });
119
+
120
+ // Telemetry — may fail on connect-mode reconnections where
121
+ // exposeFunction bindings already exist; log and continue.
122
+ const networkLogFile = getSessionNetworkLogPath(session);
123
+ const actionsLogFile = getSessionActionsLogPath(session);
124
+ const logger = createLoggerForSession(session);
125
+
126
+ try {
127
+ await installSessionTelemetry({
128
+ context,
129
+ initialPage: page,
130
+ includeUserDomActions: true,
131
+ logAction: (entry: TelemetryEntry) => {
132
+ appendFileSync(actionsLogFile, JSON.stringify(entry) + "\n");
133
+ },
134
+ logNetwork: (entry: TelemetryEntry) => {
135
+ appendFileSync(networkLogFile, JSON.stringify(entry) + "\n");
136
+ },
137
+ });
138
+ } catch (err) {
139
+ logger.warn("telemetry-install-failed", {
140
+ session,
141
+ error: err instanceof Error ? err.message : String(err),
142
+ });
143
+ }
144
+
145
+ // IPC server — handler is wired after construction to avoid a
146
+ // circular type inference issue (daemon references itself).
147
+ const socketPath = getDaemonSocketPath(session);
148
+ let handler: (request: DaemonRequest) => Promise<unknown>;
149
+ const ipcServer = new DaemonServer(socketPath, (request) =>
150
+ handler(request),
151
+ );
152
+ const daemon = new BrowserDaemon(
153
+ session,
154
+ externallyManaged,
155
+ browser,
156
+ context,
157
+ page,
158
+ ipcServer,
159
+ logger,
160
+ );
161
+
162
+ // Action logging and page tracking must be registered before optional
163
+ // navigation so popups opened during the initial load are visible to IPC.
164
+ for (const p of initialPages) {
165
+ wrapPageForActionLogging(p, session);
166
+ daemon.trackPage(p);
167
+ }
168
+ context.on("page", (newPage) => {
169
+ wrapPageForActionLogging(newPage, session);
170
+ daemon.trackPage(newPage);
171
+ });
172
+
173
+ // Navigate after telemetry is installed (so we capture the initial
174
+ // page load) but before starting the IPC server (so callers polling
175
+ // for IPC readiness see a page that has already loaded).
176
+ if (navigateUrl) {
177
+ await page.goto(navigateUrl);
178
+ }
179
+
180
+ handler = (request) => daemon.handleRequest(request);
181
+ await ipcServer.listen();
182
+ daemon.logger.info("ipc-server-listening", { socketPath });
183
+
184
+ browser.on("disconnected", () => {
185
+ void daemon.shutdown("browser-disconnected-exiting", false);
186
+ });
187
+
188
+ return daemon;
189
+ }
190
+
191
+ // ── Launch mode ────────────────────────────────────────────────────
192
+
193
+ static async launchBrowser(config: DaemonLaunchConfig): Promise<BrowserDaemon> {
194
+ const windowPositionArg = config.windowPosition
195
+ ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
196
+ : undefined;
197
+
198
+ const browser = await chromium.launch({
199
+ headless: !config.headed,
200
+ args: [
201
+ "--disable-blink-features=AutomationControlled",
202
+ `--remote-debugging-port=${config.port}`,
203
+ "--remote-debugging-address=127.0.0.1",
204
+ "--no-focus-on-check",
205
+ ...(windowPositionArg ? [windowPositionArg] : []),
206
+ ],
207
+ });
208
+
209
+ const context = await browser.newContext({
210
+ ...(config.storageStatePath
211
+ ? { storageState: config.storageStatePath }
212
+ : {}),
213
+ viewport: {
214
+ width: config.viewport.width,
215
+ height: config.viewport.height,
216
+ },
217
+ userAgent:
218
+ "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",
219
+ });
220
+
221
+ const page = await context.newPage();
222
+ page.setDefaultTimeout(30000);
223
+ page.setDefaultNavigationTimeout(45000);
224
+
225
+ const daemon = await BrowserDaemon.initialize({
226
+ session: config.session,
227
+ externallyManaged: false,
228
+ browser,
229
+ context,
230
+ page,
231
+ initialPages: [page],
232
+ navigateUrl: config.url,
233
+ });
234
+
235
+ daemon.logger.info("child-launched", {
236
+ port: config.port,
237
+ pid: process.pid,
238
+ session: config.session,
239
+ });
240
+
241
+ return daemon;
242
+ }
243
+
244
+ // ── Connect mode ───────────────────────────────────────────────────
245
+
246
+ static async connectToEndpoint(
247
+ config: DaemonConnectConfig,
248
+ ): Promise<BrowserDaemon> {
249
+ const browser = await chromium.connectOverCDP(config.cdpEndpoint);
250
+
251
+ // Discover existing contexts and pages.
252
+ const contexts = browser.contexts();
253
+ const context =
254
+ contexts.length > 0 ? contexts[0] : await browser.newContext();
255
+ const operationalPages = context.pages().filter(isOperationalPage);
256
+ const page =
257
+ operationalPages.length > 0
258
+ ? operationalPages[operationalPages.length - 1]
259
+ : await context.newPage();
260
+
261
+ const daemon = await BrowserDaemon.initialize({
262
+ session: config.session,
263
+ externallyManaged: true,
264
+ browser,
265
+ context,
266
+ page,
267
+ initialPages:
268
+ operationalPages.length > 0 ? operationalPages : [page],
269
+ navigateUrl: config.url,
270
+ });
271
+
272
+ daemon.logger.info("child-connected", {
273
+ cdpEndpoint: config.cdpEndpoint,
274
+ url: config.url,
275
+ pid: process.pid,
276
+ session: config.session,
277
+ });
278
+
279
+ return daemon;
280
+ }
281
+
282
+ // ── Lifecycle ──────────────────────────────────────────────────────
283
+
284
+ async shutdown(reason: string, closeBrowser: boolean): Promise<void> {
285
+ if (this.shuttingDown) return;
286
+ this.shuttingDown = true;
287
+ this.logger.info(reason, { session: this.session });
288
+ await this.ipcServer.close();
289
+ if (!closeBrowser) return;
290
+ if (this.externallyManaged) {
291
+ // Drop the CDP pipe without killing the external browser.
292
+ try {
293
+ (
294
+ this.browser as unknown as {
295
+ _connection?: { close(): void };
296
+ }
297
+ )._connection?.close();
298
+ } catch {
299
+ // Connection may already be closed.
300
+ }
301
+ } else {
302
+ await this.browser.close();
303
+ }
304
+ }
305
+
306
+ // ── Page resolution ────────────────────────────────────────────────
307
+
308
+ private resolveTargetPage(pageId?: string): Page {
309
+ if (!pageId) {
310
+ if (this.pageById.size > 1) {
311
+ throw new Error(
312
+ `Multiple pages are open in session "${this.session}". Pass --page <id> to target a page (run "libretto pages --session ${this.session}" to list ids).`,
313
+ );
314
+ }
315
+ // Return the single tracked page rather than `this.page` — the
316
+ // initial page may have been closed and replaced by a new one.
317
+ if (this.pageById.size === 1) {
318
+ return this.pageById.values().next().value!;
319
+ }
320
+ return this.page;
321
+ }
322
+ const page = this.pageById.get(pageId);
323
+ if (!page) {
324
+ throw new Error(
325
+ `Page "${pageId}" was not found in session "${this.session}". Run "libretto pages --session ${this.session}" to list ids.`,
326
+ );
327
+ }
328
+ return page;
329
+ }
330
+
331
+ // ── IPC handler ────────────────────────────────────────────────────
332
+
333
+ private async handleRequest(request: DaemonRequest): Promise<unknown> {
334
+ if (request.command === "ping") {
335
+ return { protocolVersion: PROTOCOL_VERSION };
336
+ }
337
+
338
+ // All non-ping commands get a timeout guard. The timer is cleared
339
+ // when the command settles to avoid orphaned timers that would
340
+ // keep the event loop alive after shutdown.
341
+ let timerId: ReturnType<typeof setTimeout>;
342
+ return Promise.race([
343
+ this.dispatchCommand(request).finally(() => clearTimeout(timerId)),
344
+ new Promise<never>((_resolve, reject) => {
345
+ timerId = setTimeout(
346
+ () =>
347
+ reject(
348
+ new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`),
349
+ ),
350
+ REQUEST_TIMEOUT_MS,
351
+ );
352
+ }),
353
+ ]);
354
+ }
355
+
356
+ private async dispatchCommand(request: DaemonRequest): Promise<unknown> {
357
+ switch (request.command) {
358
+ case "pages":
359
+ return handlePages(this.pageById, this.page);
360
+ case "exec":
361
+ return handleExec(
362
+ this.resolveTargetPage(request.pageId),
363
+ request.code,
364
+ this.context,
365
+ this.browser,
366
+ this.execState,
367
+ this.session,
368
+ request.visualize,
369
+ );
370
+ case "readonly-exec":
371
+ return handleReadonlyExec(
372
+ this.resolveTargetPage(request.pageId),
373
+ request.code,
374
+ );
375
+ case "snapshot":
376
+ return handleSnapshot(
377
+ this.resolveTargetPage(request.pageId),
378
+ this.session,
379
+ this.logger,
380
+ request.pageId,
381
+ );
382
+ default:
383
+ throw new Error(
384
+ `Unknown command: ${(request as { command: string }).command}`,
385
+ );
386
+ }
387
+ }
388
+ }
389
+
390
+ // ── Main ───────────────────────────────────────────────────────────────
391
+
392
+ async function main(): Promise<void> {
393
+ const config = JSON.parse(process.argv[2]) as DaemonConfig;
394
+
395
+ const daemon = isConnectConfig(config)
396
+ ? await BrowserDaemon.connectToEndpoint(config)
397
+ : await BrowserDaemon.launchBrowser(config);
398
+
399
+ process.on("SIGTERM", () => {
400
+ void daemon.shutdown("child-sigterm", true);
401
+ });
402
+
403
+ process.on("SIGINT", () => {
404
+ void daemon.shutdown("child-sigint", true);
405
+ });
406
+
407
+ process.on("uncaughtException", (err) => {
408
+ daemon.logger.error("uncaught-exception", err);
409
+ process.exit(1);
410
+ });
411
+
412
+ process.on("unhandledRejection", (reason) => {
413
+ daemon.logger.warn("unhandled-rejection", { reason: String(reason) });
414
+ });
415
+
416
+ process.on("exit", (code) => {
417
+ daemon.logger.info("child-exit", {
418
+ code,
419
+ pid: process.pid,
420
+ session: config.session,
421
+ });
422
+ });
423
+
424
+ // The process stays alive as long as the IPC server and browser
425
+ // connection hold the event loop open. shutdown() closes both,
426
+ // letting the process exit naturally.
427
+ }
428
+
429
+ await main();
@@ -0,0 +1,128 @@
1
+ import type { Browser, BrowserContext, Page } from "playwright";
2
+ import { format, formatWithOptions, type InspectOptions } from "node:util";
3
+ import { installInstrumentation } from "../../../shared/instrumentation/index.js";
4
+ import { compileExecFunction } from "../exec-compiler.js";
5
+ import { createReadonlyExecHelpers } from "../readonly-exec.js";
6
+ import { readNetworkLog, readActionLog } from "../telemetry.js";
7
+
8
+ type ExecOutput = {
9
+ stdout: string;
10
+ stderr: string;
11
+ };
12
+
13
+ export class DaemonExecError extends Error {
14
+ constructor(
15
+ message: string,
16
+ readonly output: ExecOutput,
17
+ ) {
18
+ super(message);
19
+ this.name = "DaemonExecError";
20
+ }
21
+ }
22
+
23
+ type ExecResponse = {
24
+ result: unknown;
25
+ output: ExecOutput;
26
+ };
27
+
28
+ function createBufferedConsole(): { console: Console; output: ExecOutput } {
29
+ const output: ExecOutput = { stdout: "", stderr: "" };
30
+ const writeStdout = (...args: unknown[]) => {
31
+ output.stdout += `${format(...args)}\n`;
32
+ };
33
+ const writeStderr = (...args: unknown[]) => {
34
+ output.stderr += `${format(...args)}\n`;
35
+ };
36
+
37
+ const bufferedConsole = {
38
+ ...globalThis.console,
39
+ log: writeStdout,
40
+ info: writeStdout,
41
+ debug: writeStdout,
42
+ dir: (value?: unknown, options?: InspectOptions) => {
43
+ output.stdout += `${formatWithOptions(options ?? {}, value)}\n`;
44
+ },
45
+ warn: writeStderr,
46
+ error: writeStderr,
47
+ } satisfies Console;
48
+
49
+ return { console: bufferedConsole, output };
50
+ }
51
+
52
+ export async function handleExec(
53
+ targetPage: Page,
54
+ code: string,
55
+ context: BrowserContext,
56
+ browser: Browser,
57
+ execState: Record<string, unknown>,
58
+ session: string,
59
+ visualize?: boolean,
60
+ ): Promise<ExecResponse> {
61
+ const buffered = createBufferedConsole();
62
+
63
+ if (visualize) {
64
+ await installInstrumentation(targetPage, { visualize: true });
65
+ }
66
+
67
+ const networkLog = (
68
+ opts: {
69
+ last?: number;
70
+ filter?: string;
71
+ method?: string;
72
+ pageId?: string;
73
+ } = {},
74
+ ) => readNetworkLog(session, opts);
75
+
76
+ const actionLog = (
77
+ opts: {
78
+ last?: number;
79
+ filter?: string;
80
+ action?: string;
81
+ source?: string;
82
+ pageId?: string;
83
+ } = {},
84
+ ) => readActionLog(session, opts);
85
+
86
+ const helpers = {
87
+ page: targetPage,
88
+ context,
89
+ browser,
90
+ state: execState,
91
+ console: buffered.console,
92
+ networkLog,
93
+ actionLog,
94
+ };
95
+
96
+ const helperNames = Object.keys(helpers);
97
+ const fn = compileExecFunction(code, helperNames);
98
+ try {
99
+ const result = await fn(...Object.values(helpers));
100
+ return { result, output: buffered.output };
101
+ } catch (error) {
102
+ throw new DaemonExecError(
103
+ error instanceof Error ? error.message : String(error),
104
+ buffered.output,
105
+ );
106
+ }
107
+ }
108
+
109
+ export async function handleReadonlyExec(
110
+ targetPage: Page,
111
+ code: string,
112
+ ): Promise<ExecResponse> {
113
+ const buffered = createBufferedConsole();
114
+ const helpers = createReadonlyExecHelpers(targetPage, {
115
+ console: buffered.console,
116
+ });
117
+ const helperNames = Object.keys(helpers);
118
+ const fn = compileExecFunction(code, helperNames);
119
+ try {
120
+ const result = await fn(...Object.values(helpers));
121
+ return { result, output: buffered.output };
122
+ } catch (error) {
123
+ throw new DaemonExecError(
124
+ error instanceof Error ? error.message : String(error),
125
+ buffered.output,
126
+ );
127
+ }
128
+ }
@@ -0,0 +1,24 @@
1
+ export {
2
+ DaemonServer,
3
+ DaemonClient,
4
+ DaemonClientError,
5
+ getDaemonSocketPath,
6
+ type DaemonCommandResult,
7
+ type DaemonExecOutput,
8
+ type DaemonRequest,
9
+ type DaemonResponse,
10
+ type DaemonResultMap,
11
+ type RequestHandler,
12
+ } from "./ipc.js";
13
+
14
+ export {
15
+ type DaemonLaunchConfig,
16
+ type DaemonConnectConfig,
17
+ type DaemonConfig,
18
+ } from "./config.js";
19
+
20
+ export {
21
+ spawnSessionDaemon,
22
+ type SpawnSessionDaemonOptions,
23
+ type SpawnSessionDaemonResult,
24
+ } from "./spawn.js";