diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-15 07:07:58 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-15 07:07:58 +0100 |
| commit | 3e761274fe7b0e6be4a162e880293150ad9d9fbe (patch) | |
| tree | 89c448734eae908170d32f7f1e55e2defcb66d22 | |
| parent | 19c6f64c8561e286687e2b81a2e1e82fb21fd73b (diff) | |
feat(cdp): report a BU_CDP_WS pin in status, /browser-target and the prompt
Upstream 0.11.0 made BU_CDP_WS reach the daemon, and discoverEndpoint()
returns it before the fork's target is consulted. resolveCdpTarget() did
not know about it, so /browser-status and the system prompt named the
BU_CDP_HTTP/stored/default target while the daemon dialled the pinned URL,
and /browser-target claimed to switch browsers when it could not.
resolveCdpTarget() now puts BU_CDP_WS first, as a remote target with
source "ws". /browser-target warns that it takes precedence, as it already
did for BU_CDP_HTTP. Only host:port is ever shown: the path carries the
browser id and a proxied URL may carry a token. /browser-status does not
probe a pinned URL, since /json/version answering says nothing about
whether the pinned browser id is still current.
| -rw-r--r-- | FORK.md | 10 | ||||
| -rw-r--r-- | src/cdp/endpoint.ts | 60 | ||||
| -rw-r--r-- | src/index.ts | 18 | ||||
| -rw-r--r-- | test/cdp/endpoint-test.ts | 46 |
4 files changed, 120 insertions, 14 deletions
@@ -86,8 +86,14 @@ the variable is set, `/browser-target` says so instead of pretending to obey. Upstream's `BU_CDP_WS` sits above all of these: `discoverEndpoint()` returns it before consulting the fork's target, so an explicit WebSocket URL still wins. -`/browser-status`, `/browser-target` and the system prompt do not know about it, -and describe the `BU_CDP_HTTP`/stored/default target even when it is set. +`resolveCdpTarget()` mirrors that, so `/browser-status` and the system prompt +name the pinned endpoint and `/browser-target` warns that it takes precedence, +as it does for `BU_CDP_HTTP`. They show only its host:port: the path carries the +browser id, and a proxied URL may carry a token. `/browser-status` does not +probe a pinned URL, because `/json/version` answering says nothing about whether +the pinned browser id is still current. A pinned URL counts as a remote target, +like any `BU_CDP_HTTP` value, so profile selection is refused while it is set, +even for a loopback URL. The asymmetry is deliberate. An explicit endpoint is an instruction, so failing to reach it must not quietly automate a different browser — that would drive diff --git a/src/cdp/endpoint.ts b/src/cdp/endpoint.ts index 96cf2ea..9cb21c6 100644 --- a/src/cdp/endpoint.ts +++ b/src/cdp/endpoint.ts @@ -16,10 +16,11 @@ export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223"; const LOCAL_ALIASES = new Set(["local", "localhost", "off", "none", "0", "no"]); /** - * Where a target came from. "env" is BU_CDP_HTTP, "stored" is /browser-target, - * "default" is this fork's built-in. Only "default" is allowed to fall back. + * Where a target came from. "ws" is upstream's BU_CDP_WS, "env" is BU_CDP_HTTP, + * "stored" is /browser-target, "default" is this fork's built-in. Only + * "default" is allowed to fall back. */ -export type CdpTargetSource = "env" | "stored" | "default"; +export type CdpTargetSource = "ws" | "env" | "stored" | "default"; export type CdpTarget = | { @@ -28,12 +29,17 @@ export type CdpTarget = readonly port: number; readonly source: CdpTargetSource; } - | { readonly kind: "local"; readonly source: Exclude<CdpTargetSource, "default"> } + | { readonly kind: "local"; readonly source: Exclude<CdpTargetSource, "default" | "ws"> } | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource }; /** - * Precedence: BU_CDP_HTTP, then the /browser-target choice, then the built-in - * default. + * Precedence: BU_CDP_WS, then BU_CDP_HTTP, then the /browser-target choice, + * then the built-in default. + * + * BU_CDP_WS -> upstream's pinned WebSocket URL; discoverEndpoint() returns + * it before any of the rest is consulted, so it is reported + * here only so status, /browser-target and the prompt agree + * with what the daemon actually dials * * "host:port" -> that remote, and a failure to reach it is fatal * local|off|… -> upstream behaviour, this machine's own browser @@ -57,6 +63,16 @@ export const resolveCdpTarget = ( env: NodeJS.ProcessEnv = process.env, stored: string | null = null, ): CdpTarget => { + // FORK: mirror upstream's endpointFromEnv() in discovery.ts, which wins. + const pinnedWs = env["BU_CDP_WS"]?.trim(); + if (pinnedWs) { + const parsed = parseWsUrl(pinnedWs); + if (!parsed) { + return { kind: "invalid", raw: redactWsUrl(pinnedWs), source: "ws", reason: "expected a ws:// or wss:// URL" }; + } + return { kind: "remote", host: parsed.host, port: parsed.port, source: "ws" }; + } + const fromEnv = env["BU_CDP_HTTP"]?.trim(); const fromStore = stored?.trim(); @@ -104,8 +120,38 @@ const parseHostPort = (value: string): { host: string; port: number } | null => return { host, port }; }; +/** + * host:port of a BU_CDP_WS URL. Only that much is ever shown: the path carries + * the browser's id, and a proxied endpoint may carry a token or credentials. + */ +const parseWsUrl = (value: string): { host: string; port: number } | null => { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") return null; + const host = url.hostname.replace(/^\[(.*)\]$/, "$1"); + if (!host) return null; + const port = url.port === "" ? (url.protocol === "wss:" ? 443 : 80) : Number(url.port); + return { host, port }; +}; + +/** A BU_CDP_WS value safe to print: host:port when parseable, otherwise a placeholder. */ +export const redactWsUrl = (value: string): string => { + const parsed = parseWsUrl(value.trim()); + return parsed ? `${parsed.host}:${parsed.port}` : "<unparseable URL>"; +}; + const sourceLabel = (source: CdpTargetSource): string => - source === "env" ? "BU_CDP_HTTP" : source === "stored" ? "/browser-target" : "built-in default"; + source === "ws" + ? "BU_CDP_WS, pinned WebSocket" + : source === "env" + ? "BU_CDP_HTTP" + : source === "stored" + ? "/browser-target" + : "built-in default"; /** One-line description for /browser-status and error messages. */ export const describeCdpTarget = (target: CdpTarget): string => { diff --git a/src/index.ts b/src/index.ts index e4d23f8..f80c028 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { restoreDownloadBehavior } from "./domains/files"; import { createDaemonTransport } from "./daemon/transport"; import { setDebugClicks } from "./util/debug"; import { asBoolean, asString } from "./util/guards"; -import { describeCdpTarget, isValidTargetValue, localAliases, resolveCdpTarget } from "./cdp/endpoint"; +import { describeCdpTarget, isValidTargetValue, localAliases, redactWsUrl, resolveCdpTarget } from "./cdp/endpoint"; import { clearTarget, readTarget, writeTarget } from "./cdp/target-store"; import { ensureDaemon, stopDaemon } from "./daemon/spawn"; import { queryLiveWsUrl } from "./cdp/discovery"; @@ -50,8 +50,12 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { // when it is really just "not needed yet", so say which it is, and show // what we would connect to. const target = resolveCdpTarget(process.env, await readTarget()); + // FORK: a BU_CDP_WS pin is not probed: /json/version answering says nothing + // about whether the browser id pinned in that URL is still current. const reachable = - target.kind === "remote" ? (await queryLiveWsUrl(target.port, 2_000, target.host)) !== null : null; + target.kind === "remote" && target.source !== "ws" + ? (await queryLiveWsUrl(target.port, 2_000, target.host)) !== null + : null; const browserLine = s.alive ? "🟢 Connected" @@ -61,7 +65,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { const targetLine = target.kind === "remote" - ? `${describeCdpTarget(target)}${reachable ? " — reachable ✓" : " — unreachable ✗"}` + + ? `${describeCdpTarget(target)}${reachable === null ? "" : reachable ? " — reachable ✓" : " — unreachable ✗"}` + (reachable === false && target.source === "default" ? "\n will fall back to this machine's browser" : "") @@ -134,10 +138,14 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { // Warn rather than silently disobey: the environment deliberately wins, // so a stored choice made now would not take effect this session. - const envOverride = (process.env["BU_CDP_HTTP"] ?? "").trim(); + // FORK: BU_CDP_WS outranks both (upstream reads it first); show only its host:port. + const wsOverride = (process.env["BU_CDP_WS"] ?? "").trim(); + const httpOverride = (process.env["BU_CDP_HTTP"] ?? "").trim(); + const envOverride = + wsOverride !== "" ? `BU_CDP_WS (${redactWsUrl(wsOverride)})` : httpOverride !== "" ? `BU_CDP_HTTP=${httpOverride}` : ""; if (envOverride !== "") { ctx.ui.notify( - `Saved, but BU_CDP_HTTP=${envOverride} is set in this environment and takes precedence.\n` + + `Saved, but ${envOverride} is set in this environment and takes precedence.\n` + `Still using: ${describeCdpTarget(resolved)}`, "warning", ); diff --git a/test/cdp/endpoint-test.ts b/test/cdp/endpoint-test.ts index 57d6088..ee8f692 100644 --- a/test/cdp/endpoint-test.ts +++ b/test/cdp/endpoint-test.ts @@ -13,8 +13,10 @@ import { DEFAULT_REMOTE_CDP, describeCdpTarget, isValidTargetValue, + redactWsUrl, resolveCdpTarget, } from "../../src/cdp/endpoint"; +import { getBrowserSystemPrompt } from "../../src/prompt"; let passed = 0; let failed = 0; @@ -150,6 +152,50 @@ function main(): void { check(!isValidTargetValue("default"), "E13: \"default\" is handled as clear, not stored verbatim"); } + // E14: upstream's BU_CDP_WS is read by discoverEndpoint() before anything the + // fork resolves, so it must outrank BU_CDP_HTTP and the stored choice here + // too — otherwise status, /browser-target and the prompt name a browser the + // daemon is not driving. + { + const pinned = "ws://10.0.0.7:9229/devtools/browser/0b5c-uuid"; + const t = resolveCdpTarget({ BU_CDP_WS: pinned, BU_CDP_HTTP: "10.0.0.9:9333" }, "10.0.0.8:9444"); + check(t.kind === "remote", "E14: BU_CDP_WS resolves to a remote target"); + if (t.kind === "remote") { + check(t.host === "10.0.0.7" && t.port === 9229, "E14: it wins over BU_CDP_HTTP and the stored choice"); + check(t.source === "ws", "E14: it is marked as the pinned WebSocket"); + } + const blank = resolveCdpTarget({ BU_CDP_WS: " ", BU_CDP_HTTP: "10.0.0.9:9333" }); + check(blank.kind === "remote" && blank.source === "env", "E14: a blank BU_CDP_WS is ignored, as upstream does"); + } + + // E15: only host:port is ever shown. The path carries the browser id and a + // proxied endpoint may carry a token or credentials. + { + const secret = "wss://user:hunter2@cdp.example.com/devtools/browser/abc?token=s3cr3t"; + const t = resolveCdpTarget({ BU_CDP_WS: secret }); + const shown = describeCdpTarget(t); + check(t.kind === "remote" && t.port === 443, "E15: wss without a port means 443"); + check(shown.startsWith("cdp.example.com:443 (BU_CDP_WS"), `E15: described as host:port — got ${shown}`); + check(!/hunter2|user|abc|token|s3cr3t|devtools/.test(shown), "E15: no path, query or credentials leak"); + check(redactWsUrl(secret) === "cdp.example.com:443", "E15: redactWsUrl keeps host:port only"); + check(redactWsUrl("ws://[::1]:9222/x") === "::1:9222", "E15: IPv6 hosts lose their brackets like elsewhere"); + } + + // E16: an unusable BU_CDP_WS is reported, still without echoing it. + for (const bad of ["not a url", "http://10.0.0.7:9229/devtools/browser/x"]) { + const t = resolveCdpTarget({ BU_CDP_WS: bad }); + check(t.kind === "invalid" && t.source === "ws", `E16: "${bad}" is reported as an invalid BU_CDP_WS`); + check(!describeCdpTarget(t).includes("devtools"), "E16: the invalid value is not echoed"); + } + + // E17: the system prompt names the pinned endpoint, redacted. + { + const t = resolveCdpTarget({ BU_CDP_WS: "ws://10.0.0.7:9229/devtools/browser/0b5c-uuid" }); + const prompt = getBrowserSystemPrompt(true, t); + check(prompt.includes("10.0.0.7:9229"), "E17: the prompt names the pinned host:port"); + check(!prompt.includes("0b5c-uuid"), "E17: the prompt does not carry the browser id"); + } + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); } |
