Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion packages/server/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { shouldTryRemoteBrowserFallback } from "./browser";
import { isNoOpBrowserSentinel, shouldTryRemoteBrowserFallback } from "./browser";

const savedEnv: Record<string, string | undefined> = {};
const envKeys = ["PLANNOTATOR_BROWSER", "BROWSER"];
Expand Down Expand Up @@ -43,4 +43,35 @@ describe("shouldTryRemoteBrowserFallback", () => {
process.env.PLANNOTATOR_BROWSER = "/usr/bin/browser";
expect(shouldTryRemoteBrowserFallback(true)).toBe(false);
});

test("true for remote sessions when BROWSER is a no-op sentinel (e.g. agent view)", () => {
clearEnv();
process.env.BROWSER = "true";
expect(shouldTryRemoteBrowserFallback(true)).toBe(true);
});

test("true for remote sessions when PLANNOTATOR_BROWSER is a no-op sentinel", () => {
clearEnv();
process.env.PLANNOTATOR_BROWSER = "none";
expect(shouldTryRemoteBrowserFallback(true)).toBe(true);
});
});

describe("isNoOpBrowserSentinel", () => {
test("returns false for undefined / empty", () => {
expect(isNoOpBrowserSentinel(undefined)).toBe(false);
expect(isNoOpBrowserSentinel("")).toBe(false);
});

test("recognises the documented no-op values, case- and whitespace-insensitive", () => {
for (const v of ["true", "false", "none", ":", "0", "1", "TRUE", " none "]) {
expect(isNoOpBrowserSentinel(v)).toBe(true);
}
});

test("does not flag real browser handlers", () => {
expect(isNoOpBrowserSentinel("/usr/bin/firefox")).toBe(false);
expect(isNoOpBrowserSentinel("Google Chrome")).toBe(false);
expect(isNoOpBrowserSentinel("open")).toBe(false);
});
});
33 changes: 30 additions & 3 deletions packages/server/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ import fs from "node:fs";

const IPC_REGISTRY = path.join(os.homedir(), ".plannotator", "vscode-ipc.json");

/**
* Common "no-op" values for $BROWSER used by headless/background environments
* (e.g. Claude Code's agent view sets BROWSER=true) to signal "do not actually
* launch a browser". Treating these as if the variable were unset prevents
* silently shelling out to e.g. `true <url>`, which exits 0 without opening
* anything and leaves the Plannotator server hanging on waitForDecision().
*/
const NOOP_BROWSER_VALUES = new Set(["true", "false", "none", ":", "0", "1"]);

export function isNoOpBrowserSentinel(value: string | undefined): boolean {
if (!value) return false;
return NOOP_BROWSER_VALUES.has(value.trim().toLowerCase());
}

/**
* Try opening URL via VS Code extension IPC registry.
* Falls back when env vars (PLANNOTATOR_BROWSER) aren't available to the process.
Expand Down Expand Up @@ -76,15 +90,29 @@ export async function isWSL(): Promise<boolean> {
* Fails silently if browser can't be opened
*/
export function shouldTryRemoteBrowserFallback(isRemote: boolean): boolean {
return isRemote && !process.env.PLANNOTATOR_BROWSER && !process.env.BROWSER;
if (!isRemote) return false;
const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
const browser = process.env.BROWSER;
// Treat headless sentinels (e.g. BROWSER=true from Claude Code's agent view)
// as if no real browser handler were configured, so the IPC fallback still runs.
const hasRealHandler =
(plannotatorBrowser && !isNoOpBrowserSentinel(plannotatorBrowser)) ||
(browser && !isNoOpBrowserSentinel(browser));
return !hasRealHandler;
}

export async function openBrowser(
url: string,
options?: { isRemote?: boolean }
): Promise<boolean> {
try {
const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
const rawPlannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
const rawBrowser = process.env.BROWSER;
const plannotatorBrowser = isNoOpBrowserSentinel(rawPlannotatorBrowser)
? undefined
: rawPlannotatorBrowser;
const envBrowser = isNoOpBrowserSentinel(rawBrowser) ? undefined : rawBrowser;
const browser = plannotatorBrowser || envBrowser;
if (shouldTryRemoteBrowserFallback(options?.isRemote ?? false)) {
const openedViaIpc = await tryVscodeIpc(url);
if (openedViaIpc) {
Expand All @@ -96,7 +124,6 @@ export async function openBrowser(
const wsl = await isWSL();

if (browser) {
const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
if (plannotatorBrowser && platform === "darwin") {
if (plannotatorBrowser.includes("/") && !plannotatorBrowser.endsWith(".app")) {
await $`${plannotatorBrowser} ${url}`.quiet();
Expand Down
Loading