Skip to content

Commit 42c85f0

Browse files
authored
fix(browser): treat no-op BROWSER values as unset so headless sessions can fall back (#756)
Claude Code's agent view (and similar background/headless environments) sets $BROWSER=true to mean "do not launch a browser." The previous logic took that literally and ran `true http://localhost:NNNN` via `Bun.$`, which exits 0 without opening anything. The server then sits forever in waitForDecision() with no visible UI and no way for the user to recover the URL. Detect the documented no-op sentinels (`true`, `false`, `none`, `:`, `0`, `1`) on both PLANNOTATOR_BROWSER and BROWSER and treat them as if the variable were unset. This lets shouldTryRemoteBrowserFallback() reach the VS Code IPC fallback in remote sessions and lets openBrowser() fall through to the platform default when nothing else is configured. Refs #154.
1 parent 29390c9 commit 42c85f0

2 files changed

Lines changed: 62 additions & 4 deletions

File tree

packages/server/browser.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { shouldTryRemoteBrowserFallback } from "./browser";
2+
import { isNoOpBrowserSentinel, shouldTryRemoteBrowserFallback } from "./browser";
33

44
const savedEnv: Record<string, string | undefined> = {};
55
const envKeys = ["PLANNOTATOR_BROWSER", "BROWSER"];
@@ -43,4 +43,35 @@ describe("shouldTryRemoteBrowserFallback", () => {
4343
process.env.PLANNOTATOR_BROWSER = "/usr/bin/browser";
4444
expect(shouldTryRemoteBrowserFallback(true)).toBe(false);
4545
});
46+
47+
test("true for remote sessions when BROWSER is a no-op sentinel (e.g. agent view)", () => {
48+
clearEnv();
49+
process.env.BROWSER = "true";
50+
expect(shouldTryRemoteBrowserFallback(true)).toBe(true);
51+
});
52+
53+
test("true for remote sessions when PLANNOTATOR_BROWSER is a no-op sentinel", () => {
54+
clearEnv();
55+
process.env.PLANNOTATOR_BROWSER = "none";
56+
expect(shouldTryRemoteBrowserFallback(true)).toBe(true);
57+
});
58+
});
59+
60+
describe("isNoOpBrowserSentinel", () => {
61+
test("returns false for undefined / empty", () => {
62+
expect(isNoOpBrowserSentinel(undefined)).toBe(false);
63+
expect(isNoOpBrowserSentinel("")).toBe(false);
64+
});
65+
66+
test("recognises the documented no-op values, case- and whitespace-insensitive", () => {
67+
for (const v of ["true", "false", "none", ":", "0", "1", "TRUE", " none "]) {
68+
expect(isNoOpBrowserSentinel(v)).toBe(true);
69+
}
70+
});
71+
72+
test("does not flag real browser handlers", () => {
73+
expect(isNoOpBrowserSentinel("/usr/bin/firefox")).toBe(false);
74+
expect(isNoOpBrowserSentinel("Google Chrome")).toBe(false);
75+
expect(isNoOpBrowserSentinel("open")).toBe(false);
76+
});
4677
});

packages/server/browser.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import fs from "node:fs";
99

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

12+
/**
13+
* Common "no-op" values for $BROWSER used by headless/background environments
14+
* (e.g. Claude Code's agent view sets BROWSER=true) to signal "do not actually
15+
* launch a browser". Treating these as if the variable were unset prevents
16+
* silently shelling out to e.g. `true <url>`, which exits 0 without opening
17+
* anything and leaves the Plannotator server hanging on waitForDecision().
18+
*/
19+
const NOOP_BROWSER_VALUES = new Set(["true", "false", "none", ":", "0", "1"]);
20+
21+
export function isNoOpBrowserSentinel(value: string | undefined): boolean {
22+
if (!value) return false;
23+
return NOOP_BROWSER_VALUES.has(value.trim().toLowerCase());
24+
}
25+
1226
/**
1327
* Try opening URL via VS Code extension IPC registry.
1428
* Falls back when env vars (PLANNOTATOR_BROWSER) aren't available to the process.
@@ -76,15 +90,29 @@ export async function isWSL(): Promise<boolean> {
7690
* Fails silently if browser can't be opened
7791
*/
7892
export function shouldTryRemoteBrowserFallback(isRemote: boolean): boolean {
79-
return isRemote && !process.env.PLANNOTATOR_BROWSER && !process.env.BROWSER;
93+
if (!isRemote) return false;
94+
const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
95+
const browser = process.env.BROWSER;
96+
// Treat headless sentinels (e.g. BROWSER=true from Claude Code's agent view)
97+
// as if no real browser handler were configured, so the IPC fallback still runs.
98+
const hasRealHandler =
99+
(plannotatorBrowser && !isNoOpBrowserSentinel(plannotatorBrowser)) ||
100+
(browser && !isNoOpBrowserSentinel(browser));
101+
return !hasRealHandler;
80102
}
81103

82104
export async function openBrowser(
83105
url: string,
84106
options?: { isRemote?: boolean }
85107
): Promise<boolean> {
86108
try {
87-
const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
109+
const rawPlannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
110+
const rawBrowser = process.env.BROWSER;
111+
const plannotatorBrowser = isNoOpBrowserSentinel(rawPlannotatorBrowser)
112+
? undefined
113+
: rawPlannotatorBrowser;
114+
const envBrowser = isNoOpBrowserSentinel(rawBrowser) ? undefined : rawBrowser;
115+
const browser = plannotatorBrowser || envBrowser;
88116
if (shouldTryRemoteBrowserFallback(options?.isRemote ?? false)) {
89117
const openedViaIpc = await tryVscodeIpc(url);
90118
if (openedViaIpc) {
@@ -96,7 +124,6 @@ export async function openBrowser(
96124
const wsl = await isWSL();
97125

98126
if (browser) {
99-
const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER;
100127
if (plannotatorBrowser && platform === "darwin") {
101128
if (plannotatorBrowser.includes("/") && !plannotatorBrowser.endsWith(".app")) {
102129
await $`${plannotatorBrowser} ${url}`.quiet();

0 commit comments

Comments
 (0)