#!/bin/bash # chrome-debug - Launch a Chromium-family browser in remote-debugging mode for MCP attach # # Usage: # chrome-debug [-p port] [-d dir] [-nfv] [--detached] [--no-extensions] [] [-- extra-chrome-args] # chrome-debug -l | --list # chrome-debug [-c channel] [--cft-version ver] [--latest] [options] [] # chrome-debug --list-cache | --prune-cache [keep] # # Options: # -p, --port PORT Debug port (default: lowest free port in the .mcp.json pool) # -d, --user-data-dir DIR Chrome profile dir (default: /tmp/chrome-debug-) # -l, --list List running debug browsers and exit # -n, --dry-run Resolve and print, but do not launch # -f, --fresh Wipe the port's profile dir before launching # --detached Launch, print PID/port, then detach and exit (leave browser running) # --no-extensions Launch with extensions disabled (--disable-extensions) # -v, --verbose Verbose resolution output # -h, --help Show help message # -c, --channel CH CfT channel for run-and-go: stable|beta|dev|canary (default stable) # --cft-version VER Pin an exact Chrome for Testing build; overrides --channel # --latest Force the channel tip even if an older build is cached # --list-cache List cached Chrome for Testing builds and exit # --prune-cache [KEEP] Remove cached builds, keeping the newest KEEP (default 1) _chrome_debug() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="chrome-debug" ;; esac _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _warn() { echo "[WRN][$SCRIPT_NAME] $*" >&2; } _info() { echo "[INF][$SCRIPT_NAME] $*" >&2; } _dry() { printf '[DRY][%s] %s\n' "$SCRIPT_NAME" "$*"; } _shell_join() { local out="" local arg local quoted for arg in "$@"; do printf -v quoted '%q' "$arg" if [ -n "$out" ]; then out="$out "; fi out="$out$quoted" done printf '%s' "$out" } _show_help() { local s="" local r="" if { [ -t 1 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then s=$'\033[4m' r=$'\033[24m' fi cat < so the launched instance doesn't collide with an already-running, non-debug browser profile. Account sync is always disabled (--disable-sync) so a debug session never pulls in synced bookmarks, history, passwords, or extensions. On an MDM-managed browser the account may still be force-signed-in, but sync-down is suppressed; use Chrome for Testing for a fully account-free session. Two DevTools settings are enabled by default on launch -- "generate HAR with sensitive data" and "disable cache (while DevTools is open)" -- by seeding the profile's Preferences before the browser starts. Each is seeded only if the profile doesn't already carry a value, so an in-session toggle survives the next launch. Opt out with --no-har-sensitive, --no-disable-cache, or (for all) --no-devtools-prefs; seed any other setting with --devtools-pref KEY=VALUE. If the chosen port is already serving a debug browser, $SCRIPT_NAME attaches to it -- it prints the linkage and exits rather than launching a second instance on the same profile (which Chrome would refuse). Use -f/--fresh to wipe the port's profile directory first for a clean-slate session. By default $SCRIPT_NAME holds the foreground so the launching terminal owns the browser -- Ctrl-C (or stopping the process) tears it down. With --detached it instead prints the PID and port, then exits 0 and leaves the browser running, so a persistent debug browser can be reused across sessions; kill it later with the reported PID. This is the tidy alternative to backgrounding with "&". -l/--list reports the running debug browsers -- every Chromium-family process launched with --remote-debugging-port -- one row each: port, PID, browser version, the mapped .mcp.json chrome-devtools entry (if any), and profile dir. It takes no ${s}browser-location${r} and exits 0 even when none are running. Use -- to pass additional arguments through to the browser executable unchanged. OPTIONS -p, --port ${s}port${r} Debug port (default: lowest free port in the MCP pool) -d, --user-data-dir ${s}dir${r} Chrome profile dir (default: /tmp/chrome-debug-) -c, --channel ${s}channel${r} CfT channel for run-and-go: stable|beta|dev|canary (default stable) --cft-version ${s}ver${r} Pin an exact Chrome for Testing build (x.y.z.w); overrides --channel --latest Force the channel tip even if an older build is cached --list-cache List cached Chrome for Testing builds and exit --prune-cache [${s}keep${r}] Remove cached builds, keeping the newest keep (default 1; 'all' removes all); honors --dry-run -l, --list List running debug browsers and exit (takes no browser-location) -n, --dry-run Resolve and print, but do not launch -f, --fresh Wipe the port's profile dir before launching (clean session) --detached Launch, print PID/port, then detach and exit (leave the browser running for reuse) --no-extensions Launch with extensions disabled (passes --disable-extensions) --no-har-sensitive Force off the default-on DevTools "generate HAR with sensitive data" setting --no-disable-cache Force off the default-on DevTools "disable cache (while DevTools is open)" setting --no-devtools-prefs Skip all built-in DevTools setting seeding --devtools-pref ${s}kv${r} Seed a DevTools setting KEY=VALUE (repeatable); prefix KEY with synced: for the synced bucket -v, --verbose Verbose resolution output -h, --help Show this help message ENVIRONMENT CHROME_DEBUG_MCP_JSON Path to a single .mcp.json for port-pool discovery, overriding the default cwd-to-\$HOME walk-up CHROME_DEBUG_CACHE Cache root for downloaded Chrome for Testing builds (default: \$XDG_CACHE_HOME/chrome-debug or ~/.cache/chrome-debug) DEPENDENCIES jq Parse .mcp.json to discover the chrome-devtools MCP port pool curl Verify the remote-debugging port is responding (required for launching, best-effort for --list; not needed for --dry-run) nc Check whether a candidate port is already in use defaults Resolve .app bundle metadata on macOS lsof Optional: resolve the owning PID when attaching to an already-serving browser unzip Extract a downloaded Chrome for Testing archive (required only when downloading) EXIT STATUS 0 Success (launched, attached, or dry-run/list resolved) 1 Runtime failure (resolution/download/unzip failed, port busy but not serving, endpoint never came up) 2 Usage error (missing/invalid value, bad --channel/--cft-version, contradictory flags) 3 Dependency error (jq/nc/curl, or unzip when downloading) EXAMPLES $SCRIPT_NAME "/Applications/Google Chrome.app" $SCRIPT_NAME -p 9222 "/Applications/Google Chrome.app" $SCRIPT_NAME --detached "/Applications/Microsoft Edge.app" $SCRIPT_NAME --list $SCRIPT_NAME -n -v "/Applications/Google Chrome.app" $SCRIPT_NAME "/Applications/Google Chrome.app" -- --incognito $SCRIPT_NAME --devtools-pref synced:ui-theme=dark "/Applications/Microsoft Edge.app" $SCRIPT_NAME $SCRIPT_NAME --latest --detached $SCRIPT_NAME --cft-version 153.0.8010.47 $SCRIPT_NAME --list-cache EOF } # Resolve a browser location (.app bundle, raw executable, or directory) to an # executable path. Prints the path on success (rc 0); prints nothing on failure (rc 1) _resolve_browser() { local loc="$1" # Absolutize: `defaults read` rejects relative bundle paths, and callers may # pass a path relative to their CWD. A missing path stays as-is so the # not-found error below still fires if [ -d "$loc" ]; then loc="$(cd "$loc" 2>/dev/null && pwd)" elif [ -e "$loc" ]; then local loc_dir; loc_dir="$(cd "$(dirname "$loc")" 2>/dev/null && pwd)" [ -n "$loc_dir" ] && loc="$loc_dir/$(basename "$loc")" fi # .app bundle: read CFBundleExecutable, join with Contents/MacOS _resolve_app() { local app="$1" local exe; exe="$(defaults read "$app/Contents/Info" CFBundleExecutable 2>/dev/null)" if [ -z "$exe" ] || [ ! -x "$app/Contents/MacOS/$exe" ]; then _error "Failed to resolve executable inside '$app'" return 1 fi printf '%s\n' "$app/Contents/MacOS/$exe" } case "$loc" in *.app) [ -d "$loc" ] || { _error "Chrome app not found at '$loc'"; return 1; } _resolve_app "$loc"; return $? ;; esac if [ -f "$loc" ] && [ -x "$loc" ]; then printf '%s\n' "$loc" return 0 fi if [ -d "$loc" ]; then # Find all .app bundles under the dir; pick the newest by version-sorted path. # `-prune` stops `find` from descending into a matched bundle, so nested # helper apps (Contents/Frameworks/.../Helpers/*.app) aren't candidates. # `sort -t. -k1,1 ... ` is unreliable across mixed segments, so sort the whole # path with `sort -V` (version sort) and take the last (highest) local chosen; chosen="$(find "$loc" -type d -name '*.app' -prune 2>/dev/null | sort -V | tail -n 1)" if [ -z "$chosen" ]; then _error "no .app bundle found under '$loc'" return 1 fi [ -n "$verbose" ] && _info "resolved bundle: $chosen" _resolve_app "$chosen"; return $? fi _error "Browser location not found: '$loc'" return 1 } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } # Print the .mcp.json file(s) to consult, closest first. With # CHROME_DEBUG_MCP_JSON set, that single file is used verbatim. Otherwise mirror # how the MCP client discovers a project-scoped .mcp.json: walk up from $PWD to # $HOME (inclusive), emitting each .mcp.json found, nearest first. If none are # found (e.g. $PWD is outside $HOME), fall back to $HOME/.mcp.json _mcp_json_paths() { if [ -n "$CHROME_DEBUG_MCP_JSON" ]; then printf '%s\n' "$CHROME_DEBUG_MCP_JSON" return 0 fi # Normalize the ceiling: strip any trailing slash so the $dir compare below # can't be defeated by HOME=/foo/ (which would never equal a slashless $dir) local ceiling="${HOME%/}" [ -n "$ceiling" ] || ceiling="/" local found="" local dir="$PWD" while :; do if [ -f "$dir/.mcp.json" ]; then printf '%s\n' "$dir/.mcp.json" found=1 fi [ "$dir" = "$ceiling" ] && break [ "$dir" = "/" ] && break dir="$(dirname "$dir")" done # Fall back to the home file when the walk (which stops at $HOME) found nothing # because $PWD is outside the $HOME subtree if [ -z "$found" ] && [ -f "$HOME/.mcp.json" ]; then printf '%s\n' "$HOME/.mcp.json" fi } # Render the discovered .mcp.json path(s) for error messages: comma-joined, # or a "(searched ...)" note naming the walk range when nothing was found _mcp_json_display() { local paths; paths="$(_mcp_json_paths | paste -sd, -)" if [ -n "$paths" ]; then printf '%s' "$paths" else printf '(searched %s up to %s)' "$PWD" "$HOME" fi } # Extract " " for every chrome-devtools-mcp server in a single # .mcp.json whose args carry a 127.0.0.1: attach target _parse_pool_file() { jq -r ' .mcpServers | to_entries[] | select([.value.args[]? // empty] | any(test("chrome-devtools-mcp"))) | .key as $name | ([.value.args[]? | capture("127\\.0\\.0\\.1:(?

[0-9]+)") | .p] | .[0]) as $port | select($port != null) | "\($port) \($name)" ' "$1" 2>/dev/null } # Print " " for every chrome-devtools-mcp server across all # discovered .mcp.json files (see _mcp_json_paths), unioned and deduped by port # keeping the closest file's entry (files are emitted nearest-first). rc 1 (no # output) if nothing is found _parse_pool() { local seen_ports=" " local out="" local f local line local port while IFS= read -r f; do [ -f "$f" ] || continue while IFS= read -r line; do [ -n "$line" ] || continue port="${line%% *}" case "$seen_ports" in *" $port "*) continue ;; esac seen_ports="$seen_ports$port " out="$out$line " done </dev/null 2>&1 } # rc 0 if a DevTools endpoint is already serving on the port (/json/version # returns a body naming the Browser), rc 1 otherwise. Used to make launch # idempotent: a port already serving is the intended end-state, so we attach # rather than trying to start a second browser on the same profile (which # Chrome's process-singleton would abort). curl is guarded before this runs _port_serving_devtools() { local body; body="$(curl -s --max-time 2 "http://127.0.0.1:$1/json/version" 2>/dev/null)" case "$body" in *'"Browser"'*) return 0 ;; esac return 1 } # Print the PID listening on a port (the browser's main process, which owns all # its renderer/helper children). Used only to report the PID when attaching to an # already-serving browser we didn't launch. Best-effort: needs lsof (not a hard # dependency), prints nothing / rc 1 if lsof is absent or the port has no listener _port_listener_pid() { command -v lsof >/dev/null 2>&1 || return 1 lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1 } # Sets `port` and `server_name`. Uses explicit `port` if set, else picks lowest # free pool port. Emits the off-pool warning. Returns 1 on all-busy / explicit-busy _select_port() { local pool; pool="$(_parse_pool)" # rc ignored: off-pool explicit port must work even if pool empty if [ -n "$port" ]; then if ! _port_free "$port"; then # Port busy. If a DevTools endpoint is already serving it, that IS # the intended end-state (one debug browser per port) -- attach to # it rather than erroring or launching a second instance. Only probe # when we can (curl present) and when actually launching; dry-run and # curl-less runs fall through to the busy error if [ -z "$dry_run" ] && command -v curl >/dev/null 2>&1 && _port_serving_devtools "$port"; then already_serving=1 else _error "port :$port is already in use" return 1 fi fi server_name="$(printf '%s\n' "$pool" | awk -v p="$port" '$1==p {print $2; exit}')" if [ -z "$server_name" ]; then _warn "no chrome-devtools entry for :$port - chrome-devtools-mcp can't attach; fine for Playwright / chrome://inspect / other CDP tools" fi return 0 fi if [ -z "$pool" ]; then _error "no chrome-devtools entries in $(_mcp_json_display); add one or pass -p" return 1 fi local candidates; candidates="$(printf '%s\n' "$pool" | sort -n)" local p local n while read -r p n; do [ -n "$p" ] || continue if _port_free "$p"; then port="$p" server_name="$n" return 0 fi done </dev/null 2>&1 && have_curl=1 # Collect rows first so we can tell "none running" from "some" for the footer local rows="" local line local pid local rest local port local profile local exe local browser local server while IFS= read -r line; do case "$line" in *--remote-debugging-port=*) ;; *) continue ;; esac case "$line" in *--type=*) continue ;; esac # ps right-justifies the pid column, so left-trim before splitting off pid line="${line#"${line%%[![:space:]]*}"}" pid="${line%% *}" case "$pid" in *[!0-9]*|"") continue ;; esac rest="${line#"$pid"}" rest="${rest#"${rest%%[![:space:]]*}"}" port="${line##*--remote-debugging-port=}" port="${port%% *}" case "$port" in *[!0-9]*|"") continue ;; esac # Profile: the --user-data-dir value, up to the next " --" flag or line end # (so a profile path containing spaces still resolves in full) case "$line" in *--user-data-dir=*) profile="${line##*--user-data-dir=}" case "$profile" in *" --"*) profile="${profile%% --*}" ;; esac ;; *) profile="(default)" ;; esac # Browser identity: prefer the live /json/version Browser string; fall back # to the executable basename (command up to the first " --", then basename) browser="" if [ -n "$have_curl" ]; then browser="$(curl -s --max-time 2 "http://127.0.0.1:$port/json/version" 2>/dev/null | jq -r '.Browser // empty' 2>/dev/null)" fi if [ -z "$browser" ]; then exe="$rest" case "$exe" in *" --"*) exe="${exe%% --*}" ;; esac browser="${exe##*/}" [ -n "$browser" ] || browser="unknown" fi server="$(printf '%s\n' "$pool" | awk -v p="$port" '$1==p {print $2; exit}')" [ -n "$server" ] || server="(none)" rows="$rows$(printf '%-6s %-8s %-32s %-22s %s' "$port" "$pid" "$browser" "$server" "$profile") " done </dev/null) LIST if [ -z "$rows" ]; then echo "No debug browsers running (nothing launched with --remote-debugging-port)" return 0 fi printf '%-6s %-8s %-32s %-22s %s\n' "PORT" "PID" "BROWSER" "MCP ENTRY" "PROFILE" printf '%s' "$rows" return 0 } # --- Chrome for Testing acquisition (run-and-go) ---------------------------- # Cache root for downloaded Chrome for Testing builds. Script-specific override # in front of the XDG cache base -- CfT is re-downloadable, so cache is the # correct base. Tree layout: /cft///chrome-/ _cft_cache_root() { printf '%s' "${CHROME_DEBUG_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/chrome-debug}" } # Cached CfT version directory names (basenames of /cft/*/), unsorted. Uses # a glob rather than ls so odd names are handled and shellcheck stays quiet; # callers pipe through sort -V (or -Vr) for version order. $1 is the cft root _cft_version_names() { local root="$1" [ -d "$root" ] || return 0 local d for d in "$root"/*/; do [ -d "$d" ] && basename "$d" done } # List cached CfT builds on stdout (program output): one row per version+platform # with on-disk size and path, sorted by version. Empty cache prints "(empty)". # Filesystem-only; rc 0 always _cft_list() { local root; root="$(_cft_cache_root)/cft" if [ ! -d "$root" ]; then echo "(empty)" return 0 fi local found="" local v local p local plat local size while IFS= read -r v; do [ -n "$v" ] || continue [ -d "$root/$v" ] || continue for p in "$root/$v"/*/; do [ -d "$p" ] || continue plat="$(basename "$p")" size="$(du -sh "$p" 2>/dev/null | cut -f1)" printf '%s %-10s %-6s %s\n' "$v" "$plat" "${size:-?}" "$p" found=1 done done </cft// _cft_version_dir() { printf '%s/cft/%s/%s' "$(_cft_cache_root)" "$1" "$2" } # Deterministic CfT download URL for _cft_download_url() { local base="${CHROME_DEBUG_CFT_DOWNLOAD_BASE:-https://storage.googleapis.com/chrome-for-testing-public}" printf '%s/%s/%s/chrome-%s.zip' "$base" "$1" "$2" "$2" } # Resolve a channel's tip version from the CfT last-known-good JSON. Prints the # version (rc 0), or nothing (rc 1) on fetch/parse failure. $1 is lowercase channel _cft_channel_version() { local ch="$1" local key case "$ch" in stable) key="Stable" ;; beta) key="Beta" ;; dev) key="Dev" ;; canary) key="Canary" ;; *) return 1 ;; esac local endpoint="${CHROME_DEBUG_CFT_ENDPOINT:-https://googlechromelabs.github.io/chrome-for-testing}" local v v="$(curl -fsS --max-time 20 "$endpoint/last-known-good-versions-with-downloads.json" 2>/dev/null \ | jq -r --arg k "$key" '.channels[$k].version // empty' 2>/dev/null)" [ -n "$v" ] || return 1 # Guard the resolved version before it flows into cache paths (mkdir/mv and the # self-heal rm): a channel tip must look like N.N.N.N, same shape as --cft-version printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || return 1 printf '%s' "$v" } # Print the resolved .app executable for a cached build if present and runnable # (rc 0); nothing / rc 1 otherwise. Reuses _resolve_browser on the version dir _cft_cached_path() { local dir; dir="$(_cft_version_dir "$1" "$2")" [ -d "$dir" ] || return 1 _resolve_browser "$dir" 2>/dev/null } # Print the highest cached version (for the platform) that resolves to a runnable # browser, or nothing (rc 1). Backs bare run-and-go's "reuse newest cached" _cft_newest_cached() { local platform="$1" local root; root="$(_cft_cache_root)/cft" [ -d "$root" ] || return 1 local v while IFS= read -r v; do [ -n "$v" ] || continue if _cft_cached_path "$v" "$platform" >/dev/null 2>&1; then printf '%s' "$v" return 0 fi done </dev/null) EOF } # Prune cached CfT builds, keeping the newest $1 versions ("all" or 0 removes all # eligible). Cache-only -- only ever touches /cft. Never removes a build a # running debug browser is using. Honors $dry_run (plans, deletes nothing). rc 0 _cft_prune() { local keep="$1" case "$keep" in all) keep=0 ;; esac local root; root="$(_cft_cache_root)/cft" if [ ! -d "$root" ]; then echo "(empty)" return 0 fi local running; running="$(_running_browser_exes)" local platform; platform="$(_cft_platform)" local i=0 local v local dir local exe while IFS= read -r v; do [ -n "$v" ] || continue [ -d "$root/$v" ] || continue i=$((i + 1)) [ "$i" -le "$keep" ] && continue dir="$root/$v" exe="$(_cft_cached_path "$v" "$platform" 2>/dev/null)" if [ -n "$exe" ] && printf '%s\n' "$running" | grep -qxF "$exe"; then _warn "keeping in-use CfT $v" continue fi if [ -n "$dry_run" ]; then _dry "Would remove: cft/$v ($(du -sh "$dir" 2>/dev/null | cut -f1))" else if rm -rf "$dir"; then _info "removed cft/$v" else _warn "could not remove cft/$v" fi fi done </dev/null 2>&1; then _error "unzip is required" return 3 fi local url; url="$(_cft_download_url "$version" "$platform")" local dest; dest="$(_cft_version_dir "$version" "$platform")" if [ -d "$dest" ]; then # already present (race / leftover) _resolve_browser "$dest" 2>/dev/null && return 0 # present but unresolvable (corrupt/partial leftover): drop it and re-download rm -rf "$dest" fi local parent; parent="$(dirname "$dest")" mkdir -p "$parent" 2>/dev/null || { _error "could not create cache dir '$parent'"; return 1; } local tmp tmp="$(mktemp -d "$parent/.dl.XXXXXX" 2>/dev/null)" || { _error "mktemp failed"; return 1; } local zip="$tmp/cft.zip" _info "downloading CfT $version ($platform) from $url" if ! curl -fL --progress-bar --max-time 300 -o "$zip" "$url"; then rm -rf "$tmp" _error "download failed: $url" return 1 fi if ! unzip -q "$zip" -d "$tmp" 2>/dev/null; then rm -rf "$tmp" _error "could not unzip CfT archive" return 1 fi rm -f "$zip" if [ -d "$dest" ]; then # lost a race; keep the existing tree rm -rf "$tmp" elif ! mv "$tmp" "$dest" 2>/dev/null; then rm -rf "$tmp" _error "could not publish CfT into cache '$dest'" return 1 fi _info "cached CfT $version at $dest" _resolve_browser "$dest" 2>/dev/null || { _error "downloaded CfT but could not resolve its executable under '$dest'"; return 1; } } # Last resort when CfT cannot be obtained: pick a debuggable Chromium already in # ${CHROME_DEBUG_APPS_DIR:-/Applications}, most debug-friendly first. Managed # Google Chrome is deliberately excluded -- it commonly policy-blocks remote # debugging, so auto-picking it would dead-end. Prints the exe (rc 0), else rc 1 _last_resort_browser() { local apps="${CHROME_DEBUG_APPS_DIR:-/Applications}" local name local exe for name in "Google Chrome for Testing.app" "Microsoft Edge.app" "Brave Browser.app" "Chromium.app"; do [ -d "$apps/$name" ] || continue exe="$(_resolve_browser "$apps/$name" 2>/dev/null)" if [ -n "$exe" ]; then printf '%s\n' "$exe" return 0 fi done return 1 } # Resolve the run-and-go browser: pick a CfT version (explicit pin / channel tip / # newest cached), reuse the cache when possible, else download; fall back to a # debuggable /Applications browser only when CfT cannot be obtained. Sets # browser_exe and the cft_* plan vars. rc 0 ok, 1 runtime, 3 dependency. # Under $dry_run it never mutates -- it records the plan and, for reuse, browser_exe _resolve_default_browser() { cft_platform="$(_cft_platform)" local target="" if [ -n "$cft_version" ]; then target="$cft_version" # explicit pin (already validated) elif [ -n "$latest" ]; then target="$(_cft_channel_version "$channel")" [ -n "$target" ] || { _error "could not resolve CfT $channel version (network or API failure)"; return 1; } else local newest; newest="$(_cft_newest_cached "$cft_platform")" if [ -n "$newest" ]; then target="$newest" # bare: reuse newest cached else target="$(_cft_channel_version "$channel")" # empty if offline -> handled below fi fi local cached="" [ -n "$target" ] && cached="$(_cft_cached_path "$target" "$cft_platform" 2>/dev/null)" cft_version="$target" if [ -n "$cached" ]; then cft_from="cache"; cft_path="$cached" elif [ -n "$target" ]; then cft_from="download" cft_url="$(_cft_download_url "$target" "$cft_platform")" cft_dest="$(_cft_version_dir "$target" "$cft_platform")" else cft_from="none" fi if [ -n "$dry_run" ]; then if [ "$cft_from" = "cache" ]; then browser_exe="$cached" elif [ "$cft_from" = "none" ]; then local lr; lr="$(_last_resort_browser 2>/dev/null)" if [ -n "$lr" ]; then browser_exe="$lr"; cft_from="last-resort" else _error "cannot resolve a Chrome for Testing version (offline?) and nothing cached; connect to a network or pass an explicit " return 1 fi fi return 0 fi if [ "$cft_from" = "cache" ]; then _info "reusing cached CfT $target at $cached" browser_exe="$cached" return 0 fi if [ "$cft_from" = "download" ]; then local exe exe="$(_cft_download "$target" "$cft_platform")" local dl_rc=$? if [ "$dl_rc" -eq 0 ] && [ -n "$exe" ]; then browser_exe="$exe" return 0 fi [ "$dl_rc" -eq 3 ] && return 3 # download failed -> fall through to last resort fi local lr; lr="$(_last_resort_browser)" if [ -n "$lr" ]; then _warn "Chrome for Testing unavailable; falling back to $lr" browser_exe="$lr" return 0 fi _error "could not obtain a browser: Chrome for Testing unavailable and no debuggable browser found in ${CHROME_DEBUG_APPS_DIR:-/Applications}; connect to a network or pass an explicit " return 1 } # --help and --list-cache must work even when dependencies are missing: # --help is always available, and --list-cache is a pure filesystem read # (needs neither jq nor nc). Both are recognized as the first argument case "$1" in -h|--help) _show_help; return 0 ;; --list-cache) _cft_list; return $? ;; esac if ! command -v jq >/dev/null 2>&1; then _error "jq is required" return 3 fi # --list needs jq (pool mapping + /json/version parse) but never nc, so handle it # after the jq check. The nc requirement is deferred to the launch path (below the # cache-maintenance dispatch), so --prune-cache doesn't require nc either case "$1" in -l|--list) _list_debug_browsers; return $? ;; esac # --- defaults and parse arguments --- local port="" local server_name="" local already_serving="" local user_data_dir="" local dry_run="" local verbose="" local fresh="" local detached="" local channel="stable" local cft_version="" local latest="" local run_and_go="" local cft_from="" local cft_platform="" local cft_path="" local cft_url="" local cft_dest="" local cache_mode="" local prune_keep="1" local no_extensions="" local no_har="" local no_cache="" local no_devtools_prefs="" local -a devtools_pref_args=() local -a chrome_args=() local -a positionals=() local -a browser_argv=() _expand_short_opts "pdc" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help; return 0 ;; -v|--verbose) verbose=1; shift ;; -n|--dry-run) dry_run=1; shift ;; -f|--fresh) fresh=1; shift ;; --detached) detached=1; shift ;; # long-only: -d is taken by --user-data-dir --latest) latest=1; shift ;; # long-only: -l is taken by --list -c|--channel) [ -n "${2-}" ] || { _error "--channel requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } channel="$2"; shift 2 ;; --channel=*) channel="${1#*=}" [ -n "$channel" ] || { _error "--channel requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; --cft-version) # long-only: -v is taken by --verbose [ -n "${2-}" ] || { _error "--cft-version requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } cft_version="$2"; shift 2 ;; --cft-version=*) cft_version="${1#*=}" [ -n "$cft_version" ] || { _error "--cft-version requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; --prune-cache) # long-only maintenance; optional [keep] value (value-optional recipe) cache_mode="prune"; shift if [ -n "${1:-}" ] && printf '%s' "$1" | grep -qE '^([0-9]+|all)$'; then prune_keep="$1"; shift fi ;; --prune-cache=*) cache_mode="prune"; prune_keep="${1#*=}"; shift printf '%s' "$prune_keep" | grep -qE '^([0-9]+|all)$' || { _error "--prune-cache keep must be a number or 'all'. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } ;; --no-extensions) no_extensions=1; shift ;; # long-only: negation flag --no-har-sensitive) no_har=1; shift ;; # long-only: force the HAR-with-sensitive-data setting off --no-disable-cache) no_cache=1; shift ;; # long-only: force the disable-cache setting off --no-devtools-prefs) no_devtools_prefs=1; shift ;; # long-only: skip all built-in DevTools setting seeding --devtools-pref) [ -n "${2-}" ] || { _error "--devtools-pref requires KEY=VALUE. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } devtools_pref_args+=("$2"); shift 2 ;; --devtools-pref=*) [ -n "${1#*=}" ] || { _error "--devtools-pref requires KEY=VALUE. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } devtools_pref_args+=("${1#*=}"); shift ;; -p|--port) [ -n "${2-}" ] || { _error "--port requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } port="$2"; shift 2 ;; --port=*) port="${1#*=}" [ -n "$port" ] || { _error "--port requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -d|--user-data-dir) [ -n "${2-}" ] || { _error "--user-data-dir requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } user_data_dir="$2"; shift 2 ;; --user-data-dir=*) user_data_dir="${1#*=}" [ -n "$user_data_dir" ] || { _error "--user-data-dir requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; --) shift; while [ $# -gt 0 ]; do chrome_args+=("$1"); shift; done ;; --print-pool) # meta:hidden diagnostic: dump parsed pool, sorted if ! _parse_pool | sort -n | grep .; then _error "no chrome-devtools entries with a resolvable port in $(_mcp_json_display)" return 1 fi return 0 ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; *) positionals+=("$1"); shift ;; esac done # Cache-maintenance modes act and return before any browser logic (they need # neither a browser-location nor curl/nc; --dry-run applies to --prune-cache) if [ "$cache_mode" = "prune" ]; then _cft_prune "$prune_keep" return $? fi # nc is needed by the launch path (_select_port); check it here -- after the # cache-maintenance modes have returned, so those filesystem-only modes don't # require it, and before acquisition so a run-and-go download can't start only # to fail on a missing nc afterward if ! command -v nc >/dev/null 2>&1; then _error "nc is required" return 3 fi if [ "${#positionals[@]}" -gt 1 ]; then _error "Multiple browser locations not allowed (already set to '${positionals[0]}'). Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local browser_location="" if [ "${#positionals[@]}" -eq 0 ]; then run_and_go=1 # no location -> acquire Chrome for Testing (run-and-go) else browser_location="${positionals[0]}" fi # Validate acquisition flags -- only meaningful in run-and-go. With an explicit # location they are ignored, so warn to surface a typo if [ -n "$run_and_go" ]; then case "$channel" in stable|beta|dev|canary) ;; *) _error "Invalid --channel '$channel' (valid: stable|beta|dev|canary). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac if [ -n "$cft_version" ]; then if ! printf '%s' "$cft_version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then _error "Invalid --cft-version '$cft_version' (expected N.N.N.N). Run \`$SCRIPT_NAME -h\` for usage" return 2 fi if [ -n "$latest" ]; then _error "--cft-version and --latest are mutually exclusive. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi fi elif [ "$channel" != "stable" ] || [ -n "$cft_version" ] || [ -n "$latest" ]; then _warn "run-and-go flags (--channel/--cft-version/--latest) ignored with an explicit browser-location" fi # Validate an explicit port is numeric: it flows into /tmp/chrome-debug- # and, with --fresh, into rm -rf, so a non-numeric value must never get through if [ -n "$port" ]; then case "$port" in *[!0-9]*|"") _error "--port must be a port number (got '$port'). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac fi local browser_exe="" if [ -n "$run_and_go" ]; then _resolve_default_browser local rag_rc=$? [ "$rag_rc" -eq 0 ] || return "$rag_rc" else if ! browser_exe="$(_resolve_browser "$browser_location")"; then return 1 fi fi if ! _select_port; then return 1 fi if [ -z "$user_data_dir" ]; then user_data_dir="/tmp/chrome-debug-$port" fi # --- DevTools setting seeding ------------------------------------------------ # DevTools persists frontend settings into the profile's Default/Preferences # JSON, bucketed by each setting's storageType: global settings under # devtools.preferences, SYNCED settings under # devtools.synced_preferences_sync_disabled (the _sync_disabled variant # because we always pass --disable-sync -- with sync on it would be # _sync_enabled). Values are stored as JSON.stringify(value) strings. Seeding # these buckets before launch means the frontend picks them up when it opens. # Two settings are on by default via seed-if-unset (jq //=), so a value the # profile already carries -- e.g. a prior in-session toggle -- is respected; # the --no-* opt-outs and --devtools-pref force a value instead local HAR_SETTING='network.show-options-to-generate-har-with-sensitive-data' local CACHE_SETTING='cache-disabled' local SYNCED_BUCKET='synced_preferences_sync_disabled' local GLOBAL_BUCKET='preferences' local -a seed_bucket=() local -a seed_op=() local -a seed_key=() local -a seed_val=() # Built-in defaults (skipped wholesale by --no-devtools-prefs). Each is # seed-if-unset (//=) at "true", or forced (=) to "false" by its --no-* opt-out if [ -z "$no_devtools_prefs" ]; then if [ -n "$no_har" ]; then seed_bucket+=("$SYNCED_BUCKET"); seed_op+=('='); seed_key+=("$HAR_SETTING"); seed_val+=('false') else seed_bucket+=("$SYNCED_BUCKET"); seed_op+=('//='); seed_key+=("$HAR_SETTING"); seed_val+=('true') fi if [ -n "$no_cache" ]; then seed_bucket+=("$GLOBAL_BUCKET"); seed_op+=('='); seed_key+=("$CACHE_SETTING"); seed_val+=('false') else seed_bucket+=("$GLOBAL_BUCKET"); seed_op+=('//='); seed_key+=("$CACHE_SETTING"); seed_val+=('true') fi fi # Explicit --devtools-pref KEY=VALUE, applied after the defaults so an explicit # setting always wins. KEY may carry a synced:/global: prefix; a bare KEY uses # the global bucket (still applied at launch via read-merge, re-seeded each # run). VALUE is stored exactly as DevTools would: JSON.stringify(parsed-or-string) if [ "${#devtools_pref_args[@]}" -gt 0 ]; then local dp local dp_key local dp_bucket local dp_enc for dp in "${devtools_pref_args[@]}"; do case "$dp" in *=*) : ;; *) _error "--devtools-pref needs KEY=VALUE (got '$dp'). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac dp_key="${dp%%=*}" dp_bucket="$GLOBAL_BUCKET" case "$dp_key" in synced:*) dp_bucket="$SYNCED_BUCKET"; dp_key="${dp_key#synced:}" ;; global:*) dp_bucket="$GLOBAL_BUCKET"; dp_key="${dp_key#global:}" ;; esac [ -n "$dp_key" ] || { _error "--devtools-pref needs a non-empty KEY (got '$dp'). Run \`$SCRIPT_NAME -h\` for usage"; return 2; } dp_enc="$(jq -rn --arg v "${dp#*=}" 'try ($v|fromjson) catch $v | tojson')" seed_bucket+=("$dp_bucket"); seed_op+=('='); seed_key+=("$dp_key"); seed_val+=("$dp_enc") done fi # Render the seed plan for the dry-run / verbose paths. Prints nothing if empty _print_seed_plan() { [ "${#seed_key[@]}" -gt 0 ] || return 0 printf ' devtools-prefs:\n' local i=0 local tag while [ "$i" -lt "${#seed_key[@]}" ]; do tag='global' [ "${seed_bucket[$i]}" = "$SYNCED_BUCKET" ] && tag='synced' printf ' [%s] %s %s %s\n' "$tag" "${seed_key[$i]}" "${seed_op[$i]}" "${seed_val[$i]}" i=$((i + 1)) done } # Merge the seed plan into /Default/Preferences via jq, creating the # file if absent. Non-fatal: a parse or write failure warns and lets the launch # proceed rather than clobbering the profile. Buckets/keys/values pass as --arg # (never interpolated); only the operator (//= or =, a controlled set) is baked _apply_devtools_seeds() { [ "${#seed_key[@]}" -gt 0 ] || return 0 local prof_dir="$1/Default" local prefs="$prof_dir/Preferences" mkdir -p "$prof_dir" 2>/dev/null || { _warn "could not create '$prof_dir'; skipping DevTools seeding"; return 0; } local prog='.' local -a jq_args=() local i=0 while [ "$i" -lt "${#seed_key[@]}" ]; do prog="$prog | .devtools[\$b$i][\$k$i] ${seed_op[$i]} \$v$i" jq_args+=(--arg "b$i" "${seed_bucket[$i]}" --arg "k$i" "${seed_key[$i]}" --arg "v$i" "${seed_val[$i]}") i=$((i + 1)) done local input='{}' [ -f "$prefs" ] && input="$(< "$prefs")" local out if ! out="$(printf '%s' "$input" | jq "${jq_args[@]}" "$prog" 2>/dev/null)"; then _warn "could not parse '$prefs'; skipping DevTools seeding (launch continues)" return 0 fi local tmp tmp="$(mktemp "$prefs.XXXXXX" 2>/dev/null)" || tmp="$prefs.tmp.$$" if printf '%s' "$out" > "$tmp" 2>/dev/null && mv "$tmp" "$prefs" 2>/dev/null; then [ -n "$verbose" ] && _info "seeded ${#seed_key[@]} DevTools pref(s) into $prefs" else rm -f "$tmp" 2>/dev/null _warn "could not write '$prefs'; skipping DevTools seeding (launch continues)" fi } browser_argv=( "--remote-debugging-port=$port" "--user-data-dir=$user_data_dir" "--no-first-run" "--no-default-browser-check" "--disable-sync" ) # --no-extensions is a discoverable alias for the --disable-extensions # passthrough: it suppresses externally-installed extensions (e.g. the # machine's External Extensions manifest) so a debug profile stays bare if [ -n "$no_extensions" ]; then browser_argv+=("--disable-extensions") fi if [ "${#chrome_args[@]}" -gt 0 ]; then browser_argv+=("${chrome_args[@]}") fi # Dry-run: resolve and print the launch plan without launching if [ -n "$dry_run" ]; then if [ -n "$run_and_go" ]; then case "$cft_from" in cache) _dry "Would reuse cached CfT $cft_version at $cft_path" ;; download) _dry "Would download CfT $cft_version ($cft_platform) from $cft_url to $cft_dest" ;; last-resort) _dry "Would fall back to $browser_exe (Chrome for Testing unavailable)" ;; esac fi _dry "Would launch browser with:" printf " browser: '%s'\n" "${browser_exe:-(Chrome for Testing $cft_version, pending download)}" printf " port: '%s'\n" "$port" printf " server: '%s'\n" "${server_name:-(none)}" printf " profile: '%s'\n" "$user_data_dir" [ -n "$detached" ] && printf ' detached: yes\n' if [ -n "$browser_exe" ]; then printf ' command: %s\n' "$(_shell_join "$browser_exe" "${browser_argv[@]}")" fi _print_seed_plan return 0 fi # Prints the success confirmation: version/status line, PID + port, MCP linkage # (or off-pool note), and the profile dir. $1 = browser version, $2 = mode # ("launched" for a freshly-started browser, "serving" for an already-running one # we attached to), $3 = browser PID (launched mode only; serving mode resolves it # from the listening port). The first line differs by mode so the two cases are # distinguishable. The PID and port are reported explicitly so the caller can kill # the browser (kill ) or attach an independent CDP client (127.0.0.1:) _print_attach_info() { local ver="$1" local mode="$2" local pid="$3" if [ "$mode" = "serving" ]; then echo "$SCRIPT_NAME: $ver already serving on :$port" # We didn't launch it, so resolve the owning PID from the listening port # (best-effort: needs lsof, which isn't a hard dependency) [ -z "$pid" ] && pid="$(_port_listener_pid "$port")" else echo "$SCRIPT_NAME: launched $ver, listening on :$port" fi # The main browser process owns every renderer/helper child, so `kill ` # tears the whole browser down. Direct-exec launch (not `open -a`) keeps this # PID valid -- it's the process that holds the debug port echo " pid: ${pid:-unknown} port: $port" # Lead with the CDP endpoint so any client attaches; the mapped .mcp.json # chrome-devtools entry (when present) is a convenience, not a requirement if [ -n "$server_name" ]; then echo " → connect a CDP client to 127.0.0.1:$port (e.g. agent-browser); MCP entry '$server_name' maps to this port" else echo " → connect a CDP client to 127.0.0.1:$port (e.g. agent-browser, chrome-devtools-mcp, Playwright)" fi # Surface the literal teardown command as a reminder (when the PID is known). # Kills the whole browser: the main process owns every renderer/helper child [ -n "$pid" ] && echo " → kill: kill $pid" echo " profile: $user_data_dir" } # curl is only needed to probe the debug endpoint on launch, so check it here # (not unconditionally) -- dry-run and --help must work without it if ! command -v curl >/dev/null 2>&1; then _error "curl is required" return 3 fi # Idempotent-by-port: the port is already serving a debug browser (detected in # _select_port), so attach to it instead of launching a second instance on the # same profile -- which Chrome's process-singleton would abort. Report and exit if [ -n "$already_serving" ]; then # --fresh can't apply to a live browser (its profile is in use, and a # relaunch would singleton-abort), so say so rather than silently ignoring it [ -n "$fresh" ] && _warn "port :$port is already serving; --fresh ignored (close the browser first to start clean)" if [ -n "$no_har" ] || [ -n "$no_cache" ] || [ "${#devtools_pref_args[@]}" -gt 0 ]; then _warn "port :$port is already serving; requested DevTools prefs not seeded (close the browser first)" fi local serving_ver; serving_ver="$(curl -s --max-time 2 "http://127.0.0.1:$port/json/version" 2>/dev/null | jq -r '.Browser // "unknown"' 2>/dev/null)" _print_attach_info "${serving_ver:-unknown}" serving "" return 0 fi # --fresh wipes the profile dir for a clean-slate session. Only on the real # launch path (dry-run returned above, so this never mutates the filesystem # during a dry-run). No-op-safe if the dir doesn't exist yet if [ -n "$fresh" ]; then rm -rf "$user_data_dir" fi # Seed DevTools settings into the profile before launch (after any --fresh # wipe, so a clean slate still gets the defaults). The frontend reads these # buckets when it opens; the attach path above returns before reaching here _apply_devtools_seeds "$user_data_dir" # Probe budget is env-overridable so tests run fast; defaults are for real use local probe_tries="${CHROME_DEBUG_PROBE_TRIES:-20}" local probe_sleep="${CHROME_DEBUG_PROBE_SLEEP:-0.25}" # Launch as a background child; capture PID. Plain `&` already detaches cleanly # (verified: on our exit the browser reparents to launchd and, when headed, stays # in the Dock). The PID is the browser's main process (direct exec, not `open -a`, # so it's the real process, not a LaunchServices stub) -- reported to the caller # for `kill`, and used to `wait` on the success path "$browser_exe" "${browser_argv[@]}" >/dev/null 2>&1 & local browser_pid=$! # Poll /json/version until the DevTools endpoint answers or tries run out local version_json="" local i=0 while [ "$i" -lt "$probe_tries" ]; do version_json="$(curl -s --max-time 2 "http://127.0.0.1:$port/json/version" 2>/dev/null)" case "$version_json" in *'"Browser"'*) break ;; *) version_json="" ;; esac i=$((i + 1)) [ "$probe_sleep" = "0" ] || sleep "$probe_sleep" done if [ -z "$version_json" ]; then _error "launched but debug endpoint never came up on :$port. If a browser is already open on this profile ($user_data_dir), Chrome won't start a second instance -- close it or use --fresh" return 1 fi local browser_ver; browser_ver="$(printf '%s' "$version_json" | jq -r '.Browser // "unknown"' 2>/dev/null)" _print_attach_info "${browser_ver:-unknown}" launched "$browser_pid" # --detached: leave the browser running and return instead of holding the # foreground. Safe because an executed (non-interactive) shell does not SIGHUP # its background jobs on exit, so the browser reparents to launchd and keeps # running -- the same clean detach the plain-& launch above relies on. This is # the tidy alternative to backgrounding the whole command with "chrome-debug ... &" if [ -n "$detached" ]; then echo " detached -- not holding the terminal; browser persists for reuse across sessions" return 0 fi # Hold the foreground so this terminal tab owns the browser: Ctrl-C here kills it # and nothing else. Exit status follows the browser wait "$browser_pid" return $? ) _chrome_debug "$@" __chrome_debug_rc=$? unset -f _chrome_debug if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __chrome_debug_rc; return $__chrome_debug_rc" fi eval "unset __chrome_debug_rc; exit $__chrome_debug_rc"