aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FORK.md10
-rw-r--r--src/cdp/endpoint.ts60
-rw-r--r--src/index.ts18
-rw-r--r--test/cdp/endpoint-test.ts46
4 files changed, 120 insertions, 14 deletions
diff --git a/FORK.md b/FORK.md
index 639e76c..d106983 100644
--- a/FORK.md
+++ b/FORK.md
@@ -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);
}