aboutsummaryrefslogtreecommitdiff
path: root/src/cdp/target-store.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/target-store.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/target-store.ts')
-rw-r--r--src/cdp/target-store.ts87
1 files changed, 87 insertions, 0 deletions
diff --git a/src/cdp/target-store.ts b/src/cdp/target-store.ts
new file mode 100644
index 0000000..3a35f0a
--- /dev/null
+++ b/src/cdp/target-store.ts
@@ -0,0 +1,87 @@
+/**
+ * FORK: durable persistence for the chosen CDP target.
+ *
+ * `/browser-target` has to outlive the pi session that ran it, because the
+ * browser daemon is machine-wide and shared by every session — so the choice
+ * cannot live in pi's session entries. Same reasoning, and the same atomic
+ * write, as the profile pin in src/profile/store.ts; a separate file so a
+ * corrupt target cannot cost you your profile selection.
+ *
+ * The stored value is the raw string a user would have put in BU_CDP_HTTP
+ * ("local", "10.88.0.25:9223", …). It is deliberately NOT pre-parsed: keeping
+ * it in the same vocabulary as the environment variable means one resolution
+ * path in src/cdp/endpoint.ts, and a stored value can never mean something the
+ * variable could not.
+ *
+ * Every read failure degrades to "nothing stored" rather than throwing — a
+ * corrupt file must never break browser control, it should just fall back to
+ * the built-in default.
+ */
+
+import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
+import { randomUUID } from "node:crypto";
+import { dirname } from "node:path";
+import { type Result, err, ok } from "../util/result";
+import { isRecord } from "../util/guards";
+import { targetFilePath } from "../profile/paths";
+
+const CURRENT_VERSION = 1;
+
+type TargetFile = {
+ readonly version: 1;
+ /** Raw BU_CDP_HTTP-style value, or null when cleared. */
+ readonly target: string | null;
+ readonly savedAt: string;
+};
+
+/**
+ * The persisted target, or null when nothing is stored, the file is missing, or
+ * it cannot be parsed. A file written by a newer version is treated as "nothing
+ * stored" rather than guessed at.
+ */
+export const readTarget = async (): Promise<string | null> => {
+ let raw: string;
+ try {
+ raw = await readFile(targetFilePath(), "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!isRecord(parsed)) return null;
+ const file = parsed;
+ if (file["version"] !== CURRENT_VERSION) return null;
+ const target = file["target"];
+ if (typeof target !== "string") return null;
+ const trimmed = target.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Replace the target atomically: a sibling temp file plus rename, so a crash
+ * mid-write cannot leave a half-written target behind — which would otherwise
+ * silently point the agent at the wrong browser.
+ */
+const writeTargetFile = async (target: string | null): Promise<Result<void, string>> => {
+ const path = targetFilePath();
+ const tmp = `${path}.${randomUUID()}.tmp`;
+ const payload: TargetFile = { version: CURRENT_VERSION, target, savedAt: new Date().toISOString() };
+ try {
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
+ await rename(tmp, path);
+ return ok(undefined);
+ } catch (e) {
+ await unlink(tmp).catch(() => {});
+ return err(`could not save browser target to ${path}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+};
+
+/** Persist the chosen target. */
+export const writeTarget = (target: string): Promise<Result<void, string>> => writeTargetFile(target);
+
+/** Clear the choice, restoring the built-in default. */
+export const clearTarget = (): Promise<Result<void, string>> => writeTargetFile(null);