libretto 0.6.9 → 0.6.11
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/dist/cli/cli.js +2 -0
- package/dist/cli/commands/auth.js +535 -0
- package/dist/cli/commands/billing.js +74 -0
- package/dist/cli/commands/browser.js +8 -3
- package/dist/cli/commands/deploy.js +2 -7
- package/dist/cli/commands/execution.js +99 -136
- package/dist/cli/commands/snapshot.js +38 -126
- package/dist/cli/core/ai-model.js +0 -3
- package/dist/cli/core/auth-fetch.js +195 -0
- package/dist/cli/core/auth-storage.js +52 -0
- package/dist/cli/core/browser.js +128 -202
- package/dist/cli/core/daemon/config.js +6 -0
- package/dist/cli/core/daemon/daemon.js +298 -0
- package/dist/cli/core/daemon/exec.js +86 -0
- package/dist/cli/core/daemon/index.js +16 -0
- package/dist/cli/core/daemon/ipc.js +171 -0
- package/dist/cli/core/daemon/pages.js +15 -0
- package/dist/cli/core/daemon/snapshot.js +86 -0
- package/dist/cli/core/daemon/spawn.js +90 -0
- package/dist/cli/core/exec-compiler.js +111 -0
- package/dist/cli/core/prompt.js +72 -0
- package/dist/cli/core/providers/libretto-cloud.js +2 -6
- package/dist/cli/core/readonly-exec.js +1 -1
- package/dist/cli/router.js +4 -0
- package/dist/cli/workers/run-integration-runtime.js +0 -5
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +2 -1
- package/docs/browser-automation-approaches.md +435 -0
- package/docs/releasing.md +117 -0
- package/package.json +4 -3
- package/skills/libretto/SKILL.md +14 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +2 -0
- package/src/cli/commands/auth.ts +787 -0
- package/src/cli/commands/billing.ts +133 -0
- package/src/cli/commands/browser.ts +8 -2
- package/src/cli/commands/deploy.ts +2 -7
- package/src/cli/commands/execution.ts +126 -186
- package/src/cli/commands/snapshot.ts +46 -143
- package/src/cli/core/ai-model.ts +4 -5
- package/src/cli/core/auth-fetch.ts +283 -0
- package/src/cli/core/auth-storage.ts +102 -0
- package/src/cli/core/browser.ts +159 -242
- package/src/cli/core/daemon/config.ts +46 -0
- package/src/cli/core/daemon/daemon.ts +429 -0
- package/src/cli/core/daemon/exec.ts +128 -0
- package/src/cli/core/daemon/index.ts +24 -0
- package/src/cli/core/daemon/ipc.ts +294 -0
- package/src/cli/core/daemon/pages.ts +21 -0
- package/src/cli/core/daemon/snapshot.ts +114 -0
- package/src/cli/core/daemon/spawn.ts +171 -0
- package/src/cli/core/exec-compiler.ts +169 -0
- package/src/cli/core/prompt.ts +94 -0
- package/src/cli/core/providers/libretto-cloud.ts +2 -6
- package/src/cli/core/readonly-exec.ts +2 -1
- package/src/cli/router.ts +4 -0
- package/src/cli/workers/run-integration-runtime.ts +0 -6
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/browser-daemon.js +0 -122
- package/src/cli/core/browser-daemon.ts +0 -198
package/src/cli/core/browser.ts
CHANGED
|
@@ -5,12 +5,9 @@ import {
|
|
|
5
5
|
type CDPSession,
|
|
6
6
|
type Page,
|
|
7
7
|
} from "playwright";
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import { createRequire } from "node:module";
|
|
12
10
|
import { createServer } from "node:net";
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
14
11
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
15
12
|
import type { SessionAccessMode } from "../../shared/state/index.js";
|
|
16
13
|
import { PROFILES_DIR } from "./context.js";
|
|
@@ -27,6 +24,7 @@ import {
|
|
|
27
24
|
} from "./session.js";
|
|
28
25
|
import type { ProviderApi } from "./providers/types.js";
|
|
29
26
|
import { getCloudProviderApi } from "./providers/index.js";
|
|
27
|
+
import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
|
|
30
28
|
|
|
31
29
|
const CLOSE_WAIT_MS = 1_500;
|
|
32
30
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
@@ -202,27 +200,6 @@ async function resolvePageReferences(pages: Page[]): Promise<PageReference[]> {
|
|
|
202
200
|
return refs;
|
|
203
201
|
}
|
|
204
202
|
|
|
205
|
-
export async function listOpenPages(
|
|
206
|
-
session: string,
|
|
207
|
-
logger: LoggerApi,
|
|
208
|
-
): Promise<OpenPageSummary[]> {
|
|
209
|
-
const { browser, page: activePage } = await connect(session, logger);
|
|
210
|
-
try {
|
|
211
|
-
const pages = browser
|
|
212
|
-
.contexts()
|
|
213
|
-
.flatMap((ctx) => ctx.pages())
|
|
214
|
-
.filter(isOperationalPage);
|
|
215
|
-
const pageRefs = await resolvePageReferences(pages);
|
|
216
|
-
return pageRefs.map(({ id, page }) => ({
|
|
217
|
-
id,
|
|
218
|
-
url: page.url(),
|
|
219
|
-
active: page === activePage,
|
|
220
|
-
}));
|
|
221
|
-
} finally {
|
|
222
|
-
disconnectBrowser(browser, logger, session);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
203
|
export async function connect(
|
|
227
204
|
session: string,
|
|
228
205
|
logger: LoggerApi,
|
|
@@ -341,7 +318,18 @@ export async function runPages(
|
|
|
341
318
|
logger: LoggerApi,
|
|
342
319
|
): Promise<void> {
|
|
343
320
|
logger.info("pages-start", { session });
|
|
344
|
-
|
|
321
|
+
|
|
322
|
+
const state = readSessionStateOrThrow(session);
|
|
323
|
+
let pageSummaries: OpenPageSummary[];
|
|
324
|
+
|
|
325
|
+
if (!state.daemonSocketPath) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
|
|
328
|
+
`Close and reopen the session: libretto close --session ${session}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const client = new DaemonClient(state.daemonSocketPath);
|
|
332
|
+
pageSummaries = await client.pages();
|
|
345
333
|
|
|
346
334
|
if (pageSummaries.length === 0) {
|
|
347
335
|
console.log("No pages found.");
|
|
@@ -402,6 +390,7 @@ export async function runOpen(
|
|
|
402
390
|
options?: {
|
|
403
391
|
viewport?: { width: number; height: number };
|
|
404
392
|
accessMode?: SessionAccessMode;
|
|
393
|
+
authProfileDomain?: string;
|
|
405
394
|
},
|
|
406
395
|
): Promise<void> {
|
|
407
396
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
@@ -423,9 +412,26 @@ export async function runOpen(
|
|
|
423
412
|
const runLogPath = logFileForSession(session);
|
|
424
413
|
|
|
425
414
|
const browserMode = headed ? "headed" : "headless";
|
|
415
|
+
|
|
416
|
+
// When --auth-profile is provided, use that domain for profile lookup
|
|
417
|
+
// instead of deriving it from the URL.
|
|
418
|
+
const authDomain = options?.authProfileDomain
|
|
419
|
+
? normalizeDomain(normalizeUrl(options.authProfileDomain))
|
|
420
|
+
: undefined;
|
|
421
|
+
if (authDomain) {
|
|
422
|
+
const authProfilePath = getProfilePath(authDomain);
|
|
423
|
+
if (!existsSync(authProfilePath)) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`No saved auth profile for "${authDomain}". ` +
|
|
426
|
+
`Save one first: libretto open https://${authDomain} --headed --session <name>, ` +
|
|
427
|
+
`log in, then run: libretto save ${authDomain} --session <name>`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
426
432
|
const supportsSavedProfile =
|
|
427
433
|
parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
|
|
428
|
-
const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined;
|
|
434
|
+
const domain = authDomain ?? (supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined);
|
|
429
435
|
const profilePath = domain ? getProfilePath(domain) : undefined;
|
|
430
436
|
const useProfile = domain ? hasProfile(domain) : false;
|
|
431
437
|
|
|
@@ -444,129 +450,51 @@ export async function runOpen(
|
|
|
444
450
|
}
|
|
445
451
|
console.log(`Launching ${browserMode} browser (session: ${session})...`);
|
|
446
452
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
453
|
+
// Spawn daemon and wait for IPC readiness. The daemon launches
|
|
454
|
+
// Chromium internally — IPC readiness implies the browser is up,
|
|
455
|
+
// so no separate CDP polling is needed.
|
|
456
|
+
const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
|
|
457
|
+
config: {
|
|
458
|
+
port,
|
|
459
|
+
url,
|
|
460
|
+
session,
|
|
461
|
+
headed,
|
|
462
|
+
viewport,
|
|
463
|
+
storageStatePath: useProfile ? profilePath : undefined,
|
|
464
|
+
windowPosition,
|
|
465
|
+
},
|
|
455
466
|
session,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
467
|
+
logger,
|
|
468
|
+
logPath: runLogPath,
|
|
469
|
+
// The daemon launches Chromium, installs telemetry, navigates to
|
|
470
|
+
// the URL, and only then starts IPC. Navigation alone can take up
|
|
471
|
+
// to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
|
|
472
|
+
// must cover launch + navigation.
|
|
473
|
+
ipcTimeoutMs: 60_000,
|
|
474
|
+
});
|
|
463
475
|
|
|
464
|
-
|
|
465
|
-
process.execPath,
|
|
466
|
-
["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
|
|
476
|
+
writeSessionState(
|
|
467
477
|
{
|
|
468
|
-
|
|
469
|
-
|
|
478
|
+
port,
|
|
479
|
+
pid,
|
|
480
|
+
session,
|
|
481
|
+
startedAt: new Date().toISOString(),
|
|
482
|
+
status: "active",
|
|
483
|
+
mode: accessMode,
|
|
484
|
+
viewport,
|
|
485
|
+
daemonSocketPath,
|
|
470
486
|
},
|
|
487
|
+
logger,
|
|
471
488
|
);
|
|
472
|
-
child.unref();
|
|
473
|
-
closeSync(childStderrFd);
|
|
474
|
-
|
|
475
|
-
logger.info("open-child-spawned", { pid: child.pid, port, session });
|
|
476
|
-
|
|
477
|
-
let childSpawnError: Error | null = null;
|
|
478
|
-
let childEarlyExit: {
|
|
479
|
-
code: number | null;
|
|
480
|
-
signal: NodeJS.Signals | null;
|
|
481
|
-
} | null = null;
|
|
482
|
-
|
|
483
|
-
child.on("error", (err) => {
|
|
484
|
-
childSpawnError = err;
|
|
485
|
-
logger.error("open-child-spawn-error", { error: err, session, port });
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
child.on("exit", (code, signal) => {
|
|
489
|
-
childEarlyExit = { code, signal };
|
|
490
|
-
logger.warn("open-child-exited", {
|
|
491
|
-
code,
|
|
492
|
-
signal,
|
|
493
|
-
session,
|
|
494
|
-
port,
|
|
495
|
-
pid: child.pid,
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
const cdpPollIntervalMs = 500;
|
|
500
|
-
const cdpMaxAttempts = 30;
|
|
501
|
-
const cdpStartupTimeoutMs = cdpPollIntervalMs * cdpMaxAttempts;
|
|
502
|
-
|
|
503
|
-
for (let i = 0; i < cdpMaxAttempts; i++) {
|
|
504
|
-
const spawnError = childSpawnError as Error | null;
|
|
505
|
-
if (spawnError !== null) {
|
|
506
|
-
const errWithCode = spawnError as Error & { code?: string };
|
|
507
|
-
const hint =
|
|
508
|
-
errWithCode.code === "ENOENT"
|
|
509
|
-
? " Ensure Node.js is available in PATH for child processes."
|
|
510
|
-
: "";
|
|
511
|
-
throw new Error(
|
|
512
|
-
`Failed to launch browser child process: ${spawnError.message}.${hint} Check logs: ${runLogPath}`,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const earlyExit = childEarlyExit as {
|
|
517
|
-
code: number | null;
|
|
518
|
-
signal: NodeJS.Signals | null;
|
|
519
|
-
} | null;
|
|
520
|
-
if (earlyExit !== null) {
|
|
521
|
-
const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
|
|
522
|
-
throw new Error(
|
|
523
|
-
`Browser child process exited before startup (status: ${status}). Check logs: ${runLogPath}`,
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
489
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
.catch(() => false);
|
|
531
|
-
if (i > 0 && i % 5 === 0) {
|
|
532
|
-
logger.info("open-waiting-for-cdp", { attempt: i, port, session });
|
|
533
|
-
}
|
|
534
|
-
if (ready) {
|
|
535
|
-
writeSessionState(
|
|
536
|
-
{
|
|
537
|
-
port,
|
|
538
|
-
pid: child.pid!,
|
|
539
|
-
session,
|
|
540
|
-
startedAt: new Date().toISOString(),
|
|
541
|
-
status: "active",
|
|
542
|
-
mode: accessMode,
|
|
543
|
-
viewport,
|
|
544
|
-
},
|
|
545
|
-
logger,
|
|
546
|
-
);
|
|
547
|
-
logger.info("open-success", {
|
|
548
|
-
url,
|
|
549
|
-
mode: browserMode,
|
|
550
|
-
session,
|
|
551
|
-
port,
|
|
552
|
-
pid: child.pid,
|
|
553
|
-
});
|
|
554
|
-
console.log(`Browser open (${browserMode}): ${url}`);
|
|
555
|
-
|
|
556
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
logger.error("open-timeout", {
|
|
490
|
+
logger.info("open-success", {
|
|
491
|
+
url,
|
|
492
|
+
mode: browserMode,
|
|
562
493
|
session,
|
|
563
494
|
port,
|
|
564
|
-
pid
|
|
565
|
-
attempts: cdpMaxAttempts,
|
|
495
|
+
pid,
|
|
566
496
|
});
|
|
567
|
-
|
|
568
|
-
`Failed to connect to browser after ${Math.ceil(cdpStartupTimeoutMs / 1000)}s. Check startup logs: ${runLogPath}`,
|
|
569
|
-
);
|
|
497
|
+
console.log(`Browser open (${browserMode}): ${url}`);
|
|
570
498
|
}
|
|
571
499
|
|
|
572
500
|
export async function runOpenWithProvider(
|
|
@@ -599,71 +527,39 @@ export async function runOpenWithProvider(
|
|
|
599
527
|
|
|
600
528
|
console.log(`Connecting to ${providerName} browser...`);
|
|
601
529
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (contexts.length > 0 && contexts[0].pages().length > 0) {
|
|
618
|
-
page = contexts[0].pages()[0];
|
|
619
|
-
} else {
|
|
620
|
-
const context =
|
|
621
|
-
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
622
|
-
page = await context.newPage();
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
await page.goto(url);
|
|
626
|
-
logger.info("open-provider-navigated", { url, session });
|
|
627
|
-
|
|
628
|
-
// Cloud sessions have no local port. Reconnection uses cdpEndpoint directly.
|
|
629
|
-
writeSessionState(
|
|
630
|
-
{
|
|
631
|
-
port: 0,
|
|
632
|
-
cdpEndpoint: providerSession.cdpEndpoint,
|
|
633
|
-
session,
|
|
634
|
-
startedAt: new Date().toISOString(),
|
|
635
|
-
status: "active",
|
|
636
|
-
mode: accessMode,
|
|
637
|
-
provider: {
|
|
638
|
-
name: providerName,
|
|
639
|
-
sessionId: providerSession.sessionId,
|
|
640
|
-
},
|
|
641
|
-
},
|
|
642
|
-
logger,
|
|
643
|
-
);
|
|
530
|
+
const runLogPath = logFileForSession(session);
|
|
531
|
+
const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
|
|
532
|
+
config: {
|
|
533
|
+
mode: "connect" as const,
|
|
534
|
+
session,
|
|
535
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
536
|
+
url,
|
|
537
|
+
},
|
|
538
|
+
session,
|
|
539
|
+
logger,
|
|
540
|
+
logPath: runLogPath,
|
|
541
|
+
// Remote CDP connection + navigation; must cover both.
|
|
542
|
+
ipcTimeoutMs: 60_000,
|
|
543
|
+
onFailure: () => provider.closeSession(providerSession.sessionId),
|
|
544
|
+
});
|
|
644
545
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
await provider.closeSession(providerSession.sessionId);
|
|
658
|
-
} catch (cleanupErr) {
|
|
659
|
-
logger.warn("open-provider-cleanup-failed", {
|
|
660
|
-
provider: providerName,
|
|
546
|
+
writeSessionState(
|
|
547
|
+
{
|
|
548
|
+
port: 0,
|
|
549
|
+
pid,
|
|
550
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
551
|
+
session,
|
|
552
|
+
startedAt: new Date().toISOString(),
|
|
553
|
+
status: "active",
|
|
554
|
+
mode: accessMode,
|
|
555
|
+
daemonSocketPath,
|
|
556
|
+
provider: {
|
|
557
|
+
name: providerName,
|
|
661
558
|
sessionId: providerSession.sessionId,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
logger,
|
|
562
|
+
);
|
|
667
563
|
|
|
668
564
|
logger.info("open-provider-success", {
|
|
669
565
|
url,
|
|
@@ -768,9 +664,18 @@ export async function runClose(
|
|
|
768
664
|
return;
|
|
769
665
|
}
|
|
770
666
|
|
|
667
|
+
// Kill local daemon process if present (applies to both local and
|
|
668
|
+
// provider sessions — the daemon disconnects without closing the
|
|
669
|
+
// external browser).
|
|
670
|
+
if (state.pid != null) {
|
|
671
|
+
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
672
|
+
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
673
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Close provider session if applicable (tears down the remote browser).
|
|
771
677
|
let replayUrl: string | undefined;
|
|
772
678
|
if (state.provider) {
|
|
773
|
-
// Cloud provider session — close via provider API, no local pid to kill
|
|
774
679
|
logger.info("close-provider", {
|
|
775
680
|
session,
|
|
776
681
|
provider: state.provider.name,
|
|
@@ -787,22 +692,15 @@ export async function runClose(
|
|
|
787
692
|
sessionId: state.provider.sessionId,
|
|
788
693
|
error: err,
|
|
789
694
|
});
|
|
790
|
-
// Preserve state with cleanup-failed status so the user can retry.
|
|
791
|
-
// The provider.sessionId is retained for manual or future cleanup.
|
|
792
695
|
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
793
696
|
throw new Error(
|
|
794
697
|
`Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
|
|
795
698
|
`State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
|
|
796
699
|
);
|
|
797
700
|
}
|
|
798
|
-
} else {
|
|
799
|
-
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
800
|
-
if (state.pid != null) {
|
|
801
|
-
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
802
|
-
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
803
|
-
}
|
|
804
701
|
}
|
|
805
702
|
|
|
703
|
+
unlinkDaemonSocket(state.daemonSocketPath, logger, session);
|
|
806
704
|
clearSessionState(session, logger);
|
|
807
705
|
logger.info("close-success", { session, replayUrl });
|
|
808
706
|
console.log(`Browser closed (session: ${session}).`);
|
|
@@ -816,6 +714,7 @@ type ClosableSession = {
|
|
|
816
714
|
pid?: number;
|
|
817
715
|
port: number;
|
|
818
716
|
provider?: { name: string; sessionId: string };
|
|
717
|
+
daemonSocketPath?: string;
|
|
819
718
|
};
|
|
820
719
|
|
|
821
720
|
function waitForCloseSignalWindow(ms: number): Promise<void> {
|
|
@@ -869,12 +768,32 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
869
768
|
pid: state.pid,
|
|
870
769
|
port: state.port,
|
|
871
770
|
provider: state.provider,
|
|
771
|
+
daemonSocketPath: state.daemonSocketPath,
|
|
872
772
|
});
|
|
873
773
|
}
|
|
874
774
|
|
|
875
775
|
return { closable, clearedUnreadableStates };
|
|
876
776
|
}
|
|
877
777
|
|
|
778
|
+
function unlinkDaemonSocket(
|
|
779
|
+
socketPath: string | undefined,
|
|
780
|
+
logger: LoggerApi,
|
|
781
|
+
session: string,
|
|
782
|
+
): void {
|
|
783
|
+
if (!socketPath) return;
|
|
784
|
+
try {
|
|
785
|
+
unlinkSync(socketPath);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
788
|
+
logger.warn("close-socket-unlink-failed", {
|
|
789
|
+
session,
|
|
790
|
+
socketPath,
|
|
791
|
+
error: err,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
878
797
|
function clearStoppedSessionStates(
|
|
879
798
|
sessions: ReadonlyArray<ClosableSession>,
|
|
880
799
|
logger: LoggerApi,
|
|
@@ -884,6 +803,7 @@ function clearStoppedSessionStates(
|
|
|
884
803
|
for (const session of sessions) {
|
|
885
804
|
if (skip?.has(session.session)) continue;
|
|
886
805
|
if (session.pid == null || !isPidRunning(session.pid)) {
|
|
806
|
+
unlinkDaemonSocket(session.daemonSocketPath, logger, session.session);
|
|
887
807
|
clearSessionState(session.session, logger);
|
|
888
808
|
cleared += 1;
|
|
889
809
|
}
|
|
@@ -944,22 +864,20 @@ export async function runCloseAll(
|
|
|
944
864
|
}
|
|
945
865
|
}
|
|
946
866
|
|
|
947
|
-
// Send SIGTERM to local sessions
|
|
867
|
+
// Send SIGTERM to all daemon processes (both local and provider sessions).
|
|
948
868
|
for (const target of closable) {
|
|
949
|
-
if (target.
|
|
869
|
+
if (target.pid == null) continue;
|
|
950
870
|
logger.info("close-all-sigterm", {
|
|
951
871
|
session: target.session,
|
|
952
872
|
pid: target.pid,
|
|
953
873
|
port: target.port,
|
|
954
874
|
});
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
);
|
|
962
|
-
}
|
|
875
|
+
sendSignalToProcessGroupOrPid(
|
|
876
|
+
target.pid,
|
|
877
|
+
"SIGTERM",
|
|
878
|
+
logger,
|
|
879
|
+
target.session,
|
|
880
|
+
);
|
|
963
881
|
}
|
|
964
882
|
|
|
965
883
|
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
@@ -1082,8 +1000,9 @@ export async function runConnect(
|
|
|
1082
1000
|
`Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
|
|
1083
1001
|
);
|
|
1084
1002
|
|
|
1085
|
-
//
|
|
1086
|
-
// endpoints are validated by the
|
|
1003
|
+
// Fast-fail: verify the CDP endpoint is reachable before spawning
|
|
1004
|
+
// the daemon (HTTP only — WebSocket endpoints are validated by the
|
|
1005
|
+
// daemon's connectOverCDP call).
|
|
1087
1006
|
if (!isWebSocket) {
|
|
1088
1007
|
const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
|
|
1089
1008
|
try {
|
|
@@ -1103,41 +1022,39 @@ export async function runConnect(
|
|
|
1103
1022
|
});
|
|
1104
1023
|
}
|
|
1105
1024
|
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
logger.info("connect-pages", {
|
|
1116
|
-
session,
|
|
1117
|
-
pageCount: pages.length,
|
|
1118
|
-
urls: pages.map((p) => p.url()),
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
disconnectBrowser(browser, logger, session);
|
|
1025
|
+
const runLogPath = logFileForSession(session);
|
|
1026
|
+
const { pid, socketPath: daemonSocketPath, client } =
|
|
1027
|
+
await spawnSessionDaemon({
|
|
1028
|
+
config: { mode: "connect" as const, session, cdpEndpoint: endpoint },
|
|
1029
|
+
session,
|
|
1030
|
+
logger,
|
|
1031
|
+
logPath: runLogPath,
|
|
1032
|
+
ipcTimeoutMs: 10_000,
|
|
1033
|
+
});
|
|
1122
1034
|
|
|
1123
1035
|
writeSessionState(
|
|
1124
1036
|
{
|
|
1125
1037
|
port,
|
|
1038
|
+
pid,
|
|
1126
1039
|
cdpEndpoint: endpoint,
|
|
1127
1040
|
session,
|
|
1128
1041
|
startedAt: new Date().toISOString(),
|
|
1129
1042
|
status: "active",
|
|
1130
1043
|
mode: accessMode,
|
|
1044
|
+
daemonSocketPath,
|
|
1131
1045
|
},
|
|
1132
1046
|
logger,
|
|
1133
1047
|
);
|
|
1134
1048
|
|
|
1049
|
+
// Query the daemon for discovered pages.
|
|
1050
|
+
const pages = await client.pages();
|
|
1051
|
+
|
|
1135
1052
|
logger.info("connect-success", { cdpUrl: endpoint, session, port });
|
|
1136
1053
|
console.log(`Connected to ${endpoint} (session: ${session})`);
|
|
1137
1054
|
console.log(` Pages found: ${pages.length}`);
|
|
1138
1055
|
if (pages.length > 0) {
|
|
1139
1056
|
for (const p of pages.slice(0, 5)) {
|
|
1140
|
-
console.log(` ${p.url
|
|
1057
|
+
console.log(` ${p.url}`);
|
|
1141
1058
|
}
|
|
1142
1059
|
if (pages.length > 5) {
|
|
1143
1060
|
console.log(` ... and ${pages.length - 5} more`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration types for the browser daemon process.
|
|
3
|
+
*
|
|
4
|
+
* Serialized as JSON in `process.argv[2]` when spawning the daemon.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Config for daemon-managed browser launch (`libretto open`).
|
|
9
|
+
* The daemon owns the browser lifecycle and will close it on shutdown.
|
|
10
|
+
*/
|
|
11
|
+
export type DaemonLaunchConfig = {
|
|
12
|
+
port: number;
|
|
13
|
+
url: string;
|
|
14
|
+
session: string;
|
|
15
|
+
headed: boolean;
|
|
16
|
+
viewport: { width: number; height: number };
|
|
17
|
+
storageStatePath?: string;
|
|
18
|
+
windowPosition?: { x: number; y: number };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Config for connecting to an externally managed browser (`libretto connect`).
|
|
23
|
+
* The daemon borrows the CDP connection and will disconnect (not close) on
|
|
24
|
+
* shutdown — the browser outlives the session.
|
|
25
|
+
*/
|
|
26
|
+
export type DaemonConnectConfig = {
|
|
27
|
+
mode: "connect";
|
|
28
|
+
session: string;
|
|
29
|
+
cdpEndpoint: string;
|
|
30
|
+
/** If set, the daemon navigates to this URL after connecting. */
|
|
31
|
+
url?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Discriminated union passed as JSON in `process.argv[2]`.
|
|
36
|
+
* Launch configs omit `mode` for backward compatibility with existing
|
|
37
|
+
* `runOpen()` callers — any config without `mode: "connect"` is treated
|
|
38
|
+
* as a launch config.
|
|
39
|
+
*/
|
|
40
|
+
export type DaemonConfig = DaemonLaunchConfig | DaemonConnectConfig;
|
|
41
|
+
|
|
42
|
+
export function isConnectConfig(
|
|
43
|
+
config: DaemonConfig,
|
|
44
|
+
): config is DaemonConnectConfig {
|
|
45
|
+
return "mode" in config && config.mode === "connect";
|
|
46
|
+
}
|