Skip to content
Open
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
17 changes: 13 additions & 4 deletions packages/core/lib/v3/agent/tools/goto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { tool } from "ai";
import { z } from "zod";
import type { V3 } from "../../v3.js";
import type { Variables } from "../../types/public/agent.js";
import { substituteVariables } from "../utils/variables.js";

export const gotoTool = (v3: V3) =>
tool({
export const gotoTool = (v3: V3, variables?: Variables) => {
const hasVariables = variables && Object.keys(variables).length > 0;
const urlDescription = hasVariables
? `The URL to navigate to. Use %variableName% to substitute a variable value. Available: ${Object.keys(variables).join(", ")}`
: "The URL to navigate to";

return tool({
description: "Navigate to a specific URL",
inputSchema: z.object({
url: z.string().describe("The URL to navigate to"),
url: z.string().describe(urlDescription),
}),
execute: async ({ url }) => {
try {
Expand All @@ -21,12 +28,14 @@ export const gotoTool = (v3: V3) =>
},
},
});
const resolvedUrl = substituteVariables(url, variables);
const page = await v3.context.awaitActivePage();
await page.goto(url, { waitUntil: "load" });
await page.goto(resolvedUrl, { waitUntil: "load" });
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Exception and error message sanitization

goto now substitutes variable values into URL, then returns raw page.goto error messages that can include the resolved URL and leak secret variable values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/agent/tools/goto.ts, line 33:

<comment>goto now substitutes variable values into URL, then returns raw page.goto error messages that can include the resolved URL and leak secret variable values.</comment>

<file context>
@@ -21,12 +28,14 @@ export const gotoTool = (v3: V3) =>
+        const resolvedUrl = substituteVariables(url, variables);
         const page = await v3.context.awaitActivePage();
-        await page.goto(url, { waitUntil: "load" });
+        await page.goto(resolvedUrl, { waitUntil: "load" });
         v3.recordAgentReplayStep({ type: "goto", url, waitUntil: "load" });
         return { success: true, url };
</file context>
Fix with Cubic

v3.recordAgentReplayStep({ type: "goto", url, waitUntil: "load" });
return { success: true, url };
} catch (error) {
return { success: false, error: error?.message ?? String(error) };
}
},
});
};
2 changes: 1 addition & 1 deletion packages/core/lib/v3/agent/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function createAgentTools(v3: V3, options?: V3AgentToolOptions) {
extract: extractTool(v3, executionModel, toolTimeout),
fillForm: fillFormTool(v3, executionModel, variables, toolTimeout),
fillFormVision: fillFormVisionTool(v3, provider, variables),
goto: gotoTool(v3),
goto: gotoTool(v3, variables),
keys: keysTool(v3, variables),
navback: navBackTool(v3),
screenshot: screenshotTool(v3),
Expand Down
10 changes: 8 additions & 2 deletions packages/core/lib/v3/cache/AgentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,11 @@ export class AgentCache {
variables,
);
case "goto":
await this.replayAgentGotoStep(step as AgentReplayGotoStep, ctx);
await this.replayAgentGotoStep(
step as AgentReplayGotoStep,
ctx,
variables,
);
return step;
case "scroll":
await this.replayAgentScrollStep(step as AgentReplayScrollStep, ctx);
Expand Down Expand Up @@ -771,9 +775,11 @@ export class AgentCache {
private async replayAgentGotoStep(
step: AgentReplayGotoStep,
ctx: V3Context,
variables?: Record<string, string>,
): Promise<void> {
const page = await ctx.awaitActivePage();
await page.goto(step.url, { waitUntil: step.waitUntil ?? "load" });
const resolvedUrl = substituteVariables(step.url, variables);
await page.goto(resolvedUrl, { waitUntil: step.waitUntil ?? "load" });
}

private async replayAgentScrollStep(
Expand Down
68 changes: 68 additions & 0 deletions packages/core/tests/unit/cache-llm-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,72 @@ describe("Cache LLM client selection", () => {
waitUntil: "load",
});
});

it("AgentCache substitutes variables for goto steps during replay", async () => {
const gotoEntry: CachedAgentEntry = {
version: 1,
instruction: "navigate with vars",
startUrl: "https://example.com/source",
options: {},
configSignature: "sig",
steps: [
{
type: "goto",
url: "https://example.com/%path%?token=%token%",
waitUntil: "load",
},
],
result: { success: true, actions: [] } as AgentResult,
timestamp: new Date().toISOString(),
};

const storage = {
enabled: true,
readJson: vi.fn().mockResolvedValue({ value: gotoEntry }),
writeJson: vi.fn().mockResolvedValue({}),
directory: "/tmp/cache",
} as unknown as CacheStorage;

const handler = {
takeDeterministicAction: vi.fn(),
} as unknown as ActHandler;

const fakePage = { goto: vi.fn() } as unknown as Page;
const ctx = {
awaitActivePage: vi.fn().mockResolvedValue(fakePage),
} as unknown as V3Context;

const cache = new AgentCache({
storage,
logger: vi.fn(),
getActHandler: () => handler,
getContext: () => ctx,
getDefaultLlmClient: () => ({ id: "default" }) as unknown as LLMClient,
getBaseModelName: () => "openai/gpt-4.1-mini" as AvailableModel,
getSystemPrompt: () => undefined,
domSettleTimeoutMs: undefined,
act: vi.fn(),
});

const context: AgentCacheContext = {
instruction: "navigate with vars",
startUrl: "https://example.com/source",
options: {},
configSignature: "sig",
cacheKey: "agent-goto-vars",
variableKeys: ["path", "token"],
variables: { path: "target", token: "abc123" },
};

const result = await cache.tryReplay(context);

expect(result?.success).toBe(true);
expect(handler.takeDeterministicAction).not.toHaveBeenCalled();
expect(fakePage.goto).toHaveBeenCalledWith(
"https://example.com/target?token=abc123",
{
waitUntil: "load",
},
);
});
});
44 changes: 44 additions & 0 deletions packages/core/tests/unit/goto-tool-variables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import type { Variables } from "../../lib/v3/types/public/agent.js";
import type { Page } from "../../lib/v3/understudy/page.js";
import type { V3 } from "../../lib/v3/v3.js";
import { gotoTool } from "../../lib/v3/agent/tools/goto.js";

describe("gotoTool variables", () => {
it("substitutes variables for navigation and preserves template URL in replay", async () => {
const goto = vi.fn().mockResolvedValue(undefined);
const page = { goto } as unknown as Page;
const recordAgentReplayStep = vi.fn();

const v3 = {
logger: vi.fn(),
recordAgentReplayStep,
context: {
awaitActivePage: vi.fn().mockResolvedValue(page),
},
} as unknown as V3;

const variables: Variables = {
host: "example.com",
path: "login",
};

const tool = gotoTool(v3, variables);
const result = await tool.execute({
url: "https://%host%/%path%",
});

expect(goto).toHaveBeenCalledWith("https://example.com/login", {
waitUntil: "load",
});
expect(recordAgentReplayStep).toHaveBeenCalledWith({
type: "goto",
url: "https://%host%/%path%",
waitUntil: "load",
});
expect(result).toEqual({
success: true,
url: "https://%host%/%path%",
});
});
});
12 changes: 12 additions & 0 deletions packages/server-v4/openapi.v4.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,12 @@ components:
enum:
- jsevent
- xy
expectDownload:
type: boolean
expectNavigation:
type: boolean
expectPopup:
type: boolean
required:
- selector
additionalProperties: false
Expand Down Expand Up @@ -6585,6 +6591,12 @@ components:
enum:
- jsevent
- xy
expectDownload:
type: boolean
expectNavigation:
type: boolean
expectPopup:
type: boolean
required:
- selector
- method
Expand Down
4 changes: 3 additions & 1 deletion packages/server-v4/src/schemas/v4/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ export const PageClickParamsSchema = PageWithPageIdSchema.extend({
clickCount: z.number().int().lte(3).gte(1).optional(),
returnSelector: z.boolean().default(false).optional(),
method: z.enum(["jsevent", "xy"]).default("xy"),
// TODO: add expectDownload, expectNavigation, expectPopup OR expect: z.enum(...)
expectDownload: z.boolean().optional(),
expectNavigation: z.boolean().optional(),
expectPopup: z.boolean().optional(),
})
.strict()
.meta({ id: "PageClickParams" });
Expand Down
16 changes: 16 additions & 0 deletions packages/server-v4/test/integration/v4/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,22 @@ describe("v4 page routes", { concurrency: false }, () => {
assertSuccessAction(jseventCtx, "click");
});

it("POST /v4/page/click accepts expect* options", async () => {
const gotoCtx = await postPageRoute("goto", sessionId, {
url: CLICK_TEST_URL,
waitUntil: "load",
});
assertSuccessAction(gotoCtx, "goto");

const clickCtx = await postPageRoute("click", sessionId, {
selector: { css: "#click-target" },
expectNavigation: true,
expectPopup: false,
expectDownload: false,
});
assertSuccessAction(clickCtx, "click");
});

it("POST /v4/page/dragAndDrop accepts mixed selector types (xpath from, coordinates to)", async () => {
const gotoCtx = await postPageRoute("goto", sessionId, {
url: METHODS_TEST_URL,
Expand Down
Loading