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 |
/**
* 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 { ResultOf } from "../cdp/commands";
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 = ResultOf<"Target.getTargets">["targetInfos"][number];
const pageTargets = async (client: BrowserClient): Promise<ReadonlyArray<PageTarget>> => {
const r = await client.session().callBrowser("Target.getTargets");
if (!r.success) return [];
return r.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);
};
|