/** * Chrome Launcher * Utilities for launching Chrome with debugging enabled */ import type { PortReserver } from './port-reserver.js'; export type ChromeCloseReason = 'inactivity' | 'manual' | 'crash' | 'external' | 'signal' | 'unknown'; export interface ChromeCloseEvent { port: number; pid: number; reason: ChromeCloseReason; timestamp: Date; exitCode?: number | null; signal?: string | null; } export type ChromeExitCallback = (event: ChromeCloseEvent) => void | Promise; /** * Prefix used for all launcher-created temporary Chrome profile directories. * The startup sweep only ever considers directories with this prefix. */ export declare const EPHEMERAL_PROFILE_PREFIX = "chrome-debug-profile-"; /** * A Chrome user-data-dir tracked by the launcher. * * `ephemeral` records a throwaway profile we created and are therefore allowed * to delete when the instance goes away. Named/persistent profiles (see issue * 13) will be registered with `ephemeral: false` and must never be deleted by * the launcher, neither on kill nor by the startup sweep. */ export interface ChromeProfileRecord { dir: string; ephemeral: boolean; } /** * Legal characters for a named persistent profile (issue 13). * * Deliberately strict: the name becomes a directory under the profile root, so * anything that could escape it (`/`, `..`, leading dot) or confuse the startup * sweep is rejected rather than sanitised - silently renaming a profile would * hand the caller a different identity than they asked for. */ export declare const PROFILE_NAME_PATTERN: RegExp; /** Thrown when a profile name is not a safe single directory segment. */ export declare class InvalidProfileNameError extends Error { readonly profile: string; constructor(profile: string); } /** Thrown when an operation would disturb a profile a live Chrome is holding. */ export declare class ProfileInUseError extends Error { readonly profile: string; readonly port: number; constructor(profile: string, port: number); } /** * Thrown when a profile is held by a Chrome this process did not launch - * typically another cdp-tools session on the same machine, because the default * persistent profile root (`~/.cdp-tools/profiles`) is global. * * Detected from the profile's Chrome `SingletonLock`, so we know the holding * PID but not which debug port it listens on. * * LIMITATION: the check is POSIX-only. `SingletonLock` is a symlink on * macOS/Linux; on Windows Chrome uses a plain lock file we cannot read a PID * from, so this error is never raised there and the cross-session races it * guards against remain possible. */ export declare class ProfileLockedError extends Error { readonly profile: string; readonly pid: number; readonly dir: string; constructor(profile: string, pid: number, dir: string); } /** * Validate a persistent profile name, returning it trimmed. * @throws InvalidProfileNameError */ export declare function normalizeProfileName(name: string): string; /** Outcome of resolveLaunchPort(). */ export type LaunchPortDecision = { decision: 'use'; port: number; } /** forceNewInstance asked for a specific port that is already taken. */ | { decision: 'forced-port-in-use'; port: number; }; export interface LaunchPortRequest { /** `port` as passed to launchChrome, if the caller gave one. */ explicitPort?: number; forceNewInstance?: boolean; /** This session's reserved port - the default when no port is given. */ reservedPort: number; /** Is `port` held by anything other than our own reservation? */ isPortOccupied: (port: number) => Promise; /** Pick a genuinely free port (only consulted for a portless forceNewInstance). */ findFreePort: () => Promise; } /** * Decide which port a launchChrome call should use (bug-005). * * Extracted from the MCP handler so the decision is testable without a browser * or an MCP server: src/index.ts calls main() on import, so anything left * inline there can only be "tested" by grepping the source. * * Rules: * - An explicit `port` is always honoured, never silently relocated. * - `forceNewInstance` must produce a fresh process, so an explicit port that * is already occupied is an error rather than a hand-off to whatever is * listening; occupancy is only consulted in that case. * - `forceNewInstance` without a port picks a known-free port instead of the * reserved one, which an existing instance may already be using. */ export declare function resolveLaunchPort(req: LaunchPortRequest): Promise; /** Outcome of decideProfileReuse(). */ export type ProfileReuseDecision = /** Nothing in the way: reuse the existing instance, or spawn if there is none. */ { decision: 'ok'; } /** The requested profile is held by a different live Chrome on `port`. */ | { decision: 'in-use'; port: number; } /** The instance we would reuse is running a different profile. */ | { decision: 'mismatch'; port: number; actualProfile?: string; }; export interface ProfileReuseRequest { /** Directory of the requested named profile; undefined when none was asked for. */ wantedProfileDir?: string; /** * The live Chrome this call would otherwise reuse (matched by reference or by * target port), and the profile dir we have tracked for it (undefined when it * was not launched by us, so we cannot know). */ existing?: { port: number; profileDir?: string; }; /** Port of the live Chrome currently holding the requested profile, if any. */ holderPort?: number; } /** * Decide whether a launchChrome call may reuse an existing Chrome, given the * named profile it asked for (issue 13 / bug: profile pre-check ordering). * * The ordering this encodes is the whole point: a live instance already running * the requested profile is REUSED, exactly as it would be without a profile, so * the idempotent `launchChrome({ profile, reference })` "make sure it's up" * pattern keeps working. "Profile in use" is only an error when the call would * have to put a SECOND Chrome on a profile another instance holds. */ export declare function decideProfileReuse(req: ProfileReuseRequest): ProfileReuseDecision; export interface ChromeLauncherOptions { /** Directory the temporary profiles live in. Defaults to os.tmpdir(). */ profileRoot?: string; /** * Directory named persistent profiles (issue 13) live in. Defaults to * `~/.cdp-tools/profiles`. A function is resolved on every use so a live * config reload (`chrome.persistentProfileRoot`) takes effect immediately. */ persistentProfileRoot?: string | (() => string); /** Sweep stale ephemeral profiles on construction. Defaults to true. */ sweepStaleProfilesOnStartup?: boolean; /** * A stale profile dir must be at least this old (mtime) before the startup * sweep will remove it. Guards against deleting a profile belonging to a * Chrome that another MCP instance is launching right now. Defaults to 1h. */ staleProfileMaxAgeMs?: number; } export declare class ChromeLauncher { private chromeProcesses; private launchLocks; /** profile name -> in-flight launch for that profile (see launch()) */ private profileLaunchLocks; private lastCloseEvents; private maxCloseEvents; private pendingCloseReason; private onExitCallback; /** port -> profile dir currently in use by the Chrome on that port */ private profileDirs; private profileRoot; private persistentProfileRootOption; private staleProfileMaxAgeMs; /** Resolves once the startup sweep (if any) has finished. Exposed for tests. */ readonly startupSweep: Promise; constructor(options?: ChromeLauncherOptions); /** * Set a callback to be invoked when any Chrome process exits. * Used for cleanup (closing stale connections) and port re-reservation. */ setOnExitCallback(callback: ChromeExitCallback): void; /** * Get the Chrome executable path for the current platform */ private getChromePath; /** * Create Chrome preferences file that disables password manager popups * This is required because command-line flags alone don't reliably disable * the "Change your password" leak detection popup */ private createChromePreferences; /** * Check if a port is already in use using TCP connection */ private isPortInUse; /** * Wait for Chrome debugging port to become ready * Polls the /json/version endpoint until Chrome is inspectable */ private waitForChromeReady; /** * Launch Chrome with debugging enabled * Uses atomic release-and-launch to prevent race conditions * Waits for Chrome to actually bind to the port before resolving */ launch(port?: number, url?: string, portReserver?: PortReserver, headless?: boolean, extraArgs?: string[], profileName?: string): Promise<{ port: number; pid: number; }>; /** * Port-scoped half of launch(): the per-port lock, the "already running" and * profile-ownership guards, and the spawn itself. */ private launchOnPort; /** * Internal method that performs the actual Chrome launch * Separated from launch() to allow mutex/locking logic */ private performLaunch; /** * Check if a process with the given PID is actually running * This handles the case where Chrome is killed externally (e.g., Activity Monitor, kill command) */ private isProcessAlive; /** * Check if Chrome is running on a specific port, or if any Chrome instance is running * This verifies the process is actually alive, not just tracked */ isRunning(port?: number): boolean; /** * Get all running Chrome ports */ getRunningPorts(): number[]; /** * Kill Chrome process(es) * If port is specified, kills only that instance. Otherwise kills all instances. * First attempts graceful shutdown with SIGTERM, then force kills with SIGKILL if needed */ kill(port?: number): Promise; /** * Kill a specific Chrome instance by port */ private killInstance; /** * Build the profile record for a launch on `port`. * * The name embeds the port (only one live Chrome can hold a debug port, so * this alone separates concurrent launches) plus a timestamp and 4 random * bytes (so sequential relaunches on the same port within one millisecond * still differ). Naming on Date.now() alone let same-millisecond launches on * different ports share a profile - bug-006. * * With `profileName` (issue 13) the record is a stable directory under the * persistent profile root and is marked `ephemeral: false`, which every * cleanup path skips. The port is deliberately NOT part of a named profile's * directory: the profile is the identity, the port is just where this run of * it happens to listen. */ private createProfileRecord; /** * Directory named persistent profiles live under (see * `chrome.persistentProfileRoot`; defaults to `~/.cdp-tools/profiles`). */ getPersistentProfileRoot(): string; /** * Absolute path of a named persistent profile. Does not create it. * @throws InvalidProfileNameError */ getPersistentProfilePath(profileName: string): string; /** * Port of the live Chrome currently holding a named profile, if any. * @throws InvalidProfileNameError */ findPortForProfile(profileName: string): number | undefined; /** * PID of a live Chrome holding a named persistent profile, if the profile's * SingletonLock says so. Unlike findPortForProfile() this sees Chromes we did * NOT launch (other cdp-tools sessions sharing the global profile root, or a * Chrome started by hand on the same user-data-dir). * * LIMITATION: POSIX-only. It reads the SingletonLock symlink target, which * Windows Chrome does not create - there it always reports "not locked", so * callers must treat a negative result as "no evidence of a holder" rather * than proof the profile is free. * * @throws InvalidProfileNameError */ findProfileLockHolder(profileName: string): Promise; /** * Names of persistent profiles that exist on disk. */ listPersistentProfiles(): Promise; /** * Wipe a named persistent profile and recreate it empty, so the next launch * starts from a clean browser (no cookies, no IndexedDB, no enrolment). * * Refuses while a Chrome we launched still holds the profile: deleting a * live user-data-dir corrupts the running browser and the deletion would be * partly undone as Chrome flushes state back out on exit. Kill that instance * (`killChrome({ port })`) first. * * Also refuses when the profile's Chrome SingletonLock names a live PID we * did not launch. The default profile root is global (`~/.cdp-tools/profiles`), * so another cdp-tools session may be running this exact profile; without the * lock check this call would rm -rf a live browser's user-data-dir and destroy * the very identity persistent profiles exist to preserve. That check is * POSIX-only (see ProfileLockedError) - on Windows this remains unguarded. * * @throws InvalidProfileNameError | ProfileInUseError | ProfileLockedError */ resetPersistentProfile(profileName: string): Promise<{ profile: string; path: string; existed: boolean; }>; /** * Profile directory currently tracked for a port (if any). */ getProfileDir(port: number): string | undefined; /** * All tracked profiles, keyed by port. Persistent profiles are included but * are never deleted by the launcher. */ getProfiles(): Map; /** * Stop tracking a port's profile and, if it is ephemeral, delete it from disk. * * `expected` guards against a late exit event from a previous Chrome deleting * the freshly created profile of a relaunch on the same port: if the tracked * record is no longer the one we started with, nothing is touched. */ private removeProfileDir; /** * Delete ephemeral profile directories left behind by previous sessions * (crashes, SIGKILL, machine restart). Returns the directories removed. * * Conservative on purpose - a directory is skipped when: * - it is younger than staleProfileMaxAgeMs (another MCP instance may be * launching Chrome into it right now), or * - its Chrome SingletonLock names a PID that is still alive, or * - it is currently tracked by this launcher. */ sweepStaleProfiles(): Promise; /** * Is a profile directory still held by a running Chrome? * Chrome writes a SingletonLock symlink whose target is "-". * Unreadable/absent lock means "not locked" (Windows uses a plain lockfile). */ private isProfileLocked; /** * PID named by a profile directory's Chrome SingletonLock, if that process is * still alive. POSIX-only: on Windows the lock is a plain file with no * readable PID, so this always returns undefined ("no evidence of a holder"). */ private readProfileLockPid; /** * Reset the launcher state (useful if Chrome was closed externally) */ reset(port?: number): void; /** * Record a Chrome close event */ private recordCloseEvent; /** * Get the last close events */ getLastCloseEvents(): ChromeCloseEvent[]; /** * Get Chrome launcher status for all instances * Verifies each process is actually alive and cleans up dead ones */ getStatus(): { instances: Array<{ port: number; pid: number; running: boolean; }>; lastCloseEvents: ChromeCloseEvent[]; }; /** * Set the pending close reason for a port (call before killing) */ setPendingCloseReason(port: number, reason: ChromeCloseReason): void; } //# sourceMappingURL=chrome-launcher.d.ts.map