aboutsummaryrefslogtreecommitdiff
path: root/src/cdp/endpoint.ts
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-07-29 10:38:13 +0100
committerIgor Soarez <igor@soarez.org>2026-10-14 12:19:26 +0100
commit08e558753e8ef9b2aa96114981a5a1dd37357bad (patch)
tree1d52d895f98d7880e7b1cd1ab6ed13321814dd86 /src/cdp/endpoint.ts
parent6eda4b9cce4b2ae7003ccd7429e3f3aad7c04b66 (diff)
feat(cdp): /browser-target to switch browsers without leaving pi
Switching between the shared browser and this machine's own previously meant leaving pi and relaunching with BU_CDP_HTTP — and, less obviously, killing the daemon first, or the switch silently did not happen. /browser-target show the current target /browser-target local this machine's browser /browser-target default the built-in default /browser-target host:port somewhere else Precedence becomes env > stored > default. The environment still wins so a one-off `BU_CDP_HTTP=… pi` is never silently overridden by a choice made days ago; when it is set, the command says so rather than pretending to obey. Clearing falls back to the default rather than to local — "use the usual browser", not "go local". The daemon is the reason this needed more than a settings write. It is machine-wide, resolves its target once per process, and holds the WebSocket to the old browser, so re-reading the store would leave tool calls going to the old browser while /browser-status reported the new one. The command therefore replaces the process: the daemon now honours an inbound control/shutdown (the action already existed in the protocol, previously only sent daemon→client), and stopDaemon() asks for it and polls until the process is really gone before ensureDaemon() respawns it against the new target. /browser-status now reads the store too, so it cannot disagree with what the tools will do. Persistence mirrors the profile pin: atomic temp-file-plus-rename, and every read failure degrades to "nothing stored" so a corrupt file falls back to the default instead of breaking browser control. It is a separate file from the pin, so damage to one cannot cost you the other. The stored value stays in BU_CDP_HTTP's vocabulary so there is exactly one resolution path. 62 tests: the full precedence table including the stored tier, that a bad value is blamed on the source that set it rather than sending you hunting through shell config, that the command only accepts values resolution can actually use, and eight ways of damaging the store file.
Diffstat (limited to 'src/cdp/endpoint.ts')
-rw-r--r--src/cdp/endpoint.ts76
1 files changed, 56 insertions, 20 deletions
diff --git a/src/cdp/endpoint.ts b/src/cdp/endpoint.ts
index 1be5322..96cf2ea 100644
--- a/src/cdp/endpoint.ts
+++ b/src/cdp/endpoint.ts
@@ -15,31 +15,53 @@ export const DEFAULT_REMOTE_CDP = "10.88.0.25:9223";
/** Values of BU_CDP_HTTP that mean "ignore the default, use this machine". */
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.
+ */
+export type CdpTargetSource = "env" | "stored" | "default";
+
export type CdpTarget =
| {
readonly kind: "remote";
readonly host: string;
readonly port: number;
- /** "env" was asked for explicitly; "default" is this fork's built-in. */
- readonly source: "env" | "default";
+ readonly source: CdpTargetSource;
}
- | { readonly kind: "local"; readonly source: "env" }
- | { readonly kind: "invalid"; readonly raw: string; readonly reason: string };
+ | { readonly kind: "local"; readonly source: Exclude<CdpTargetSource, "default"> }
+ | { readonly kind: "invalid"; readonly raw: string; readonly reason: string; readonly source: CdpTargetSource };
/**
- * Precedence:
- * BU_CDP_HTTP="host:port" -> that remote, and a failure to reach it is fatal
- * BU_CDP_HTTP=local|off|… -> upstream behaviour, this machine's own browser
- * unset -> DEFAULT_REMOTE_CDP, falling back to local when
- * it isn't reachable
+ * Precedence: BU_CDP_HTTP, then the /browser-target choice, then the built-in
+ * default.
+ *
+ * "host:port" -> that remote, and a failure to reach it is fatal
+ * local|off|… -> upstream behaviour, this machine's own browser
+ * neither set -> DEFAULT_REMOTE_CDP, falling back to local when unreachable
+ *
+ * The environment wins over the stored choice so that a one-off
+ * `BU_CDP_HTTP=… pi` is never silently overridden by a setting made days ago.
+ *
+ * The fallback asymmetry is deliberate. An explicit endpoint — from either
+ * source — is an instruction, so silently driving a different browser instead
+ * would be wrong: it would automate the operator's own signed-in Chrome rather
+ * than the shared one. The built-in default is only a preference, so a laptop
+ * away from the wire network still gets the local browser the extension was
+ * originally written for.
*
- * The asymmetry is deliberate. An explicit endpoint is an instruction, so
- * silently driving a different browser instead would be wrong. The built-in
- * default is only a preference, so a laptop away from the wire network still
- * gets the local browser the extension was originally written for.
+ * `stored` is passed in rather than read here so this stays pure and
+ * synchronous: /browser-status can describe the target without I/O, and the
+ * daemon can resolve identically in its own process.
*/
-export const resolveCdpTarget = (env: NodeJS.ProcessEnv = process.env): CdpTarget => {
- const raw = env["BU_CDP_HTTP"]?.trim();
+export const resolveCdpTarget = (
+ env: NodeJS.ProcessEnv = process.env,
+ stored: string | null = null,
+): CdpTarget => {
+ const fromEnv = env["BU_CDP_HTTP"]?.trim();
+ const fromStore = stored?.trim();
+
+ const raw = fromEnv !== undefined && fromEnv !== "" ? fromEnv : fromStore;
+ const source: CdpTargetSource = fromEnv !== undefined && fromEnv !== "" ? "env" : "stored";
if (raw === undefined || raw === "") {
const parsed = parseHostPort(DEFAULT_REMOTE_CDP);
@@ -48,17 +70,28 @@ export const resolveCdpTarget = (env: NodeJS.ProcessEnv = process.env): CdpTarge
return { kind: "remote", host: parsed.host, port: parsed.port, source: "default" };
}
- if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source: "env" };
+ if (LOCAL_ALIASES.has(raw.toLowerCase())) return { kind: "local", source };
const parsed = parseHostPort(raw);
if (!parsed) {
return {
kind: "invalid",
raw,
+ source,
reason: `expected "host:port" or one of ${[...LOCAL_ALIASES].join(", ")}`,
};
}
- return { kind: "remote", host: parsed.host, port: parsed.port, source: "env" };
+ return { kind: "remote", host: parsed.host, port: parsed.port, source };
+};
+
+/** The vocabulary /browser-target accepts, for help text and validation. */
+export const localAliases = (): ReadonlyArray<string> => [...LOCAL_ALIASES];
+
+/** True when `value` is something resolveCdpTarget can make sense of. */
+export const isValidTargetValue = (value: string): boolean => {
+ const v = value.trim();
+ if (v === "") return false;
+ return LOCAL_ALIASES.has(v.toLowerCase()) || parseHostPort(v) !== null;
};
const parseHostPort = (value: string): { host: string; port: number } | null => {
@@ -71,14 +104,17 @@ const parseHostPort = (value: string): { host: string; port: number } | null =>
return { host, port };
};
+const sourceLabel = (source: CdpTargetSource): string =>
+ 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 => {
switch (target.kind) {
case "remote":
- return `${target.host}:${target.port}` + (target.source === "default" ? " (built-in default)" : " (BU_CDP_HTTP)");
+ return `${target.host}:${target.port} (${sourceLabel(target.source)})`;
case "local":
- return "this machine's own browser (BU_CDP_HTTP=local)";
+ return `this machine's own browser (${sourceLabel(target.source)})`;
case "invalid":
- return `invalid BU_CDP_HTTP "${target.raw}" — ${target.reason}`;
+ return `invalid target "${target.raw}" from ${sourceLabel(target.source)} — ${target.reason}`;
}
};