Merge pull request #260 from getcompanion-ai/codex/deterministic-hosting

deterministic hosting
This commit is contained in:
Hari 2026-03-08 17:59:58 -04:00 committed by GitHub
commit 63e33460f6
2 changed files with 689 additions and 608 deletions

View file

@ -6,7 +6,11 @@ import { join, resolve } from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import { type Static, Type } from "@sinclair/typebox"; import { type Static, Type } from "@sinclair/typebox";
import { getAgentDir } from "../../config.js"; import { getAgentDir } from "../../config.js";
import { getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../../utils/shell.js"; import {
getShellEnv,
killProcessTree,
sanitizeBinaryOutput,
} from "../../utils/shell.js";
const browserActions = [ const browserActions = [
"open", "open",
@ -23,7 +27,8 @@ const browserActions = [
const browserSnapshotModes = ["interactive", "full"] as const; const browserSnapshotModes = ["interactive", "full"] as const;
const browserLoadStates = ["load", "domcontentloaded", "networkidle"] as const; const browserLoadStates = ["load", "domcontentloaded", "networkidle"] as const;
const DEFAULT_BROWSER_COMMAND = process.env.PI_AGENT_BROWSER_COMMAND || "agent-browser"; const DEFAULT_BROWSER_COMMAND =
process.env.PI_AGENT_BROWSER_COMMAND || "agent-browser";
const DEFAULT_BROWSER_TIMEOUT_SECONDS = 90; const DEFAULT_BROWSER_TIMEOUT_SECONDS = 90;
const browserSchema = Type.Object({ const browserSchema = Type.Object({
@ -31,7 +36,9 @@ const browserSchema = Type.Object({
browserActions.map((action) => Type.Literal(action)), browserActions.map((action) => Type.Literal(action)),
{ description: "Browser action to execute" }, { description: "Browser action to execute" },
), ),
url: Type.Optional(Type.String({ description: "URL to open, or URL glob to wait for" })), url: Type.Optional(
Type.String({ description: "URL to open, or URL glob to wait for" }),
),
mode: Type.Optional( mode: Type.Optional(
Type.Union( Type.Union(
browserSnapshotModes.map((mode) => Type.Literal(mode)), browserSnapshotModes.map((mode) => Type.Literal(mode)),
@ -43,7 +50,9 @@ const browserSchema = Type.Object({
description: "Element ref from snapshot output, such as @e2", description: "Element ref from snapshot output, such as @e2",
}), }),
), ),
value: Type.Optional(Type.String({ description: "Text value to fill into a field" })), value: Type.Optional(
Type.String({ description: "Text value to fill into a field" }),
),
text: Type.Optional(Type.String({ description: "Visible text to wait for" })), text: Type.Optional(Type.String({ description: "Visible text to wait for" })),
ms: Type.Optional( ms: Type.Optional(
Type.Number({ Type.Number({
@ -59,13 +68,17 @@ const browserSchema = Type.Object({
), ),
path: Type.Optional( path: Type.Optional(
Type.String({ Type.String({
description: "Output path for screenshots, relative to the current working directory if not absolute", description:
"Output path for screenshots, relative to the current working directory if not absolute",
}), }),
), ),
fullPage: Type.Optional(Type.Boolean({ description: "Capture a full-page screenshot" })), fullPage: Type.Optional(
Type.Boolean({ description: "Capture a full-page screenshot" }),
),
stateName: Type.Optional( stateName: Type.Optional(
Type.String({ Type.String({
description: "Named browser state checkpoint stored under ~/.pi/agent/browser/states/", description:
"Named browser state checkpoint stored under ~/.pi/agent/browser/states/",
}), }),
), ),
}); });
@ -186,7 +199,10 @@ interface BrowserCommandContext {
statePath?: string; statePath?: string;
} }
type BrowserCommandContextWithoutProfile = Omit<BrowserCommandContext, "profilePath">; 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);
@ -197,13 +213,18 @@ function getBrowserRootDir(options?: BrowserToolOptions): string {
return join(baseAgentDir, "browser"); return join(baseAgentDir, "browser");
} }
function getBrowserProfilePath(cwd: string, options?: BrowserToolOptions): string { function getBrowserProfilePath(
const profilePath = options?.profileDir ?? join(getBrowserRootDir(options), "profile"); cwd: string,
options?: BrowserToolOptions,
): string {
const profilePath =
options?.profileDir ?? join(getBrowserRootDir(options), "profile");
return resolveCommandPath(cwd, profilePath); return resolveCommandPath(cwd, profilePath);
} }
function getBrowserStateDir(cwd: string, options?: BrowserToolOptions): string { function getBrowserStateDir(cwd: string, options?: BrowserToolOptions): string {
const stateDir = options?.stateDir ?? join(getBrowserRootDir(options), "states"); const stateDir =
options?.stateDir ?? join(getBrowserRootDir(options), "states");
return resolveCommandPath(cwd, stateDir); return resolveCommandPath(cwd, stateDir);
} }
@ -222,8 +243,12 @@ function sanitizeStateName(stateName: string): string {
throw new Error("stateName is required for browser state actions"); throw new Error("stateName is required for browser state actions");
} }
const withoutJsonSuffix = trimmed.endsWith(".json") ? trimmed.slice(0, -".json".length) : trimmed; const withoutJsonSuffix = trimmed.endsWith(".json")
const sanitized = withoutJsonSuffix.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); ? trimmed.slice(0, -".json".length)
: trimmed;
const sanitized = withoutJsonSuffix
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (sanitized.length === 0) { if (sanitized.length === 0) {
throw new Error(`Invalid browser state name: "${stateName}"`); throw new Error(`Invalid browser state name: "${stateName}"`);
@ -249,7 +274,10 @@ function createBrowserCommandContext(
}; };
} }
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,
input.url !== undefined ? "url" : undefined, input.url !== undefined ? "url" : undefined,
@ -259,7 +287,9 @@ function buildWaitArgs(input: BrowserToolInput): { args: string[]; status: strin
].filter((target): target is string => target !== undefined); ].filter((target): target is string => target !== undefined);
if (targets.length !== 1) { if (targets.length !== 1) {
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 !== undefined) { if (input.ref !== undefined) {
@ -313,7 +343,10 @@ function buildBrowserCommand(
} }
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 createBrowserCommandContext(profilePath, stateDir, { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args, args,
@ -353,7 +386,9 @@ function buildBrowserCommand(
}); });
} }
case "screenshot": { case "screenshot": {
const screenshotPath = input.path ? resolveCommandPath(cwd, input.path) : createTempScreenshotPath(); const screenshotPath = input.path
? resolveCommandPath(cwd, input.path)
: createTempScreenshotPath();
const args = [...baseArgs, "screenshot"]; const args = [...baseArgs, "screenshot"];
if (input.fullPage) { if (input.fullPage) {
args.push("--full"); args.push("--full");
@ -372,7 +407,10 @@ function buildBrowserCommand(
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 createBrowserCommandContext(profilePath, stateDir, { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
args: [...baseArgs, "state", "save", statePath], args: [...baseArgs, "state", "save", statePath],
@ -385,9 +423,14 @@ function buildBrowserCommand(
if (!input.stateName) { if (!input.stateName) {
throw new Error("browser state_load requires stateName"); throw new Error("browser state_load requires stateName");
} }
const statePath = join(stateDir, `${sanitizeStateName(input.stateName)}.json`); const statePath = join(
stateDir,
`${sanitizeStateName(input.stateName)}.json`,
);
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 createBrowserCommandContext(profilePath, stateDir, { return createBrowserCommandContext(profilePath, stateDir, {
action: input.action, action: input.action,
@ -411,7 +454,11 @@ function buildBrowserCommand(
} }
} }
function buildBrowserErrorMessage(action: BrowserToolAction, output: string, exitCode: number | null): string { function buildBrowserErrorMessage(
action: BrowserToolAction,
output: string,
exitCode: number | null,
): string {
const base = const base =
exitCode === null exitCode === null
? `Browser action "${action}" failed` ? `Browser action "${action}" failed`
@ -430,10 +477,14 @@ function getMissingBrowserCommandMessage(command: string): string {
].join("\n"); ].join("\n");
} }
export function createBrowserTool(cwd: string, options?: BrowserToolOptions): AgentTool<typeof browserSchema> { export function createBrowserTool(
cwd: string,
options?: BrowserToolOptions,
): AgentTool<typeof browserSchema> {
const operations = options?.operations ?? defaultBrowserOperations; const operations = options?.operations ?? defaultBrowserOperations;
const command = options?.command ?? DEFAULT_BROWSER_COMMAND; const command = options?.command ?? DEFAULT_BROWSER_COMMAND;
const defaultTimeoutSeconds = options?.defaultTimeoutSeconds ?? DEFAULT_BROWSER_TIMEOUT_SECONDS; const defaultTimeoutSeconds =
options?.defaultTimeoutSeconds ?? DEFAULT_BROWSER_TIMEOUT_SECONDS;
return { return {
name: "browser", name: "browser",
@ -460,17 +511,23 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
try { try {
const { exitCode } = await operations.exec(command, commandContext.args, { const { exitCode } = await operations.exec(
command,
commandContext.args,
{
cwd, cwd,
env: getShellEnv(), env: getShellEnv(),
onData: (data) => chunks.push(data), onData: (data) => chunks.push(data),
signal, signal,
timeout: defaultTimeoutSeconds, timeout: defaultTimeoutSeconds,
}); },
);
const output = normalizeOutput(chunks); const output = normalizeOutput(chunks);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error(buildBrowserErrorMessage(commandContext.action, output, exitCode)); throw new Error(
buildBrowserErrorMessage(commandContext.action, output, exitCode),
);
} }
if (commandContext.action === "snapshot") { if (commandContext.action === "snapshot") {
@ -489,7 +546,11 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
details, details,
}; };
} catch (error) { } catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") { if (
error instanceof Error &&
"code" in error &&
error.code === "ENOENT"
) {
throw new Error(getMissingBrowserCommandMessage(command)); throw new Error(getMissingBrowserCommandMessage(command));
} }
if (error instanceof Error && error.message === "aborted") { if (error instanceof Error && error.message === "aborted") {
@ -497,7 +558,9 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
} }
if (error instanceof Error && error.message.startsWith("timeout:")) { if (error instanceof Error && error.message.startsWith("timeout:")) {
const seconds = error.message.split(":")[1]; const seconds = error.message.split(":")[1];
throw new Error(`Browser action "${commandContext.action}" timed out after ${seconds} seconds`); throw new Error(
`Browser action "${commandContext.action}" timed out after ${seconds} seconds`,
);
} }
throw error; throw error;
} }

View file

@ -121,7 +121,9 @@ describe("browser tool", () => {
const cwd = createTempDir("coding-agent-browser-snapshot-"); const cwd = createTempDir("coding-agent-browser-snapshot-");
const profileDir = join(cwd, "profile"); const profileDir = join(cwd, "profile");
const stateDir = join(cwd, "states"); const stateDir = join(cwd, "states");
const { calls, operations } = createMockBrowserOperations("main [ref=@e1]\nbutton [ref=@e2] Sign in"); const { calls, operations } = createMockBrowserOperations(
"main [ref=@e1]\nbutton [ref=@e2] Sign in",
);
const browserTool = createBrowserTool(cwd, { const browserTool = createBrowserTool(cwd, {
operations, operations,
@ -153,7 +155,9 @@ describe("browser tool", () => {
browserTool.execute("browser-wait-missing", { browserTool.execute("browser-wait-missing", {
action: "wait", action: "wait",
}), }),
).rejects.toThrow("browser wait requires exactly one of ref, url, text, ms, or loadState"); ).rejects.toThrow(
"browser wait requires exactly one of ref, url, text, ms, or loadState",
);
await expect( await expect(
browserTool.execute("browser-wait-ambiguous", { browserTool.execute("browser-wait-ambiguous", {
@ -161,7 +165,9 @@ describe("browser tool", () => {
ref: "@e2", ref: "@e2",
text: "Done", text: "Done",
}), }),
).rejects.toThrow("browser wait requires exactly one of ref, url, text, ms, or loadState"); ).rejects.toThrow(
"browser wait requires exactly one of ref, url, text, ms, or loadState",
);
expect(calls).toHaveLength(0); expect(calls).toHaveLength(0);
}); });
@ -183,7 +189,13 @@ describe("browser tool", () => {
text: "", text: "",
}); });
expect(calls[0]?.args).toEqual(["--profile", profileDir, "wait", "--text", ""]); expect(calls[0]?.args).toEqual([
"--profile",
profileDir,
"wait",
"--text",
"",
]);
}); });
it("does not create browser directories when validation fails before command construction", async () => { it("does not create browser directories when validation fails before command construction", async () => {
@ -226,7 +238,13 @@ describe("browser tool", () => {
})) as ToolResultLike; })) as ToolResultLike;
const expectedStatePath = join(stateDir, "my-session-prod.json"); const expectedStatePath = join(stateDir, "my-session-prod.json");
expect(calls[0]?.args).toEqual(["--profile", profileDir, "state", "save", expectedStatePath]); expect(calls[0]?.args).toEqual([
"--profile",
profileDir,
"state",
"save",
expectedStatePath,
]);
const details = result.details as BrowserToolDetails | undefined; const details = result.details as BrowserToolDetails | undefined;
expect(details?.statePath).toBe(expectedStatePath); expect(details?.statePath).toBe(expectedStatePath);