mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-17 11:04:54 +00:00
Merge pull request #260 from getcompanion-ai/codex/deterministic-hosting
deterministic hosting
This commit is contained in:
commit
63e33460f6
2 changed files with 689 additions and 608 deletions
|
|
@ -6,7 +6,11 @@ import { join, resolve } from "node:path";
|
|||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { getAgentDir } from "../../config.js";
|
||||
import { getShellEnv, killProcessTree, sanitizeBinaryOutput } from "../../utils/shell.js";
|
||||
import {
|
||||
getShellEnv,
|
||||
killProcessTree,
|
||||
sanitizeBinaryOutput,
|
||||
} from "../../utils/shell.js";
|
||||
|
||||
const browserActions = [
|
||||
"open",
|
||||
|
|
@ -23,7 +27,8 @@ const browserActions = [
|
|||
const browserSnapshotModes = ["interactive", "full"] 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 browserSchema = Type.Object({
|
||||
|
|
@ -31,7 +36,9 @@ const browserSchema = Type.Object({
|
|||
browserActions.map((action) => Type.Literal(action)),
|
||||
{ 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(
|
||||
Type.Union(
|
||||
browserSnapshotModes.map((mode) => Type.Literal(mode)),
|
||||
|
|
@ -43,7 +50,9 @@ const browserSchema = Type.Object({
|
|||
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" })),
|
||||
ms: Type.Optional(
|
||||
Type.Number({
|
||||
|
|
@ -59,13 +68,17 @@ const browserSchema = Type.Object({
|
|||
),
|
||||
path: Type.Optional(
|
||||
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(
|
||||
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;
|
||||
}
|
||||
|
||||
type BrowserCommandContextWithoutProfile = Omit<BrowserCommandContext, "profilePath">;
|
||||
type BrowserCommandContextWithoutProfile = Omit<
|
||||
BrowserCommandContext,
|
||||
"profilePath"
|
||||
>;
|
||||
|
||||
function resolveCommandPath(cwd: string, inputPath: string): string {
|
||||
return resolve(cwd, inputPath);
|
||||
|
|
@ -197,13 +213,18 @@ function getBrowserRootDir(options?: BrowserToolOptions): string {
|
|||
return join(baseAgentDir, "browser");
|
||||
}
|
||||
|
||||
function getBrowserProfilePath(cwd: string, options?: BrowserToolOptions): string {
|
||||
const profilePath = options?.profileDir ?? join(getBrowserRootDir(options), "profile");
|
||||
function getBrowserProfilePath(
|
||||
cwd: string,
|
||||
options?: BrowserToolOptions,
|
||||
): string {
|
||||
const profilePath =
|
||||
options?.profileDir ?? join(getBrowserRootDir(options), "profile");
|
||||
return resolveCommandPath(cwd, profilePath);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -222,8 +243,12 @@ function sanitizeStateName(stateName: string): string {
|
|||
throw new Error("stateName is required for browser state actions");
|
||||
}
|
||||
|
||||
const withoutJsonSuffix = trimmed.endsWith(".json") ? trimmed.slice(0, -".json".length) : trimmed;
|
||||
const sanitized = withoutJsonSuffix.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const withoutJsonSuffix = trimmed.endsWith(".json")
|
||||
? trimmed.slice(0, -".json".length)
|
||||
: trimmed;
|
||||
const sanitized = withoutJsonSuffix
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
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 = [
|
||||
input.ref !== undefined ? "ref" : 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);
|
||||
|
||||
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) {
|
||||
|
|
@ -313,7 +343,10 @@ function buildBrowserCommand(
|
|||
}
|
||||
case "snapshot": {
|
||||
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, {
|
||||
action: input.action,
|
||||
args,
|
||||
|
|
@ -353,7 +386,9 @@ function buildBrowserCommand(
|
|||
});
|
||||
}
|
||||
case "screenshot": {
|
||||
const screenshotPath = input.path ? resolveCommandPath(cwd, input.path) : createTempScreenshotPath();
|
||||
const screenshotPath = input.path
|
||||
? resolveCommandPath(cwd, input.path)
|
||||
: createTempScreenshotPath();
|
||||
const args = [...baseArgs, "screenshot"];
|
||||
if (input.fullPage) {
|
||||
args.push("--full");
|
||||
|
|
@ -372,7 +407,10 @@ function buildBrowserCommand(
|
|||
if (!input.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, {
|
||||
action: input.action,
|
||||
args: [...baseArgs, "state", "save", statePath],
|
||||
|
|
@ -385,9 +423,14 @@ function buildBrowserCommand(
|
|||
if (!input.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)) {
|
||||
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, {
|
||||
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 =
|
||||
exitCode === null
|
||||
? `Browser action "${action}" failed`
|
||||
|
|
@ -430,10 +477,14 @@ function getMissingBrowserCommandMessage(command: string): string {
|
|||
].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 command = options?.command ?? DEFAULT_BROWSER_COMMAND;
|
||||
const defaultTimeoutSeconds = options?.defaultTimeoutSeconds ?? DEFAULT_BROWSER_TIMEOUT_SECONDS;
|
||||
const defaultTimeoutSeconds =
|
||||
options?.defaultTimeoutSeconds ?? DEFAULT_BROWSER_TIMEOUT_SECONDS;
|
||||
|
||||
return {
|
||||
name: "browser",
|
||||
|
|
@ -460,17 +511,23 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
|
|||
const chunks: Buffer[] = [];
|
||||
|
||||
try {
|
||||
const { exitCode } = await operations.exec(command, commandContext.args, {
|
||||
const { exitCode } = await operations.exec(
|
||||
command,
|
||||
commandContext.args,
|
||||
{
|
||||
cwd,
|
||||
env: getShellEnv(),
|
||||
onData: (data) => chunks.push(data),
|
||||
signal,
|
||||
timeout: defaultTimeoutSeconds,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const output = normalizeOutput(chunks);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(buildBrowserErrorMessage(commandContext.action, output, exitCode));
|
||||
throw new Error(
|
||||
buildBrowserErrorMessage(commandContext.action, output, exitCode),
|
||||
);
|
||||
}
|
||||
|
||||
if (commandContext.action === "snapshot") {
|
||||
|
|
@ -489,7 +546,11 @@ export function createBrowserTool(cwd: string, options?: BrowserToolOptions): Ag
|
|||
details,
|
||||
};
|
||||
} 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));
|
||||
}
|
||||
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:")) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ describe("browser tool", () => {
|
|||
const cwd = createTempDir("coding-agent-browser-snapshot-");
|
||||
const profileDir = join(cwd, "profile");
|
||||
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, {
|
||||
operations,
|
||||
|
|
@ -153,7 +155,9 @@ describe("browser tool", () => {
|
|||
browserTool.execute("browser-wait-missing", {
|
||||
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(
|
||||
browserTool.execute("browser-wait-ambiguous", {
|
||||
|
|
@ -161,7 +165,9 @@ describe("browser tool", () => {
|
|||
ref: "@e2",
|
||||
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);
|
||||
});
|
||||
|
|
@ -183,7 +189,13 @@ describe("browser tool", () => {
|
|||
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 () => {
|
||||
|
|
@ -226,7 +238,13 @@ describe("browser tool", () => {
|
|||
})) as ToolResultLike;
|
||||
|
||||
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;
|
||||
expect(details?.statePath).toBe(expectedStatePath);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue