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);
};
|