aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/cdp/discovery.ts3
-rw-r--r--src/cdp/endpoint.ts76
-rw-r--r--src/cdp/target-store.ts87
-rw-r--r--src/daemon/index.ts10
-rw-r--r--src/daemon/spawn.ts70
-rw-r--r--src/index.ts83
-rw-r--r--src/profile/paths.ts7
-rw-r--r--test/cdp/endpoint-test.ts60
-rw-r--r--test/cdp/target-store-test.ts110
9 files changed, 482 insertions, 24 deletions
diff --git a/src/cdp/discovery.ts b/src/cdp/discovery.ts
index 53d4ad2..84da5f9 100644
--- a/src/cdp/discovery.ts
+++ b/src/cdp/discovery.ts
@@ -7,6 +7,7 @@ import { userDataDirCandidates } from "../profile/paths";
import { type CdpError, cdpError } from "./errors";
import { asString, errnoCode, isRecord } from "../util/guards";
import { describeCdpTarget, resolveCdpTarget } from "./endpoint";
+import { readTarget } from "./target-store";
const PORT_PROBE_DEADLINE_MS = 30_000;
const PORT_PROBE_INTERVAL_MS = 1_000;
@@ -127,7 +128,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
// No userDataDir is returned: the profile registry lives on the remote host,
// so profile enumeration degrades to the pre-profile behaviour rather than
// reading the wrong machine's Local State.
- const target = resolveCdpTarget();
+ const target = resolveCdpTarget(process.env, await readTarget());
if (target.kind === "invalid") {
return err(cdpError("discovery_failed", describeCdpTarget(target)));
}
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}`;
}
};
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);
diff --git a/src/daemon/index.ts b/src/daemon/index.ts
index 226a895..916be6c 100644
--- a/src/daemon/index.ts
+++ b/src/daemon/index.ts
@@ -9,6 +9,16 @@ async function main() {
const cdpBridge = createCdpBridge();
ipcServer.onMessage((msg, client) => {
+ // FORK: a client may ask the daemon to exit. /browser-target uses this to
+ // switch browsers: the target is resolved per daemon process, and this one
+ // is already holding a WebSocket to the old browser, so re-reading the
+ // stored target is not enough — the process has to go and be respawned.
+ if (msg.type === "control" && msg.action === "shutdown") {
+ console.log(`[pi-browser-daemon] Shutdown requested by ${client.id}: ${msg.reason ?? "no reason given"}`);
+ void shutdown();
+ return;
+ }
+
if (msg.type !== "request") return;
const send: SendToClient = (cid, resp) => {
diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts
index cea8325..726cf84 100644
--- a/src/daemon/spawn.ts
+++ b/src/daemon/spawn.ts
@@ -32,6 +32,76 @@ export const daemonSocketMayExist = async (
}
};
+/**
+ * FORK: ask a running daemon to exit, and wait until it has.
+ *
+ * Needed by /browser-target. The CDP target is resolved per daemon process and
+ * the daemon holds an open WebSocket to the old browser, so switching browsers
+ * means replacing the process — re-reading the stored target would not drop
+ * that connection.
+ *
+ * Resolves true once the daemon is gone (or was never running). Resolves false
+ * if it is still answering after the timeout, so callers can report the failure
+ * instead of respawning into a port clash.
+ */
+export const stopDaemon = async (timeoutMs = 5_000): Promise<boolean> => {
+ if (!(await isDaemonRunning(1_500))) {
+ cleanupStaleSocket();
+ return true;
+ }
+
+ await new Promise<void>((resolve) => {
+ let settled = false;
+ const done = (): void => {
+ if (settled) return;
+ settled = true;
+ resolve();
+ };
+
+ const sock = createConnection(DAEMON_SOCKET_PATH);
+ const timer = setTimeout(() => {
+ sock.destroy();
+ done();
+ }, timeoutMs);
+
+ sock.on("connect", () => {
+ // Registration is required before the daemon forwards anything to its
+ // message handler, so the shutdown has to come from a registered client.
+ const reg: WireControl = { type: "control", action: "register", clientId: "_target_switch" };
+ sock.write(serialize(reg) + "\n");
+ });
+
+ sock.on("data", (chunk: Buffer) => {
+ if (!chunk.toString().includes('"registered"')) return;
+ const bye: WireControl = { type: "control", action: "shutdown", reason: "browser target changed" };
+ try {
+ sock.write(serialize(bye) + "\n");
+ } catch {
+ // Daemon may already be going away; the poll below is the real check.
+ }
+ clearTimeout(timer);
+ sock.destroy();
+ done();
+ });
+
+ sock.on("error", () => {
+ clearTimeout(timer);
+ done();
+ });
+ });
+
+ // The daemon exits asynchronously, so confirm rather than assume.
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (!(await isDaemonRunning(1_000))) {
+ cleanupStaleSocket();
+ return true;
+ }
+ await new Promise((r) => setTimeout(r, 200));
+ }
+ return false;
+};
+
export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
if (!(await daemonSocketMayExist())) return false;
diff --git a/src/index.ts b/src/index.ts
index 2b95697..152c94d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,7 +11,9 @@ import { restoreDownloadBehavior } from "./domains/files";
import { createDaemonTransport } from "./daemon/transport";
import { setDebugClicks } from "./util/debug";
import { asBoolean, asString } from "./util/guards";
-import { describeCdpTarget, resolveCdpTarget } from "./cdp/endpoint";
+import { describeCdpTarget, isValidTargetValue, localAliases, resolveCdpTarget } from "./cdp/endpoint";
+import { clearTarget, readTarget, writeTarget } from "./cdp/target-store";
+import { ensureDaemon, stopDaemon } from "./daemon/spawn";
import { queryLiveWsUrl } from "./cdp/discovery";
export default function browserHarnessExtension(pi: ExtensionAPI): void {
@@ -47,7 +49,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
// first browser_* tool runs. A plain "Disconnected" reads as a fault
// when it is really just "not needed yet", so say which it is, and show
// what we would connect to.
- const target = resolveCdpTarget();
+ const target = resolveCdpTarget(process.env, await readTarget());
const reachable =
target.kind === "remote" ? (await queryLiveWsUrl(target.port, 2_000, target.host)) !== null : null;
@@ -91,6 +93,83 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void {
},
});
+ // FORK: switch which browser the harness drives, without leaving pi.
+ //
+ // The target is resolved per daemon process and the daemon holds the browser
+ // connection, so a switch has to replace that process — writing the store
+ // alone would leave tool calls going to the old browser while /browser-status
+ // reported the new one.
+ pi.registerCommand("browser-target", {
+ description: "Show or change which browser to drive (local, default, or host:port)",
+ handler: async (args, ctx) => {
+ const wanted = (typeof args === "string" ? args : "").trim();
+
+ if (wanted === "") {
+ const current = resolveCdpTarget(process.env, await readTarget());
+ ctx.ui.notify(
+ `Browser target: ${describeCdpTarget(current)}\n` +
+ `Change with: /browser-target local | default | host:port`,
+ "info",
+ );
+ return;
+ }
+
+ const clearing = wanted.toLowerCase() === "default";
+ if (!clearing && !isValidTargetValue(wanted)) {
+ ctx.ui.notify(
+ `Not a usable target: "${wanted}"\n` +
+ `Expected "host:port", "default", or one of ${localAliases().join(", ")}.`,
+ "error",
+ );
+ return;
+ }
+
+ const saved = clearing ? await clearTarget() : await writeTarget(wanted);
+ if (!saved.success) {
+ ctx.ui.notify(saved.error, "error");
+ return;
+ }
+
+ const resolved = resolveCdpTarget(process.env, await readTarget());
+
+ // 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();
+ if (envOverride !== "") {
+ ctx.ui.notify(
+ `Saved, but BU_CDP_HTTP=${envOverride} is set in this environment and takes precedence.\n` +
+ `Still using: ${describeCdpTarget(resolved)}`,
+ "warning",
+ );
+ return;
+ }
+
+ ctx.ui.notify(`Switching to ${describeCdpTarget(resolved)}...`, "info");
+
+ if (client) await client.stop();
+ if (!(await stopDaemon())) {
+ ctx.ui.notify("Saved, but the browser daemon did not stop — run /browser-setup to pick it up.", "warning");
+ return;
+ }
+ if (!(await ensureDaemon())) {
+ ctx.ui.notify("Saved, but the browser daemon did not come back. Run /browser-setup.", "error");
+ return;
+ }
+
+ if (!client) {
+ ctx.ui.notify(`Browser target set to ${describeCdpTarget(resolved)} ✓ — run /browser-setup to connect.`, "info");
+ return;
+ }
+ const started = await client.start();
+ ctx.ui.notify(
+ started.success
+ ? `Browser target set to ${describeCdpTarget(resolved)} ✓`
+ : `Target saved (${describeCdpTarget(resolved)}) but connecting failed: ${started.error.message}`,
+ started.success ? "info" : "error",
+ );
+ },
+ });
+
pi.registerCommand("browser-reload-daemon", {
description: "Restart the browser client",
handler: async (_args, ctx) => {
diff --git a/src/profile/paths.ts b/src/profile/paths.ts
index 0a7c74e..4ff3f7d 100644
--- a/src/profile/paths.ts
+++ b/src/profile/paths.ts
@@ -26,6 +26,13 @@ export const agentDir = (): string => {
export const pinFilePath = (): string => join(agentDir(), "browser-harness.json");
+/**
+ * FORK: where the chosen CDP target is persisted (see src/cdp/target-store.ts).
+ * A separate file from the profile pin: the two have different lifetimes, and a
+ * corrupt target must not cost you your profile selection or vice versa.
+ */
+export const targetFilePath = (): string => join(agentDir(), "browser-target.json");
+
const localAppData = (): string => process.env["LOCALAPPDATA"] ?? join(homedir(), "AppData", "Local");
export const userDataDirCandidates = (): ReadonlyArray<string> => {
diff --git a/test/cdp/endpoint-test.ts b/test/cdp/endpoint-test.ts
index d4b696f..57d6088 100644
--- a/test/cdp/endpoint-test.ts
+++ b/test/cdp/endpoint-test.ts
@@ -9,7 +9,12 @@
*
* Run: npx tsx test/cdp/endpoint-test.ts
*/
-import { DEFAULT_REMOTE_CDP, describeCdpTarget, resolveCdpTarget } from "../../src/cdp/endpoint";
+import {
+ DEFAULT_REMOTE_CDP,
+ describeCdpTarget,
+ isValidTargetValue,
+ resolveCdpTarget,
+} from "../../src/cdp/endpoint";
let passed = 0;
let failed = 0;
@@ -92,6 +97,59 @@ function main(): void {
check(describeCdpTarget(resolveCdpTarget(env("local"))).includes("own browser"), "E8: local is described plainly");
}
+ // E9: the stored choice (/browser-target) sits between env and the default.
+ {
+ const t = resolveCdpTarget({}, "local");
+ check(t.kind === "local" && t.source === "stored", "E9: a stored value is used when the env is unset");
+
+ const r = resolveCdpTarget({}, "10.2.3.4:9224");
+ check(r.kind === "remote" && r.source === "stored", "E9: a stored host:port resolves as a remote target");
+ }
+
+ // E10: the environment wins, so a one-off `BU_CDP_HTTP=… pi` is never
+ // silently overridden by a setting made days ago.
+ {
+ const t = resolveCdpTarget(env("local"), "10.2.3.4:9224");
+ check(t.kind === "local" && t.source === "env", "E10: env beats the stored value");
+
+ const r = resolveCdpTarget(env("10.9.9.9:1234"), "local");
+ check(r.kind === "remote" && r.host === "10.9.9.9" && r.source === "env", "E10: an env endpoint beats stored local");
+ }
+
+ // E11: clearing the stored value falls back to the built-in default rather
+ // than to local — clearing is "use the usual browser", not "go local".
+ {
+ const t = resolveCdpTarget({}, null);
+ check(t.kind === "remote" && t.source === "default", "E11: no stored value falls back to the default");
+ const blank = resolveCdpTarget({}, " ");
+ check(blank.kind === "remote" && blank.source === "default", "E11: a blank stored value is treated as cleared");
+ }
+
+ // E12: a corrupt stored value must be reported against the right source, or
+ // the operator goes hunting through their shell config for an env var that
+ // was never set.
+ {
+ const t = resolveCdpTarget({}, "garbage");
+ check(t.kind === "invalid" && t.source === "stored", "E12: a bad stored value is blamed on /browser-target");
+ if (t.kind === "invalid") {
+ check(describeCdpTarget(t).includes("/browser-target"), "E12: the description names the stored source");
+ }
+ const e = resolveCdpTarget(env("garbage"), null);
+ check(e.kind === "invalid" && e.source === "env", "E12: a bad env value is blamed on BU_CDP_HTTP");
+ }
+
+ // E13: the values /browser-target accepts must match what resolution can
+ // actually use, or the command would happily store something unusable.
+ {
+ for (const good of ["local", "off", "1.2.3.4:9222", "castle.local:9223"]) {
+ check(isValidTargetValue(good), `E13: "${good}" is accepted by the command`);
+ }
+ for (const bad of ["", " ", "garbage", "host:", "host:99999"]) {
+ check(!isValidTargetValue(bad), `E13: "${bad}" is rejected by the command`);
+ }
+ check(!isValidTargetValue("default"), "E13: \"default\" is handled as clear, not stored verbatim");
+ }
+
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
diff --git a/test/cdp/target-store-test.ts b/test/cdp/target-store-test.ts
new file mode 100644
index 0000000..56def62
--- /dev/null
+++ b/test/cdp/target-store-test.ts
@@ -0,0 +1,110 @@
+/**
+ * Unit tests for the persisted CDP target — no browser required.
+ *
+ * The store is what makes /browser-target outlive the pi session that ran it,
+ * because the browser daemon is machine-wide. Its central obligation is that a
+ * damaged file degrades to "nothing stored" — falling back to the built-in
+ * default — rather than throwing and taking browser control down with it.
+ *
+ * Run: npx tsx test/cdp/target-store-test.ts
+ */
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string): void => {
+ if (cond) {
+ passed++;
+ console.log(` ✓ ${label}`);
+ } else {
+ failed++;
+ console.error(` ✗ ${label}`);
+ }
+};
+
+async function main(): Promise<void> {
+ // The store resolves its path from pi's agent dir, which paths.ts derives
+ // from the environment — so point that at a scratch dir before importing.
+ const root = mkdtempSync(join(tmpdir(), "pi-target-"));
+ process.env["PI_CODING_AGENT_DIR"] = root;
+
+ const { readTarget, writeTarget, clearTarget } = await import("../../src/cdp/target-store");
+ const { targetFilePath } = await import("../../src/profile/paths");
+ const file = targetFilePath();
+
+ try {
+ // T1: nothing stored yet.
+ check((await readTarget()) === null, "T1: a missing file reads as nothing stored");
+
+ // T2: round-trip.
+ {
+ const w = await writeTarget("local");
+ check(w.success, "T2: writing a target succeeds");
+ check((await readTarget()) === "local", "T2: it reads back verbatim");
+ }
+
+ // T3: overwrite, not append — a switch must fully replace the last choice.
+ {
+ await writeTarget("10.4.5.6:9299");
+ check((await readTarget()) === "10.4.5.6:9299", "T3: a second write replaces the first");
+ }
+
+ // T4: clearing restores "nothing stored", which resolution reads as the
+ // built-in default rather than as local.
+ {
+ const c = await clearTarget();
+ check(c.success, "T4: clearing succeeds");
+ check((await readTarget()) === null, "T4: a cleared target reads as nothing stored");
+ }
+
+ // T5: whitespace is normalised on read, so a hand-edited file with a
+ // trailing newline inside the JSON string still resolves.
+ {
+ writeFileSync(file, JSON.stringify({ version: 1, target: " local ", savedAt: "" }));
+ check((await readTarget()) === "local", "T5: surrounding whitespace is trimmed");
+ }
+
+ // T6: damage must degrade to null, never throw. Each of these is a real
+ // failure mode: truncated write, hand-edit, or a file from a future version.
+ {
+ const damaged: ReadonlyArray<readonly [string, string]> = [
+ ["not json at all", "T6: unparseable JSON"],
+ ['{"version":1,"target":', "T6: truncated JSON"],
+ ['{"version":2,"target":"local"}', "T6: a newer file version"],
+ ['{"version":1,"target":42}', "T6: a non-string target"],
+ ['{"version":1,"target":null}', "T6: an explicitly null target"],
+ ['{"version":1}', "T6: a missing target field"],
+ ["[]", "T6: a JSON array instead of an object"],
+ ['{"version":1,"target":""}', "T6: an empty target string"],
+ ];
+ for (const [contents, label] of damaged) {
+ writeFileSync(file, contents);
+ let threw = false;
+ let value: string | null = "unset";
+ try {
+ value = await readTarget();
+ } catch {
+ threw = true;
+ }
+ check(!threw && value === null, `${label} degrades to nothing stored`);
+ }
+ }
+
+ // T7: recovery — a damaged file must not poison later writes.
+ {
+ writeFileSync(file, "garbage");
+ const w = await writeTarget("local");
+ check(w.success, "T7: writing over a damaged file succeeds");
+ check((await readTarget()) === "local", "T7: the store recovers");
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+
+ console.log(`\n${passed} passed, ${failed} failed`);
+ if (failed > 0) process.exit(1);
+}
+
+void main();