Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
MetricsCollector,
SecretsRedactor,
PLAYWRIGHT_BROWSERS,
resolveAdvertisedUploadFiles,
} from "pilo-core";
import type {
FileUploadConfig,
Expand Down Expand Up @@ -201,6 +202,8 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
? { allowedPaths: uploadAllowedPaths }
: false;

const advertisedUploadFiles = await resolveAdvertisedUploadFiles(allowFileUpload);
Comment thread
lmorchard marked this conversation as resolved.

// Create logger
const loggerType = options.logger ?? cfg.logger;
const metricsIncremental = options.metricsIncremental ?? cfg.metrics_incremental;
Expand Down Expand Up @@ -345,6 +348,7 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
trustedHostnames: options.trustedHostnames ?? cfg.trusted_hostnames,
unsafeMode: options.unsafe ?? cfg.unsafe_mode,
allowFileUpload,
advertisedUploadFiles,
providerConfig,
logger,
eventEmitter,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ export type { ProviderConfig } from "./provider.js";

// Config merge utilities
export { mergeWithDefaults, createNavigationRetryConfig } from "./utils/configMerge.js";

// Upload file utilities (Node-only)
export { resolveAdvertisedUploadFiles } from "./utils/uploadFiles.js";
34 changes: 30 additions & 4 deletions packages/core/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,12 @@ function buildToolExamples(
hasWebSearch: boolean,
hasTabstack: boolean = false,
hasInteractive: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
): string {
const lines = [
`- click({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}"}) - ${TOOL_STRINGS.webActions.click.description}`,
`- fill({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}", "value": "text"}) - ${TOOL_STRINGS.webActions.fill.description}`,
`- upload_file({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}", "path": "/path/to/file"}) - ${TOOL_STRINGS.webActions.uploadFile.description}`,
`- select({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}", "value": "option"}) - ${TOOL_STRINGS.webActions.select.description}`,
`- hover({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}"}) - ${TOOL_STRINGS.webActions.hover.description}`,
`- check({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}"}) - ${TOOL_STRINGS.webActions.check.description}`,
Expand All @@ -193,6 +194,15 @@ function buildToolExamples(
`- extract({"description": "data to extract"}) - ${TOOL_STRINGS.webActions.extract.description}`,
];

if (hasFileUpload) {
let uploadLine = `- upload_file({"ref": "${TOOL_STRINGS.webActions.common.elementRefExample}", "path": "/path/to/file"}) - ${TOOL_STRINGS.webActions.uploadFile.description}`;
if (advertisedUploadFiles.length > 0) {
uploadLine += `\n Files available to upload (use one of these exact paths as "path"): ${advertisedUploadFiles.join(", ")}`;
}
// Insert just after the fill() example (index 1) to preserve ordering.
lines.splice(2, 0, uploadLine);
}

if (hasWebSearch) {
lines.push(
`- webSearch({"query": "search terms"}) - ${TOOL_STRINGS.webActions.webSearch.description}`,
Expand Down Expand Up @@ -431,21 +441,29 @@ ${toolCallInstruction}
`.trim(),
);

/** Build action system prompt with optional guardrails, web search, Tabstack, and interactive tools. */
/** Build action system prompt with optional guardrails, web search, Tabstack, interactive tools, and file upload. */
const buildActionLoopSystemPrompt = (
hasGuardrails: boolean,
hasWebSearch: boolean = false,
hasTabstack: boolean = false,
hasStartingUrl: boolean = false,
hasInteractive: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
) =>
actionLoopSystemPromptTemplate({
hasGuardrails,
hasWebSearch,
hasTabstack,
hasStartingUrl,
hasInteractive,
toolExamples: buildToolExamples(hasWebSearch, hasTabstack, hasInteractive),
toolExamples: buildToolExamples(
hasWebSearch,
hasTabstack,
hasInteractive,
hasFileUpload,
advertisedUploadFiles,
),
currentDate: getCurrentFormattedDate(),
});

Expand Down Expand Up @@ -572,11 +590,19 @@ export const buildStepErrorFeedbackPrompt = (
hasGuardrails: boolean = false,
hasWebSearch: boolean = false,
hasTabstack: boolean = false,
hasFileUpload: boolean = false,
advertisedUploadFiles: string[] = [],
) =>
stepErrorFeedbackTemplate({
error,
hasGuardrails,
toolExamples: buildToolExamples(hasWebSearch, hasTabstack),
toolExamples: buildToolExamples(
hasWebSearch,
hasTabstack,
false,
hasFileUpload,
advertisedUploadFiles,
),
});

/**
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/utils/uploadFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { FileUploadConfig } from "../browser/ariaBrowser.js";

/**
* Resolve the subset of upload-allowlist entries that are existing regular files,
* as absolute paths. Directory entries and missing/unreadable paths are skipped.
*
* Used to advertise concrete uploadable file paths to the agent. Node-only
* (filesystem access): exported from `index.ts`, never from the browser-safe
* `core.ts` barrel.
*/
export async function resolveAdvertisedUploadFiles(
allowFileUpload: false | FileUploadConfig,
): Promise<string[]> {
if (!allowFileUpload || allowFileUpload.allowedPaths.length === 0) {
return [];
}
const files: string[] = [];
for (const entry of allowFileUpload.allowedPaths) {
try {
const resolved = path.resolve(entry);
const stat = await fs.stat(resolved);
if (stat.isFile()) {
files.push(resolved);
}
} catch {
// Missing or unreadable entry — cannot be advertised.
}
}
return files;
}
17 changes: 17 additions & 0 deletions packages/core/src/webAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ export interface WebAgentOptions {
* roots.
*/
allowFileUpload?: false | FileUploadConfig;
/**
* Concrete local file paths the agent may upload, surfaced in its prompt so it
* knows what is available. Derived from `allowFileUpload` by the caller
* (CLI/server) via `resolveAdvertisedUploadFiles`. Empty when uploads are
* disabled or the allowlist names only directories.
*/
advertisedUploadFiles?: string[];
/** Timeout for LLM provider calls in milliseconds (default: from config) */
llmProviderTimeoutMs?: number;
/**
Expand Down Expand Up @@ -296,6 +303,7 @@ export class WebAgent {
private readonly taskId: string | undefined;
private readonly firewall: FirewallConfig;
private readonly allowFileUpload: false | FileUploadConfig;
private readonly advertisedUploadFiles: string[];
private readonly llmProviderTimeoutMs: number;
private readonly iterationTimeoutMs: number;
// Host of the caller-provided start URL (options.startingUrl), captured at
Expand Down Expand Up @@ -341,6 +349,7 @@ export class WebAgent {
unsafeMode: Boolean(options.unsafeMode),
});
this.allowFileUpload = options.allowFileUpload ?? false;
this.advertisedUploadFiles = options.advertisedUploadFiles ?? [];
this.llmProviderTimeoutMs = options.llmProviderTimeoutMs ?? defaults.llm_provider_timeout_ms;
this.iterationTimeoutMs = options.iterationTimeoutMs ?? DEFAULT_ITERATION_TIMEOUT_MS;

Expand Down Expand Up @@ -874,10 +883,16 @@ export class WebAgent {
Boolean(this.guardrails),
this.searchProvider !== "none",
Boolean(this.tabstackApiKey),
this.hasFileUpload,
this.advertisedUploadFiles,
);
this.messages.push({ role: "user", content: errorFeedback });
}

private get hasFileUpload(): boolean {
return this.allowFileUpload !== false && this.allowFileUpload.allowedPaths.length > 0;
}

/** Remove image parts from the most recent user message (fallback for AI SDK image crashes). */
private stripImagesFromLastMessage(): void {
for (let i = this.messages.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -1923,6 +1938,8 @@ export class WebAgent {
hasTabstack,
hasStartingUrl,
hasInteractive,
this.hasFileUpload,
this.advertisedUploadFiles,
);

this.messages = [
Expand Down
32 changes: 32 additions & 0 deletions packages/core/test/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,38 @@ describe("prompts", () => {
});
});

describe("upload_file tool surfacing", () => {
it("omits upload_file when uploads are disabled", () => {
const prompt = buildActionLoopSystemPrompt(false, false, false, false, false, false, []);
expect(prompt).not.toContain("upload_file(");
});

it("shows upload_file when enabled, without a files note if none advertised", () => {
const prompt = buildActionLoopSystemPrompt(false, false, false, false, false, true, []);
expect(prompt).toContain("upload_file(");
expect(prompt).not.toContain("Files available to upload");
});

it("lists advertised files when present", () => {
const prompt = buildActionLoopSystemPrompt(false, false, false, false, false, true, [
"/fixtures/sample.txt",
]);
expect(prompt).toContain("upload_file(");
expect(prompt).toContain("Files available to upload");
expect(prompt).toContain("/fixtures/sample.txt");
});

it("error-feedback prompt also gates upload_file on enablement", () => {
const disabled = buildStepErrorFeedbackPrompt("boom", false, false, false, false, []);
expect(disabled).not.toContain("upload_file(");
const enabled = buildStepErrorFeedbackPrompt("boom", false, false, false, true, [
"/fixtures/sample.txt",
]);
expect(enabled).toContain("upload_file(");
expect(enabled).toContain("/fixtures/sample.txt");
});
});

describe("Template consistency", () => {
it("should use consistent prompt style across functions", () => {
const prompts = [
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/utils/uploadFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { resolveAdvertisedUploadFiles } from "../../src/utils/uploadFiles.js";

describe("resolveAdvertisedUploadFiles", () => {
let dir: string;
let filePath: string;

beforeAll(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "pilo-upload-"));
filePath = path.join(dir, "sample.txt");
await fs.writeFile(filePath, "x");
});

afterAll(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

it("returns [] when uploads are disabled", async () => {
expect(await resolveAdvertisedUploadFiles(false)).toEqual([]);
});

it("returns [] when the allowlist is empty", async () => {
expect(await resolveAdvertisedUploadFiles({ allowedPaths: [] })).toEqual([]);
});

it("includes file entries, resolved to absolute paths", async () => {
const result = await resolveAdvertisedUploadFiles({ allowedPaths: [filePath] });
expect(result).toEqual([path.resolve(filePath)]);
});

it("excludes directory entries", async () => {
const result = await resolveAdvertisedUploadFiles({ allowedPaths: [dir] });
expect(result).toEqual([]);
});

it("skips missing or unreadable entries", async () => {
const result = await resolveAdvertisedUploadFiles({
allowedPaths: [path.join(dir, "does-not-exist.txt"), filePath],
});
expect(result).toEqual([path.resolve(filePath)]);
});
});
1 change: 1 addition & 0 deletions packages/server/src/routes/pilo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock("pilo-core", () => ({
DEFAULT_VISION: false,
DEFAULT_MAX_ITERATIONS: 50,
DEFAULT_MAX_VALIDATION_ATTEMPTS: 3,
resolveAdvertisedUploadFiles: vi.fn().mockResolvedValue([]),
}));

// Mock the AI SDK
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/taskRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ vi.mock("pilo-core", () => {
})),
SEARCH_PROVIDERS: ["none", "duckduckgo", "google", "bing", "parallel-api"],
PLAYWRIGHT_BROWSERS: ["firefox", "chrome", "chromium", "safari", "webkit", "edge"],
resolveAdvertisedUploadFiles: vi.fn().mockResolvedValue([]),
};
});

Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/taskRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RecoverableError,
SEARCH_PROVIDERS,
PLAYWRIGHT_BROWSERS,
resolveAdvertisedUploadFiles,
} from "pilo-core";
import type { FileUploadConfig, TaskExecutionResult, UserDataCallback } from "pilo-core";
import * as path from "node:path";
Expand Down Expand Up @@ -323,6 +324,7 @@ export async function runTask(options: TaskRunnerOptions): Promise<TaskExecution
);
const allowFileUpload: false | FileUploadConfig =
uploadAllowedPaths.length > 0 ? { allowedPaths: uploadAllowedPaths } : false;
const advertisedUploadFiles = await resolveAdvertisedUploadFiles(allowFileUpload);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified, and the real issue is incomplete test mocks rather than the production call. pilo-core is a workspace:* dependency, so resolveAdvertisedUploadFiles is always present at runtime — there's no real version-mismatch path. The only place it's undefined is hand-written vi.mock("pilo-core", () => ({...})) factories that omit it; guarding the production call to accommodate those would degrade production code to satisfy test fakes.

Fixed by completing the mocks instead. You flagged routes/pilo.test.ts; the same gap also existed in routes/piloWs.test.ts (the WS route also calls runTask) — both now export resolveAdvertisedUploadFiles. (sensitive-data-leak.test.ts and taskRunner.test.ts already had it.) Note the routes/* suites were green before this because /pilo/run is an SSE stream: status 200 is sent when the stream opens, so the TypeError was thrown inside the stream callback and masked by tests that only assert the status, not the streamed events.

The CLI suite (run.test.ts) uses importOriginal() + spread, so it already had the real export — no change needed there.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correction to my earlier reply: I'd also touched routes/piloWs.test.ts, but that was unnecessary and I've reverted it. That suite mocks ../taskRunner.js outright (runTask → a local vi.fn()), so the real runTask — and thus resolveAdvertisedUploadFiles — never runs there; its pilo-core mock didn't need the export. The only suite that genuinely hit the masked TypeError is routes/pilo.test.ts (real runTask via the SSE route), and that's the one fixed here. Force-pushed; the diff now only adds the export to routes/pilo.test.ts.


const browserConfig = {
browser: browserName as (typeof PLAYWRIGHT_BROWSERS)[number],
Expand Down Expand Up @@ -368,6 +370,7 @@ export async function runTask(options: TaskRunnerOptions): Promise<TaskExecution
trustedHostnames: body.trustedHostnames ?? serverConfig.trusted_hostnames,
unsafeMode: body.unsafeMode ?? serverConfig.unsafe_mode,
allowFileUpload,
advertisedUploadFiles,
llmProviderTimeoutMs: body.llmProviderTimeoutMs ?? serverConfig.llm_provider_timeout_ms,
guardrails: body.guardrails,
searchProvider: body.searchProvider ?? serverConfig.search_provider,
Expand Down
1 change: 1 addition & 0 deletions packages/server/test/sensitive-data-leak.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ vi.mock("pilo-core", () => {
SEARCH_PROVIDERS: ["none", "duckduckgo", "google", "bing", "parallel-api"],
PLAYWRIGHT_BROWSERS: ["firefox", "chrome", "chromium", "safari", "webkit", "edge"],
withRemoteContext: vi.fn((_headers: unknown, fn: () => unknown) => fn()),
resolveAdvertisedUploadFiles: vi.fn().mockResolvedValue([]),
};
});

Expand Down
Loading