all repos — sz-chrome-remote-cdp @ 9e9bf2a3badb59d0ce5bdb47c4e9d09ffb259e69

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

src/profile/bind.ts (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
/**
 * Binding the harness session to a pinned profile.
 *
 * Sequence, all of it verified against a live browser:
 *   1. resolve the browser binary and any explicit --user-data-dir
 *   2. launch a window into the profile, marked with a file:// sentinel
 *   3. poll Target.getTargets for the sentinel page — its browserContextId IS
 *      the profile's context, which is otherwise undiscoverable (CDP exposes no
 *      profile identity)
 *   4. adopt that page as the harness window, blank it, drop the sentinel file
 *
 * If the sentinel never appears the bind FAILS LOUDLY. Falling through to a
 * normal createTarget would put the agent in the focus-derived default profile
 * — the exact bug this feature exists to remove — so the caller gets an
 * actionable error instead.
 *
 * The resolved context id is session-scoped state, never persisted: Chrome
 * mints new context ids on every browser run.
 */

import type { BrowserClient } from "../client";
import { attachTo } from "../cdp/attach";
import { type CdpError, cdpError } from "../cdp/errors";
import { getWindowId } from "../cdp/window";
import { type Result, err, ok } from "../util/result";
import { detectRunningBrowser, resolveBrowserExecutable } from "./browser-process";
import { openProfileWindow } from "./launch";
import type { ProfilePin } from "./store";

/** The launch delegates through ProcessSingleton, so allow for a cold profile. */
const SEED_DEADLINE_MS = 15_000;
const SEED_POLL_MS = 500;

type PageTarget = {
  readonly targetId: string;
  readonly type: string;
  readonly url: string;
  readonly browserContextId?: string;
};

const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
  const r = await client.session().callBrowser("Target.getTargets");
  if (!r.success) return [];
  const data = r.data as { targetInfos: ReadonlyArray<PageTarget> };
  return data.targetInfos.filter((t) => t.type === "page");
};

const manualFallbackHint = (pin: ProfilePin): string =>
  `open a window for "${pin.label}" yourself (browser profile menu → ${pin.profileDir}), then retry. ` +
  `Run /browser-profile to pick a different profile, or clear the selection to use whichever window is focused.`;

/**
 * Open (and adopt) a harness window inside the pinned profile. Returns the
 * seed tab's target id.
 */
export const seedProfileWindow = async (
  client: BrowserClient,
  pin: ProfilePin,
): Promise<Result<string, CdpError>> => {
  // A pin belongs to one user-data-dir. If the harness is now attached to a
  // different browser, silently seeding the "same" profile name would be wrong.
  const connectedDir = client.userDataDir();
  if (connectedDir && connectedDir !== pin.userDataDir) {
    return err(cdpError(
      "discovery_failed",
      `the selected profile "${pin.label}" belongs to ${pin.userDataDir}, but the harness is connected to ${connectedDir}. ` +
      `Run /browser-profile to choose a profile in the browser you're using.`,
    ));
  }

  // Identify the browser by the dir we're connected to. With several Chromium
  // browsers running, launching into the wrong one would open a window the
  // harness cannot see — in a browser the user never pointed us at.
  const targetUserDataDir = connectedDir ?? pin.userDataDir;
  const running = await detectRunningBrowser(targetUserDataDir);
  const exePath = await resolveBrowserExecutable(running);
  if (!exePath) {
    return err(cdpError(
      "discovery_failed",
      `could not locate the browser executable needed to open "${pin.label}" — ${manualFallbackHint(pin)}`,
    ));
  }

  const launched = await openProfileWindow({
    exePath,
    profileDir: pin.profileDir,
    // Forward the user-data-dir ONLY when the running browser advertises one.
    // ProcessSingleton keys on this path: passing the value the target process
    // itself reports guarantees delegation, whereas supplying a default dir we
    // reconstructed could differ by case or normalisation and start a SECOND
    // browser instead.
    ...(running.explicitUserDataDir ? { explicitUserDataDir: running.explicitUserDataDir } : {}),
  });
  if (!launched.success) {
    return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`));
  }

  const { sentinelUrl, cleanup, kill } = launched.data;
  const deadline = Date.now() + SEED_DEADLINE_MS;
  let seed: PageTarget | undefined;
  while (Date.now() < deadline && !seed) {
    await new Promise((r) => setTimeout(r, SEED_POLL_MS));
    seed = (await pageTargets(client)).find((t) => t.url === sentinelUrl);
  }

  if (!seed) {
    kill();
    await cleanup();
    return err(cdpError(
      "timeout",
      `couldn't open a window in "${pin.label}" automatically — ${manualFallbackHint(pin)}`,
    ));
  }

  // The sentinel's context is the profile's context — the only way to learn it.
  if (seed.browserContextId) client.setProfileContextId(seed.browserContextId);

  const ownership = client.ownership();
  ownership.setHarnessWindow(seed.targetId);
  ownership.add(seed.targetId);
  const win = await getWindowId(client.session(), seed.targetId);
  if (win.success) ownership.setHarnessWindowId(win.data);

  // Blank the handshake page so the seed tab behaves like any other fresh tab,
  // then remove the sentinel file. Best-effort: a failure here costs nothing
  // beyond the placeholder page staying visible.
  const attached = await attachTo(client.session(), seed.targetId);
  if (attached.success) {
    await client.session().callOnTarget("Page.navigate", { url: "about:blank" }, attached.data);
  }
  await cleanup();

  return ok(seed.targetId);
};