Skip to content

Commit b9f0dd3

Browse files
committed
Merge branch 'main' into stafford/tab-976-harden-pilo-against-web-content-prompt-injection
Resolve import conflict in packages/cli/src/utils.ts (keep both BROWSERS and FIELDS/PiloConfig — all are used). Add fail-safe action-firewall introspection (getFieldMetadata/getFormSubmissionContext) to main's new BiDiBrowser so it satisfies the extended AriaBrowser interface; real BiDi introspection is a follow-up.
2 parents 9312391 + a26880e commit b9f0dd3

28 files changed

Lines changed: 2325 additions & 39 deletions

.github/workflows/build-test.yml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,108 @@ jobs:
356356
path: packages/extension/test-results/
357357
retention-days: 30
358358
if-no-files-found: ignore
359+
360+
smoke-eval:
361+
name: Smoke eval (Pilo agent on example.com)
362+
runs-on: ubuntu-latest
363+
needs: [detect-changes, setup]
364+
if: needs.detect-changes.outputs.cli == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.workflows == 'true'
365+
# id-token: write is required if/when this job is switched to Workload
366+
# Identity Federation (see the auth step below).
367+
permissions:
368+
contents: read
369+
id-token: write
370+
steps:
371+
- name: Checkout code
372+
uses: actions/checkout@v4
373+
374+
- name: Setup Node.js
375+
uses: actions/setup-node@v4
376+
with:
377+
node-version: "22"
378+
379+
- name: Setup pnpm
380+
uses: pnpm/action-setup@v2
381+
with:
382+
version: 9
383+
384+
- name: Get pnpm store directory
385+
shell: bash
386+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
387+
388+
- name: Cache pnpm store
389+
uses: actions/cache@v4
390+
with:
391+
path: ${{ env.STORE_PATH }}
392+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
393+
restore-keys: |
394+
${{ runner.os }}-pnpm-store-
395+
396+
- name: Install dependencies
397+
run: pnpm install --frozen-lockfile
398+
399+
- name: Cache core build
400+
uses: actions/cache@v4
401+
with:
402+
path: |
403+
packages/core/dist
404+
packages/core/src/browser/ariaTree/bundle.ts
405+
key: ${{ runner.os }}-core-dist-${{ github.sha }}
406+
407+
- name: Build CLI
408+
run: pnpm --filter pilo-cli run build
409+
410+
- name: Get Playwright version
411+
id: playwright-version
412+
run: |
413+
set -euo pipefail
414+
version=$(jq -re '.dependencies["playwright"] // empty' package.json)
415+
if [ -z "$version" ]; then
416+
echo "Error: playwright dependency not found in root package.json" >&2
417+
exit 1
418+
fi
419+
echo "version=$version" >> "$GITHUB_OUTPUT"
420+
421+
- name: Cache Playwright browsers
422+
uses: actions/cache@v4
423+
id: playwright-cache
424+
with:
425+
path: ~/.cache/ms-playwright
426+
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
427+
428+
- name: Install Playwright browsers
429+
if: steps.playwright-cache.outputs.cache-hit != 'true'
430+
run: pnpm exec playwright install --with-deps chromium
431+
432+
- name: Install Playwright system dependencies
433+
if: steps.playwright-cache.outputs.cache-hit == 'true'
434+
run: pnpm exec playwright install-deps chromium
435+
436+
# GCP auth for Vertex AI (Gemini). Default path uses a service-account
437+
# JSON secret; once Workload Identity Federation is provisioned for
438+
# nonprod Vertex access, swap `credentials_json` for the WIF inputs
439+
# shown in the commented block below.
440+
- name: Authenticate to Google Cloud
441+
uses: google-github-actions/auth@v2
442+
with:
443+
credentials_json: ${{ secrets.GCP_SMOKE_EVAL_SA_KEY }}
444+
# workload_identity_provider: ${{ vars.GCP_SMOKE_EVAL_WIF_PROVIDER }}
445+
# service_account: ${{ vars.GCP_SMOKE_EVAL_SERVICE_ACCOUNT }}
446+
447+
- name: Run smoke eval
448+
env:
449+
GOOGLE_CLOUD_PROJECT: moz-fx-tabs-nonprod
450+
GOOGLE_CLOUD_REGION: us-central1
451+
SMOKE_EVAL_PROVIDER: vertex
452+
SMOKE_EVAL_MODEL: gemini-2.5-flash
453+
SMOKE_EVAL_TIMEOUT_MS: "180000"
454+
run: pnpm smoke-eval
455+
456+
- name: Upload smoke-eval log
457+
if: always()
458+
uses: actions/upload-artifact@v4
459+
with:
460+
name: smoke-eval-log
461+
path: .smoke-eval-output/
462+
retention-days: 14
463+
if-no-files-found: warn

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dist
77
scripts/docker-build.sh
88
debug
99
coverage
10+
.smoke-eval-output
1011

1112
# Auto-generated files
1213
packages/core/src/browser/ariaTree/bundle.ts

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,26 @@ try {
143143
}
144144
```
145145

146+
### WebDriver BiDi (experimental)
147+
148+
Pilo can connect directly to any browser that supports the [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/) protocol, without requiring Playwright. This is distinct from Pilo's `--channel moz-firefox` option, which uses BiDi _through_ Playwright — the `--browser bidi` mode speaks the BiDi protocol directly over WebSocket with no Playwright dependency in the chain.
149+
150+
Start Firefox with remote debugging enabled:
151+
152+
```bash
153+
# Headless
154+
firefox --remote-debugging-port 9222 --headless --no-remote --profile "$(mktemp -d)"
155+
156+
# Visible (watch the agent work)
157+
firefox --remote-debugging-port 9222 --no-remote --profile "$(mktemp -d)"
158+
```
159+
160+
Then point Pilo at it:
161+
162+
```bash
163+
pilo run --browser bidi --bidi-url "ws://127.0.0.1:9222/session" "what's the weather in Tokyo?"
164+
```
165+
146166
## Features
147167

148168
- 🤖 **Natural Language Control**: Just describe what you want to do in plain English

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"test": "pnpm -r run test",
3737
"check": "pnpm run typecheck && pnpm run test",
3838
"pilo": "pnpm --filter pilo-cli run pilo",
39+
"smoke-eval": "node scripts/smoke-eval/run.mjs",
3940
"dev:extension": "pnpm --filter pilo-extension run dev --",
4041
"dev:server": "pnpm --filter pilo-server run dev"
4142
},

packages/cli/src/commands/run.ts

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -205,37 +205,64 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
205205

206206
// Create browser instance with navigation retry config
207207
// CLI options take precedence over config values
208-
const browser = new PlaywrightBrowser({
209-
browser: browserOption,
210-
bypassCSP: options.bypassCsp ?? cfg.bypass_csp,
211-
channel: options.channel ?? cfg.channel,
212-
executablePath: options.executablePath ?? cfg.executable_path,
213-
blockAds: options.blockAds ?? cfg.block_ads,
214-
blockResources,
215-
headless: options.headless ?? cfg.headless,
216-
proxyServer: options.proxy ?? cfg.proxy,
217-
proxyUsername: options.proxyUsername ?? cfg.proxy_username,
218-
proxyPassword: options.proxyPassword ?? cfg.proxy_password,
219-
pwEndpoint: options.pwEndpoint ?? cfg.pw_endpoint,
220-
pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint,
221-
pwCdpEndpoints:
222-
(options.pwCdpEndpoints as string[] | undefined) ??
223-
cfg.pw_cdp_endpoints ??
224-
(cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined),
225-
actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms,
226-
navigationRetry: {
227-
baseTimeoutMs: options.navigationTimeoutMs ?? cfg.navigation_timeout_ms,
228-
maxTimeoutMs: options.navigationMaxTimeoutMs ?? cfg.navigation_max_timeout_ms,
229-
maxAttempts: options.navigationMaxAttempts ?? cfg.navigation_max_attempts,
230-
timeoutMultiplier: options.navigationTimeoutMultiplier ?? cfg.navigation_timeout_multiplier,
231-
onRetry: (attempt: number, error: Error, nextTimeout: number) => {
232-
console.log(
233-
chalk.yellow(`⚠️ Navigation retry ${attempt}: ${error.message}`),
234-
chalk.gray(`(next timeout: ${Math.round(nextTimeout / 1000)}s)`),
235-
);
208+
let browser;
209+
if (browserOption === "bidi") {
210+
const { BiDiBrowser } = await import("pilo-core");
211+
const bidiUrl = options.bidiUrl ?? cfg.bidi_url;
212+
if (!bidiUrl) {
213+
throw new Error("--bidi-url or PILO_BIDI_URL is required when using --browser bidi");
214+
}
215+
browser = new BiDiBrowser({
216+
bidiUrl,
217+
actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms,
218+
});
219+
} else if (browserOption === "foxcloud") {
220+
const { FoxcloudBrowser } = await import("pilo-core");
221+
const foxcloudUrl = options.foxcloudUrl ?? cfg.foxcloud_url;
222+
if (!foxcloudUrl) {
223+
throw new Error(
224+
"--foxcloud-url or PILO_FOXCLOUD_URL is required when using --browser foxcloud",
225+
);
226+
}
227+
browser = new FoxcloudBrowser({
228+
brokerUrl: foxcloudUrl,
229+
proxyUrl: options.foxcloudProxyUrl ?? cfg.foxcloud_proxy_url,
230+
actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms,
231+
});
232+
} else {
233+
browser = new PlaywrightBrowser({
234+
browser: browserOption,
235+
bypassCSP: options.bypassCsp ?? cfg.bypass_csp,
236+
channel: options.channel ?? cfg.channel,
237+
executablePath: options.executablePath ?? cfg.executable_path,
238+
blockAds: options.blockAds ?? cfg.block_ads,
239+
blockResources,
240+
headless: options.headless ?? cfg.headless,
241+
proxyServer: options.proxy ?? cfg.proxy,
242+
proxyUsername: options.proxyUsername ?? cfg.proxy_username,
243+
proxyPassword: options.proxyPassword ?? cfg.proxy_password,
244+
pwEndpoint: options.pwEndpoint ?? cfg.pw_endpoint,
245+
pwCdpEndpoint: options.pwCdpEndpoint ?? cfg.pw_cdp_endpoint,
246+
pwCdpEndpoints:
247+
(options.pwCdpEndpoints as string[] | undefined) ??
248+
cfg.pw_cdp_endpoints ??
249+
(cfg.pw_cdp_endpoint ? [cfg.pw_cdp_endpoint] : undefined),
250+
actionTimeoutMs: options.actionTimeoutMs ?? cfg.action_timeout_ms,
251+
navigationRetry: {
252+
baseTimeoutMs: options.navigationTimeoutMs ?? cfg.navigation_timeout_ms,
253+
maxTimeoutMs: options.navigationMaxTimeoutMs ?? cfg.navigation_max_timeout_ms,
254+
maxAttempts: options.navigationMaxAttempts ?? cfg.navigation_max_attempts,
255+
timeoutMultiplier:
256+
options.navigationTimeoutMultiplier ?? cfg.navigation_timeout_multiplier,
257+
onRetry: (attempt: number, error: Error, nextTimeout: number) => {
258+
console.log(
259+
chalk.yellow(`⚠️ Navigation retry ${attempt}: ${error.message}`),
260+
chalk.gray(`(next timeout: ${Math.round(nextTimeout / 1000)}s)`),
261+
);
262+
},
236263
},
237-
},
238-
});
264+
});
265+
}
239266

240267
// Create AI provider with CLI overrides (only pass if explicitly set on CLI)
241268
// Unlike other options, we use explicit undefined checks here because

packages/cli/src/utils.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, readFileSync } from "fs";
22
import { dirname, join } from "path";
33
import { fileURLToPath } from "url";
4-
import { FIELDS, type PiloConfig } from "pilo-core";
4+
import { BROWSERS, FIELDS, type PiloConfig } from "pilo-core";
55

66
/**
77
* CLI-specific utilities and helpers
@@ -48,15 +48,14 @@ export function getPackageInfo(): { version: string; name: string; description:
4848
* Validate browser option
4949
*/
5050
export function validateBrowser(browser: string): boolean {
51-
const validBrowsers = ["firefox", "chrome", "chromium", "safari", "webkit", "edge"];
52-
return validBrowsers.includes(browser);
51+
return (BROWSERS as readonly string[]).includes(browser);
5352
}
5453

5554
/**
5655
* Get list of valid browsers
5756
*/
5857
export function getValidBrowsers(): string[] {
59-
return ["firefox", "chrome", "chromium", "safari", "webkit", "edge"];
58+
return [...BROWSERS];
6059
}
6160

6261
/**

packages/cli/test/utils.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,17 @@ describe("CLI Utils", () => {
2828
describe("getValidBrowsers", () => {
2929
it("should return array of valid browsers", () => {
3030
const browsers = getValidBrowsers();
31-
expect(browsers).toEqual(["firefox", "chrome", "chromium", "safari", "webkit", "edge"]);
32-
expect(browsers.length).toBe(6);
31+
expect(browsers).toEqual([
32+
"firefox",
33+
"chrome",
34+
"chromium",
35+
"safari",
36+
"webkit",
37+
"edge",
38+
"bidi",
39+
"foxcloud",
40+
]);
41+
expect(browsers.length).toBe(8);
3342
});
3443
});
3544

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"ollama-ai-provider-v2": "^3.5.0",
4949
"playwright": "1.60.0",
5050
"turndown": "^7.2.2",
51+
"ws": "^8.20.0",
5152
"zod": "^4.3.6"
5253
},
5354
"peerDependencies": {
@@ -63,6 +64,7 @@
6364
"@types/jsdom": "^28.0.1",
6465
"@types/node": "^25.7.0",
6566
"@types/turndown": "^5.0.6",
67+
"@types/ws": "^8.18.1",
6668
"@vitest/coverage-v8": "^4.1.6",
6769
"esbuild": "^0.28.0",
6870
"jsdom": "^29.0.2",

0 commit comments

Comments
 (0)