fix: address browser review feedback

Handle null browser exits, preserve empty-string wait targets, and avoid creating browser directories before action validation.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Harivansh Rathi 2026-03-08 12:57:22 -07:00
parent 0720c47495
commit 4f2dd90d0f
3 changed files with 103 additions and 39 deletions

View file

@ -197,9 +197,8 @@ export function parseArgs(
} }
export function printHelp(): void { export function printHelp(): void {
const defaultToolsText = defaultCodingToolNames.join(","); const defaultToolsText = defaultCodingToolNames.join(", ");
const availableToolsText = Object.keys(allTools).join(", "); const availableToolsText = Object.keys(allTools).join(", ");
const defaultToolsLabel = defaultCodingToolNames.join(", ");
console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, browser, edit, write tools console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, browser, edit, write tools
@ -330,7 +329,7 @@ ${chalk.bold("Environment Variables:")}
PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/)
PI_AI_ANTIGRAVITY_VERSION - Override Antigravity User-Agent version (e.g., 1.23.0) PI_AI_ANTIGRAVITY_VERSION - Override Antigravity User-Agent version (e.g., 1.23.0)
${chalk.bold(`Available Tools (default: ${defaultToolsLabel}):`)} ${chalk.bold(`Available Tools (default: ${defaultToolsText}):`)}
read - Read file contents read - Read file contents
bash - Execute bash commands bash - Execute bash commands
browser - Browser automation with persistent state browser - Browser automation with persistent state

View file

@ -186,6 +186,8 @@ interface BrowserCommandContext {
statePath?: string; statePath?: string;
} }
type BrowserCommandContextWithoutProfile = Omit<BrowserCommandContext, "profilePath">;
function resolveCommandPath(cwd: string, inputPath: string): string { function resolveCommandPath(cwd: string, inputPath: string): string {
return resolve(cwd, inputPath); return resolve(cwd, inputPath);
} }
@ -235,6 +237,18 @@ function ensureBrowserDirs(profilePath: string, stateDir: string): void {
mkdirSync(stateDir, { recursive: true }); mkdirSync(stateDir, { recursive: true });
} }
function createBrowserCommandContext(
profilePath: string,
stateDir: string,
context: BrowserCommandContextWithoutProfile,
): BrowserCommandContext {
ensureBrowserDirs(profilePath, stateDir);
return {
...context,
profilePath,
};
}
function buildWaitArgs(input: BrowserToolInput): { args: string[]; status: string } { function buildWaitArgs(input: BrowserToolInput): { args: string[]; status: string } {
const targets = [ const targets = [
input.ref !== undefined ? "ref" : undefined, input.ref !== undefined ? "ref" : undefined,
@ -248,16 +262,16 @@ function buildWaitArgs(input: BrowserToolInput): { args: string[]; status: strin
throw new Error("browser wait requires exactly one of ref, url, text, ms, or loadState"); throw new Error("browser wait requires exactly one of ref, url, text, ms, or loadState");
} }
if (input.ref) { if (input.ref !== undefined) {
return { args: ["wait", input.ref], status: `Waiting for ${input.ref}...` }; return { args: ["wait", input.ref], status: `Waiting for ${input.ref}...` };
} }
if (input.url) { if (input.url !== undefined) {
return { return {
args: ["wait", "--url", input.url], args: ["wait", "--url", input.url],
status: `Waiting for URL ${input.url}...`, status: `Waiting for URL ${input.url}...`,
}; };
} }
if (input.text) { if (input.text !== undefined) {
return { return {
args: ["wait", "--text", input.text], args: ["wait", "--text", input.text],
status: `Waiting for text "${input.text}"...`, status: `Waiting for text "${input.text}"...`,
@ -283,8 +297,6 @@ function buildBrowserCommand(
): BrowserCommandContext { ): BrowserCommandContext {
const profilePath = getBrowserProfilePath(cwd, options); const profilePath = getBrowserProfilePath(cwd, options);
const stateDir = getBrowserStateDir(cwd, options); const stateDir = getBrowserStateDir(cwd, options);
ensureBrowserDirs(profilePath, stateDir);
const baseArgs = ["--profile", profilePath]; const baseArgs = ["--profile", profilePath];
switch (input.action) { switch (input.action) {
@ -292,58 +304,53 @@ function buildBrowserCommand(
if (!input.url) { if (!input.url) {
throw new Error("browser open requires url"); throw new Error("browser open requires url");
} }
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "open", input.url], args: [...baseArgs, "open", input.url],
statusMessage: `Opening ${input.url}...`, statusMessage: `Opening ${input.url}...`,
successMessage: `Opened ${input.url}`, successMessage: `Opened ${input.url}`,
profilePath, });
};
} }
case "snapshot": { case "snapshot": {
const mode = input.mode ?? "interactive"; const mode = input.mode ?? "interactive";
const args = mode === "interactive" ? [...baseArgs, "snapshot", "-i"] : [...baseArgs, "snapshot"]; const args = mode === "interactive" ? [...baseArgs, "snapshot", "-i"] : [...baseArgs, "snapshot"];
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args, args,
statusMessage: "Capturing browser snapshot...", statusMessage: "Capturing browser snapshot...",
successMessage: "Captured browser snapshot", successMessage: "Captured browser snapshot",
profilePath, });
};
} }
case "click": { case "click": {
if (!input.ref) { if (!input.ref) {
throw new Error("browser click requires ref"); throw new Error("browser click requires ref");
} }
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "click", input.ref], args: [...baseArgs, "click", input.ref],
statusMessage: `Clicking ${input.ref}...`, statusMessage: `Clicking ${input.ref}...`,
successMessage: `Clicked ${input.ref}`, successMessage: `Clicked ${input.ref}`,
profilePath, });
};
} }
case "fill": { case "fill": {
if (!input.ref || input.value === undefined) { if (!input.ref || input.value === undefined) {
throw new Error("browser fill requires ref and value"); throw new Error("browser fill requires ref and value");
} }
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "fill", input.ref, input.value], args: [...baseArgs, "fill", input.ref, input.value],
statusMessage: `Filling ${input.ref}...`, statusMessage: `Filling ${input.ref}...`,
successMessage: `Filled ${input.ref}`, successMessage: `Filled ${input.ref}`,
profilePath, });
};
} }
case "wait": { case "wait": {
const wait = buildWaitArgs(input); const wait = buildWaitArgs(input);
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, ...wait.args], args: [...baseArgs, ...wait.args],
statusMessage: wait.status, statusMessage: wait.status,
successMessage: "Browser wait condition satisfied", successMessage: "Browser wait condition satisfied",
profilePath, });
};
} }
case "screenshot": { case "screenshot": {
const screenshotPath = input.path ? resolveCommandPath(cwd, input.path) : createTempScreenshotPath(); const screenshotPath = input.path ? resolveCommandPath(cwd, input.path) : createTempScreenshotPath();
@ -353,28 +360,26 @@ function buildBrowserCommand(
} }
args.push(screenshotPath); args.push(screenshotPath);
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args, args,
statusMessage: "Taking browser screenshot...", statusMessage: "Taking browser screenshot...",
successMessage: `Saved browser screenshot to ${screenshotPath}`, successMessage: `Saved browser screenshot to ${screenshotPath}`,
profilePath,
screenshotPath, screenshotPath,
}; });
} }
case "state_save": { case "state_save": {
if (!input.stateName) { if (!input.stateName) {
throw new Error("browser state_save requires stateName"); throw new Error("browser state_save requires stateName");
} }
const statePath = join(stateDir, `${sanitizeStateName(input.stateName)}.json`); const statePath = join(stateDir, `${sanitizeStateName(input.stateName)}.json`);
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "state", "save", statePath], args: [...baseArgs, "state", "save", statePath],
statusMessage: `Saving browser state "${input.stateName}"...`, statusMessage: `Saving browser state "${input.stateName}"...`,
successMessage: `Saved browser state "${input.stateName}" to ${statePath}`, successMessage: `Saved browser state "${input.stateName}" to ${statePath}`,
profilePath,
statePath, statePath,
}; });
} }
case "state_load": { case "state_load": {
if (!input.stateName) { if (!input.stateName) {
@ -384,23 +389,21 @@ function buildBrowserCommand(
if (!existsSync(statePath)) { if (!existsSync(statePath)) {
throw new Error(`Saved browser state "${input.stateName}" not found at ${statePath}`); throw new Error(`Saved browser state "${input.stateName}" not found at ${statePath}`);
} }
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "state", "load", statePath], args: [...baseArgs, "state", "load", statePath],
statusMessage: `Loading browser state "${input.stateName}"...`, statusMessage: `Loading browser state "${input.stateName}"...`,
successMessage: `Loaded browser state "${input.stateName}" from ${statePath}`, successMessage: `Loaded browser state "${input.stateName}" from ${statePath}`,
profilePath,
statePath, statePath,
}; });
} }
case "close": case "close":
return { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "close"], args: [...baseArgs, "close"],
statusMessage: "Closing browser...", statusMessage: "Closing browser...",
successMessage: "Closed browser", successMessage: "Closed browser",
profilePath, });
};
default: { default: {
const unsupportedAction: never = input.action; const unsupportedAction: never = input.action;
throw new Error(`Unsupported browser action: ${unsupportedAction}`); throw new Error(`Unsupported browser action: ${unsupportedAction}`);
@ -466,7 +469,7 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
}); });
const output = normalizeOutput(chunks); const output = normalizeOutput(chunks);
if (exitCode !== 0 && exitCode !== null) { if (exitCode !== 0) {
throw new Error(buildBrowserErrorMessage(commandContext.action, output, exitCode)); throw new Error(buildBrowserErrorMessage(commandContext.action, output, exitCode));
} }

View file

@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@ -41,7 +41,7 @@ function getTextOutput(result: ToolResultLike): string {
function createMockBrowserOperations( function createMockBrowserOperations(
output = "", output = "",
exitCode = 0, exitCode: number | null = 0,
): { ): {
calls: BrowserExecCall[]; calls: BrowserExecCall[];
operations: BrowserOperations; operations: BrowserOperations;
@ -166,6 +166,48 @@ describe("browser tool", () => {
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
it("preserves empty string wait targets instead of falling through to loadState", async () => {
const cwd = createTempDir("coding-agent-browser-wait-empty-");
const profileDir = join(cwd, "profile");
const stateDir = join(cwd, "states");
const { calls, operations } = createMockBrowserOperations();
const browserTool = createBrowserTool(cwd, {
operations,
profileDir,
stateDir,
});
await browserTool.execute("browser-wait-empty-text", {
action: "wait",
text: "",
});
expect(calls[0]?.args).toEqual(["--profile", profileDir, "wait", "--text", ""]);
});
it("does not create browser directories when validation fails before command construction", async () => {
const cwd = createTempDir("coding-agent-browser-invalid-open-");
const profileDir = join(cwd, "profile");
const stateDir = join(cwd, "states");
const { operations } = createMockBrowserOperations();
const browserTool = createBrowserTool(cwd, {
operations,
profileDir,
stateDir,
});
await expect(
browserTool.execute("browser-open-missing-url", {
action: "open",
}),
).rejects.toThrow("browser open requires url");
expect(existsSync(profileDir)).toBe(false);
expect(existsSync(stateDir)).toBe(false);
});
it("stores named state under the managed browser state directory", async () => { it("stores named state under the managed browser state directory", async () => {
const cwd = createTempDir("coding-agent-browser-state-"); const cwd = createTempDir("coding-agent-browser-state-");
const profileDir = join(cwd, "profile"); const profileDir = join(cwd, "profile");
@ -191,6 +233,26 @@ describe("browser tool", () => {
expect(getTextOutput(result)).toContain(expectedStatePath); expect(getTextOutput(result)).toContain(expectedStatePath);
}); });
it("treats null exit codes as browser failures", async () => {
const cwd = createTempDir("coding-agent-browser-null-exit-");
const profileDir = join(cwd, "profile");
const stateDir = join(cwd, "states");
const { operations } = createMockBrowserOperations("browser crashed", null);
const browserTool = createBrowserTool(cwd, {
operations,
profileDir,
stateDir,
});
await expect(
browserTool.execute("browser-open-null-exit", {
action: "open",
url: "https://example.com",
}),
).rejects.toThrow('browser crashed\n\nBrowser action "open" failed');
});
it("accepts browser in --tools and exposes it in default tool wiring", () => { it("accepts browser in --tools and exposes it in default tool wiring", () => {
const parsed = parseArgs(["--tools", "browser,read"]); const parsed = parseArgs(["--tools", "browser,read"]);
expect(parsed.tools).toEqual(["browser", "read"]); expect(parsed.tools).toEqual(["browser", "read"]);